diff --git a/PureMac.xcodeproj/project.pbxproj b/PureMac.xcodeproj/project.pbxproj index da6d40f..d4700db 100644 --- a/PureMac.xcodeproj/project.pbxproj +++ b/PureMac.xcodeproj/project.pbxproj @@ -15,14 +15,16 @@ 47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */; }; 48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8DE5D45BA19E2670B57DC5 /* CleaningEngine.swift */; }; 4F754D89F4CE5142BE384062 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F91226CDCFBB8E303B7DA /* AppConstants.swift */; }; + 535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */; }; 75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E6BC61614B27C065BB18C9 /* AppListView.swift */; }; 76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F7B28624C271620E13BBC /* EmptyStateView.swift */; }; 826A750D2D7EC14C2AE306A3 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3667D46D8E2004EB4D73835A /* Models.swift */; }; 8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB94E06E145558123BB5BFB3 /* Conditions.swift */; }; 93743B036059418560D876E6 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8D39C3C250F26302EC45AB /* SettingsView.swift */; }; + 95278ABDCF5F4D6AC60503B1 /* AppearancePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92259B3E9F4468865F15DEEC /* AppearancePill.swift */; }; 9AA80E035DF7B33F6EE118DF /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED1B71D5F9510582E869CFD /* AppState.swift */; }; 9BB5AAA574AFED6C27A3F8E2 /* AppPathFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0FEE7141871ED5F9E36121 /* AppPathFinder.swift */; }; - A89DF967EC9E5E8123B2925A /* SmartScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BA091943263913276F3CCF /* SmartScanView.swift */; }; + A2AE68CC75CB72D6B10CBDF5 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661A0F64CF93E482CB1728F /* DashboardView.swift */; }; A9C3A1F643C26930F442E729 /* OrphanSafetyPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */; }; B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */; }; BC6C800216343438413349A3 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4FD34378988D430A582ED0 /* OnboardingView.swift */; }; @@ -42,7 +44,7 @@ 01B2C5F66B6D812572BD4F05 /* CLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLI.swift; sourceTree = ""; }; 02E502E2B5C6AECC76E5CFEF /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = ""; }; - 11BA091943263913276F3CCF /* SmartScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartScanView.swift; sourceTree = ""; }; + 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; 1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringNormalization.swift; sourceTree = ""; }; 23486A54A82EE3865B784D1C /* AppInfoFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoFetcher.swift; sourceTree = ""; }; 2641C6376DD6F5889F35510E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -63,6 +65,7 @@ 63581B70F9B10231964E3602 /* PureMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PureMacApp.swift; sourceTree = ""; }; 77D3D9A9BC52839E6D0A22BC /* Locations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locations.swift; sourceTree = ""; }; 798B80977D14647A5691B0A0 /* AppFilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFilesView.swift; sourceTree = ""; }; + 92259B3E9F4468865F15DEEC /* AppearancePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePill.swift; sourceTree = ""; }; 9F04B811BB0012F6D2F07F91 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 9F510F232341EE18F11DC934 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; A711CDF5285F68775D9B5513 /* ScanEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanEngine.swift; sourceTree = ""; }; @@ -75,6 +78,7 @@ E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = ""; }; EEF15CB1B8EFCA78EF491824 /* ScanError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanError.swift; sourceTree = ""; }; F31F91226CDCFBB8E303B7DA /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; + F661A0F64CF93E482CB1728F /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -107,9 +111,9 @@ isa = PBXGroup; children = ( 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */, + F661A0F64CF93E482CB1728F /* DashboardView.swift */, 9F510F232341EE18F11DC934 /* MainWindow.swift */, 3D4FD34378988D430A582ED0 /* OnboardingView.swift */, - 11BA091943263913276F3CCF /* SmartScanView.swift */, DAD2EC78EB98016ADA27D114 /* Apps */, 8AE26715B5748307483A1A5E /* Components */, 9D3C2DC382F5D4B77DFD218B /* Orphans */, @@ -148,6 +152,8 @@ 8AE26715B5748307483A1A5E /* Components */ = { isa = PBXGroup; children = ( + 92259B3E9F4468865F15DEEC /* AppearancePill.swift */, + 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */, 424F7B28624C271620E13BBC /* EmptyStateView.swift */, ); path = Components; @@ -201,11 +207,11 @@ children = ( B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */, 46660271CFF167AB0FE7371D /* Info.plist */, + 241E0895B09C71AB423B2F9E /* Localizable.strings */, 5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */, 63581B70F9B10231964E3602 /* PureMacApp.swift */, D4333B07691BD85CAE0E5B15 /* Core */, 7C1729F88C0E5563E1A3DB40 /* Extensions */, - 241E0895B09C71AB423B2F9E /* Localizable.strings */, F283C00EB52AB140F61500A3 /* Logic */, 3CF46713F75B81F0F86D1C6F /* Models */, 6184B2EC3D01E6E95633406E /* Services */, @@ -273,6 +279,7 @@ }; }; buildConfigurationList = 2ABAFAE07AA42044AE58F688 /* Build configuration list for PBXProject "PureMac" */; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -286,7 +293,6 @@ mainGroup = 13CF0676D0E93925F46C13AA; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; - productRefGroup = 4562CA9E5625FA4EEEFECB6D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -318,10 +324,13 @@ 75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */, 9BB5AAA574AFED6C27A3F8E2 /* AppPathFinder.swift in Sources */, 9AA80E035DF7B33F6EE118DF /* AppState.swift in Sources */, + 535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */, + 95278ABDCF5F4D6AC60503B1 /* AppearancePill.swift in Sources */, F2FA881A4B2209CC2A6342FB /* CLI.swift in Sources */, B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */, 48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */, 8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */, + A2AE68CC75CB72D6B10CBDF5 /* DashboardView.swift in Sources */, 76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */, 2253F11BDF561B617439C96B /* FullDiskAccessManager.swift in Sources */, E60B0A2C5D0A6CAE35BF4DFB /* Locations.swift in Sources */, @@ -336,7 +345,6 @@ D50EB059E741011EB2523731 /* ScanError.swift in Sources */, 27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */, 93743B036059418560D876E6 /* SettingsView.swift in Sources */, - A89DF967EC9E5E8123B2925A /* SmartScanView.swift in Sources */, 47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */, 340E424F759ACCDE7372F99F /* Theme.swift in Sources */, ); diff --git a/PureMac/Info.plist b/PureMac/Info.plist index 37c8bfe..e9c7374 100644 --- a/PureMac/Info.plist +++ b/PureMac/Info.plist @@ -24,5 +24,17 @@ public.app-category.utilities NSHumanReadableCopyright Copyright 2026. MIT License. + NSDesktopFolderUsageDescription + PureMac needs access to your Desktop to find junk and orphaned files. + NSDocumentsFolderUsageDescription + PureMac needs access to your Documents to find junk and orphaned files. + NSDownloadsFolderUsageDescription + PureMac needs access to your Downloads to find junk and orphaned files. + NSRemovableVolumesUsageDescription + PureMac needs access to removable volumes to scan for junk files. + NSSystemAdministrationUsageDescription + PureMac needs system administration access to clean caches and uninstall apps completely. + NSAppleEventsUsageDescription + PureMac uses Apple Events to interact with Finder when revealing files. diff --git a/PureMac/PureMacApp.swift b/PureMac/PureMacApp.swift index fde9bff..a175ed8 100644 --- a/PureMac/PureMacApp.swift +++ b/PureMac/PureMacApp.swift @@ -6,6 +6,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSWindow.allowsAutomaticWindowTabbing = false + // Touch TCC-protected paths so macOS registers PureMac in the + // Full Disk Access pane on first launch (fixes issue #75). + FullDiskAccessManager.shared.triggerRegistration() } } @@ -13,6 +16,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { struct PureMacApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var appState = AppState() + @StateObject private var theme = ThemeManager.shared @AppStorage("PureMac.OnboardingComplete") private var onboardingComplete = false init() { @@ -27,13 +31,17 @@ struct PureMacApp: App { var body: some Scene { WindowGroup { - if onboardingComplete { - MainWindow() - .environmentObject(appState) - .frame(minWidth: 900, minHeight: 600) - } else { - OnboardingView(isComplete: $onboardingComplete) + Group { + if onboardingComplete { + MainWindow() + .environmentObject(appState) + .frame(minWidth: 900, minHeight: 600) + } else { + OnboardingView(isComplete: $onboardingComplete) + } } + .environmentObject(theme) + .preferredColorScheme(theme.appearance.colorScheme) } .windowStyle(.automatic) .windowToolbarStyle(.unified) diff --git a/PureMac/Services/CleaningEngine.swift b/PureMac/Services/CleaningEngine.swift index c50a450..8ce28eb 100644 --- a/PureMac/Services/CleaningEngine.swift +++ b/PureMac/Services/CleaningEngine.swift @@ -7,6 +7,11 @@ actor CleaningEngine { var freedSpace: Int64 = 0 var itemsCleaned: Int = 0 var errors: [String] = [] + var cleanedPaths: Set = [] + // Items that user-level FileManager.removeItem refused with EACCES / + // EPERM. These are root-owned and need an admin-privileged second + // pass via cleanWithAdminPrivileges(items:). + var requiresAdmin: [CleanableItem] = [] } // MARK: - Public API @@ -67,8 +72,25 @@ actor CleaningEngine { try fileManager.removeItem(at: resolvedURL) result.freedSpace += item.size result.itemsCleaned += 1 + result.cleanedPaths.insert(item.path) } catch { - result.errors.append("\(item.name): \(error.localizedDescription)") + let nsError = error as NSError + let isPermissionDenied = + (nsError.domain == NSCocoaErrorDomain && + (nsError.code == NSFileWriteNoPermissionError || + nsError.code == NSFileReadNoPermissionError)) || + (nsError.domain == NSPOSIXErrorDomain && + (nsError.code == Int(EACCES) || nsError.code == Int(EPERM))) + if isPermissionDenied { + // Defer to the admin pass — these are typically root-owned + // system caches that the user-level process can't unlink. + result.requiresAdmin.append(item) + Logger.shared.log("Deferring to admin pass: \(item.path)", level: .info) + } else { + let detail = "\(item.name) at \(item.path): \(error.localizedDescription)" + result.errors.append(detail) + Logger.shared.log("Clean failed: \(detail)", level: .error) + } } } @@ -80,6 +102,99 @@ actor CleaningEngine { return await cleanItems(selectedItems, progressHandler: progressHandler) } + /// Re-runs the deletion of the supplied items as root via NSAppleScript's + /// "with administrator privileges" clause. Triggers exactly one auth + /// prompt for the whole batch (macOS caches the credential for ~5 min). + /// + /// Every path is re-validated against the same allow-list as the user- + /// level pass (isSafeToDelete / isExplicitSingleFileDeletable) before it + /// gets handed off to /bin/rm. Paths are passed via a NUL-separated + /// temp file consumed by xargs -0, so no shell-quoting pitfalls. + func cleanWithAdminPrivileges(items: [CleanableItem]) async -> CleaningResult { + var result = CleaningResult() + + Logger.shared.log("Admin pass starting with \(items.count) item(s)", level: .info) + + // Re-validate. Don't trust the caller — anything not on the allow-list + // refuses to escalate. + let validated: [(item: CleanableItem, resolved: String)] = items.compactMap { item in + let resolved = URL(fileURLWithPath: item.path).resolvingSymlinksInPath().path + let accepted: Bool = { + if item.category == .largeFiles { + return isExplicitSingleFileDeletable(resolvedPath: resolved) + } + return isSafeToDelete(resolvedPath: resolved) + }() + if !accepted { + Logger.shared.log("Refusing admin escalation for unsafe path: \(item.path)", level: .warning) + } + return accepted ? (item, resolved) : nil + } + guard !validated.isEmpty else { + Logger.shared.log("Admin pass: no items survived validation", level: .warning) + return result + } + + // Stage paths NUL-separated so newlines/spaces in paths don't matter. + let staged = validated.map(\.resolved).joined(separator: "\u{0}") + guard let payload = staged.data(using: .utf8) else { return result } + + let tempFile = FileManager.default.temporaryDirectory + .appendingPathComponent("puremac-rm-\(UUID().uuidString)") + do { + try payload.write(to: tempFile, options: [.atomic]) + } catch { + Logger.shared.log("Couldn't stage admin path list: \(error.localizedDescription)", level: .error) + return result + } + defer { try? FileManager.default.removeItem(at: tempFile) } + + // UUIDs are alphanumeric + hyphens, NSTemporaryDirectory is a known + // path with no shell metacharacters, so direct embedding is safe. + let script = """ + do shell script "/usr/bin/xargs -0 /bin/rm -rf -- < \(tempFile.path)" with administrator privileges + """ + + let runResult: (success: Bool, error: String?) = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let appleScript = NSAppleScript(source: script) + var errorInfo: NSDictionary? + appleScript?.executeAndReturnError(&errorInfo) + if let errorInfo { + continuation.resume(returning: (false, "\(errorInfo)")) + } else { + continuation.resume(returning: (true, nil)) + } + } + } + + guard runResult.success else { + // -128 is "user cancelled" — log quietly, no need for an error row. + if let err = runResult.error, !err.contains("-128") { + Logger.shared.log("Admin clean failed: \(err)", level: .error) + result.errors.append("Administrator authorization failed") + } + return result + } + + // Verify which items actually disappeared. xargs may have reported a + // partial failure even when the AppleScript exited cleanly, so we + // re-stat every path rather than trust the script's exit status. + for (item, resolved) in validated { + if !FileManager.default.fileExists(atPath: resolved) { + result.cleanedPaths.insert(item.path) + result.itemsCleaned += 1 + result.freedSpace += item.size + } else { + let detail = "\(item.name) at \(item.path) survived admin removal" + result.errors.append(detail) + Logger.shared.log("Admin pass survivor: \(detail)", level: .error) + } + } + Logger.shared.log("Admin pass complete: \(result.itemsCleaned) deleted, \(result.errors.count) survived", level: .info) + return result + } + // MARK: - Purgeable Space func purgePurgeableSpace() async -> Int64 { @@ -150,17 +265,31 @@ actor CleaningEngine { "\(home)/Library/Preferences", "\(home)/Library/LaunchAgents", "\(home)/Library/Mail Downloads", + "\(home)/Library/Developer/Xcode/DerivedData", + "\(home)/Library/Developer/Xcode/Archives", + "\(home)/Library/Developer/CoreSimulator/Caches", "\(home)/.Trash", + "\(home)/.npm", + "\(home)/.cache", + "\(home)/Library/Containers/com.docker.docker", "/Library/Caches", "/Library/Logs", "/private/var/log", "/private/var/tmp", + // /var is a symlink to /private/var, and resolvingSymlinksInPath + // gives the /var form. Both spellings must be allow-listed or + // every system log/tmp deletion silently fails the safety check. + "/var/log", + "/var/tmp", "/tmp", ] - // Allow whole-subtree deletion only; require a trailing "/" on the - // root match so siblings like "/tmpfoo" cannot pass the prefix check. + // Either the path equals an allow-listed root (whole-subtree wipe by + // the scanner that emits the root itself, e.g. DerivedData) or it + // sits strictly inside one. The trailing "/" on the prefix match + // prevents siblings like "/tmpfoo" from sneaking past "/tmp". let normalized = (resolvedPath as NSString).standardizingPath return allowedRoots.contains { root in + if normalized == root { return true } let rootWithSeparator = root.hasSuffix("/") ? root : root + "/" return normalized.hasPrefix(rootWithSeparator) } diff --git a/PureMac/Services/FullDiskAccessManager.swift b/PureMac/Services/FullDiskAccessManager.swift index 82f4ac4..f9b1107 100644 --- a/PureMac/Services/FullDiskAccessManager.swift +++ b/PureMac/Services/FullDiskAccessManager.swift @@ -2,62 +2,72 @@ import AppKit import Foundation /// Detects whether Full Disk Access (FDA) has been granted to PureMac. -/// Without FDA, macOS TCC blocks access to ~/Desktop, ~/Documents, ~/Mail, -/// ~/.Trash, and other app containers even for non-sandboxed apps. +/// Without FDA, macOS TCC blocks access to ~/Library/Mail, ~/Library/Safari, +/// /Library/Application Support/com.apple.TCC, and other protected locations. final class FullDiskAccessManager { static let shared = FullDiskAccessManager() private init() {} - /// Check if Full Disk Access is granted by probing TCC-protected paths. - /// Returns true if at least one protected path is readable. + /// Check if Full Disk Access is granted by attempting a real read of a + /// TCC-protected file. We use FileHandle/Data — not isReadableFile or + /// fileExists — because the metadata APIs short-circuit before TCC fires + /// and so don't register the calling app in the FDA list. var hasFullDiskAccess: Bool { - // These paths are protected by TCC and require FDA to read. - // We try multiple because some may not exist on every system. - let protectedPaths = [ + let probes = [ + "/Library/Application Support/com.apple.TCC/TCC.db", FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Mail").path, + .appendingPathComponent("Library/Safari/CloudTabs.db").path, FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Safari/Bookmarks.plist").path, - "/Library/Application Support/com.apple.TCC/TCC.db", + .appendingPathComponent("Library/Mail").path, ] - for path in protectedPaths { - if FileManager.default.isReadableFile(atPath: path) { - return true - } - } - - // If none of the protected paths exist, assume FDA is not granted - // but don't block the user - some paths may legitimately not exist - // on a fresh system. Check if we can at least list a protected directory. - let home = FileManager.default.homeDirectoryForCurrentUser.path - let trashPath = "\(home)/.Trash" - if let contents = try? FileManager.default.contentsOfDirectory(atPath: trashPath), - !contents.isEmpty { - return true + for path in probes { + if canActuallyRead(path: path) { return true } } + return false + } - // Try listing Desktop - if TCC blocks it, we get an empty array or error - let desktopPath = "\(home)/Desktop" - do { - let contents = try FileManager.default.contentsOfDirectory(atPath: desktopPath) - // If Desktop exists and has files, FDA is likely granted - // (An empty Desktop is ambiguous, so we check more paths) - if !contents.isEmpty { return true } - } catch { - // Permission denied = no FDA + /// Real-read probe. Performs the syscall TCC actually evaluates, so + /// the OS records PureMac as the requester and adds it to the FDA pane. + /// Directories use contentsOfDirectory; files use FileHandle. + @discardableResult + private func canActuallyRead(path: String) -> Bool { + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { return false } - - // Ambiguous - Desktop is empty, try one more path - let mailDir = "\(home)/Library/Mail" - if FileManager.default.fileExists(atPath: mailDir) { - return FileManager.default.isReadableFile(atPath: mailDir) + if isDir.boolValue { + do { + _ = try FileManager.default.contentsOfDirectory(atPath: path) + return true + } catch { + return false + } + } else { + guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { + return false + } + defer { try? handle.close() } + return (try? handle.read(upToCount: 1)) != nil } + } - // Can't determine definitively - default to warning the user - return false + /// Force PureMac to appear in the Full Disk Access list. + /// + /// The OS only registers an app in the FDA pane after that app itself + /// makes a TCC-gated syscall. Metadata lookups (fileExists, isReadableFile) + /// don't qualify, and delegating to Finder via AppleScript registers + /// *Finder* — not PureMac. So at launch we touch a few protected paths + /// directly. The reads will fail until the user grants access; that's + /// fine — the failed attempts are what register us. + func triggerRegistration() { + DispatchQueue.global(qos: .utility).async { + _ = self.canActuallyRead(path: "/Library/Application Support/com.apple.TCC/TCC.db") + let home = FileManager.default.homeDirectoryForCurrentUser.path + _ = self.canActuallyRead(path: "\(home)/Library/Mail") + _ = self.canActuallyRead(path: "\(home)/Library/Safari/CloudTabs.db") + } } /// Opens System Settings to the Full Disk Access pane. @@ -65,4 +75,31 @@ final class FullDiskAccessManager { let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")! NSWorkspace.shared.open(url) } + + /// Reveal the running PureMac.app bundle in Finder so the user can drag + /// it into the Full Disk Access list when the OS hasn't auto-registered + /// it (common with Homebrew installs that strip the quarantine attribute). + func revealAppInFinder() { + let bundleURL = Bundle.main.bundleURL + NSWorkspace.shared.activateFileViewerSelecting([bundleURL]) + } + + /// Reset PureMac's TCC entries so the OS can re-register the bundle. + /// Useful when the bundle was replaced (Homebrew upgrade, manual move) + /// and the existing TCC row points at a stale code-signing identity. + /// Returns true if the reset command exited cleanly. + @discardableResult + func resetFullDiskAccess() -> Bool { + let bundleID = Bundle.main.bundleIdentifier ?? "com.puremac.app" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") + process.arguments = ["reset", "SystemPolicyAllFiles", bundleID] + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } } diff --git a/PureMac/Services/ScanEngine.swift b/PureMac/Services/ScanEngine.swift index 00bcb8e..3aefa56 100644 --- a/PureMac/Services/ScanEngine.swift +++ b/PureMac/Services/ScanEngine.swift @@ -182,11 +182,11 @@ actor ScanEngine { options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { continue } - var depth = 0 + // No entry cap. A 5k cap was here previously and meant any user + // with hundreds of thousands of small files (e.g. node_modules + // checkouts) would never see anything past entry 5k — the + // scattered 100+ MB files were always past that bound. for case let fileURL as URL in enumerator { - depth += 1 - if depth > 5000 { break } // Safety limit - guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]), let isFile = resourceValues.isRegularFile, isFile, let fileSize = resourceValues.fileSize diff --git a/PureMac/ViewModels/AppState.swift b/PureMac/ViewModels/AppState.swift index 891817e..1a99a28 100644 --- a/PureMac/ViewModels/AppState.swift +++ b/PureMac/ViewModels/AppState.swift @@ -30,6 +30,7 @@ final class AppState: ObservableObject { @Published var deselectedItems: Set = [] @Published var hasFullDiskAccess: Bool = true @Published var fdaBannerDismissed: Bool = false + @Published var cleanError: String? // MARK: - App Uninstaller State @@ -122,9 +123,9 @@ final class AppState: ObservableObject { func removeSelectedFiles() { // Safety guard: never allow a high-risk home dotpath (listed in - // Conditions.swift) to be sent to trashViaFinder no matter how it - // ended up in the selection. Catches selection-time additions that - // slipped past the scanner-side filters. + // Conditions.swift) to be trashed no matter how it ended up in the + // selection. Catches selection-time additions that slipped past the + // scanner-side filters. let allURLs = Array(selectedFiles) let (urls, blocked): ([URL], [URL]) = allURLs.reduce(into: ([], [])) { acc, url in let resolved = url.resolvingSymlinksInPath().path @@ -149,47 +150,42 @@ final class AppState: ObservableObject { } return } - trashViaFinder(urls: urls) { [weak self] success in + trashDirectly(urls: urls) { [weak self] removed, failed in DispatchQueue.main.async { guard let self else { return } - // Check which files were actually removed from disk - let removed = urls.filter { !FileManager.default.fileExists(atPath: $0.path) } if !removed.isEmpty { self.discoveredFiles.removeAll { removed.contains($0) } self.selectedFiles.subtract(removed) Logger.shared.log("Removed \(removed.count) files", level: .info) } - let failed = urls.count - removed.count - if failed > 0 { - self.removalError = "\(failed) file\(failed == 1 ? "" : "s") could not be removed. Grant Full Disk Access in System Settings → Privacy & Security to allow PureMac to manage all files." - Logger.shared.log("Failed to remove \(failed) files — likely missing FDA", level: .error) + if !failed.isEmpty { + self.removalError = "\(failed.count) file\(failed.count == 1 ? "" : "s") could not be removed. Grant Full Disk Access in System Settings → Privacy & Security to allow PureMac to manage all files." + Logger.shared.log("Failed to remove \(failed.count) files — likely missing FDA", level: .error) } } } } - /// Uses Finder via AppleScript to move files to Trash. - /// This triggers the standard macOS authorization prompt for protected files. - private func trashViaFinder(urls: [URL], completion: @escaping (Bool) -> Void) { - let posixPaths = urls.map { "\"\($0.path)\"" }.joined(separator: ", ") - let script = """ - tell application "Finder" - set theFiles to {} - repeat with p in {\(posixPaths)} - set end of theFiles to (POSIX file p as alias) - end repeat - delete theFiles - end tell - """ + /// Move files to the Trash via FileManager.trashItem so the syscall + /// originates from PureMac itself — TCC then registers PureMac in the + /// Full Disk Access list. The previous AppleScript-via-Finder bridge + /// caused the syscall to originate from Finder, which is why granting + /// FDA to PureMac made no difference (issue #75). + private func trashDirectly(urls: [URL], completion: @escaping ([URL], [URL]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { - let appleScript = NSAppleScript(source: script) - var errorInfo: NSDictionary? - appleScript?.executeAndReturnError(&errorInfo) - let success = errorInfo == nil - if let errorInfo { - Logger.shared.log("Finder trash error: \(errorInfo)", level: .error) + var removed: [URL] = [] + var failed: [URL] = [] + for url in urls { + var resulting: NSURL? + do { + try FileManager.default.trashItem(at: url, resultingItemURL: &resulting) + removed.append(url) + } catch { + Logger.shared.log("Trash failed for \(url.path): \(error.localizedDescription)", level: .error) + failed.append(url) + } } - completion(success) + completion(removed, failed) } } @@ -393,18 +389,44 @@ final class AppState: ObservableObject { cleanProgress = 0 Task { - let result = await cleaningEngine.cleanItems(itemsToClean) { [weak self] progress in + var result = await cleaningEngine.cleanItems(itemsToClean) { [weak self] progress in Task { @MainActor [weak self] in self?.cleanProgress = progress self?.scanState = .cleaning(progress: progress) } } + // Escalate root-owned items via "with administrator privileges". + // One auth prompt covers the entire batch. + if !result.requiresAdmin.isEmpty { + let admin = await cleaningEngine.cleanWithAdminPrivileges(items: result.requiresAdmin) + result.cleanedPaths.formUnion(admin.cleanedPaths) + result.itemsCleaned += admin.itemsCleaned + result.freedSpace += admin.freedSpace + result.errors.append(contentsOf: admin.errors) + } + totalFreedSpace = result.freedSpace lastCleanedDate = Date() - categoryResults = [:] - totalJunkSize = 0 + for (cat, catResult) in categoryResults { + let remaining = catResult.items.filter { !result.cleanedPaths.contains($0.path) } + if remaining.isEmpty { + categoryResults.removeValue(forKey: cat) + } else { + categoryResults[cat] = CategoryResult( + category: cat, + items: remaining, + totalSize: remaining.reduce(0) { $0 + $1.size } + ) + } + } + totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } + + if !result.errors.isEmpty { + cleanError = "\(result.errors.count) item\(result.errors.count == 1 ? "" : "s") couldn't be removed. Check the log for details." + } + scanState = .cleaned loadDiskInfo() @@ -424,18 +446,42 @@ final class AppState: ObservableObject { cleanProgress = 0 Task { - let cleanResult = await cleaningEngine.cleanItems(selectedItems) { [weak self] progress in + var cleanResult = await cleaningEngine.cleanItems(selectedItems) { [weak self] progress in Task { @MainActor [weak self] in self?.cleanProgress = progress self?.scanState = .cleaning(progress: progress) } } + if !cleanResult.requiresAdmin.isEmpty { + let admin = await cleaningEngine.cleanWithAdminPrivileges(items: cleanResult.requiresAdmin) + cleanResult.cleanedPaths.formUnion(admin.cleanedPaths) + cleanResult.itemsCleaned += admin.itemsCleaned + cleanResult.freedSpace += admin.freedSpace + cleanResult.errors.append(contentsOf: admin.errors) + } + totalFreedSpace = cleanResult.freedSpace lastCleanedDate = Date() - categoryResults.removeValue(forKey: category) + if let existing = categoryResults[category] { + let remaining = existing.items.filter { !cleanResult.cleanedPaths.contains($0.path) } + if remaining.isEmpty { + categoryResults.removeValue(forKey: category) + } else { + categoryResults[category] = CategoryResult( + category: category, + items: remaining, + totalSize: remaining.reduce(0) { $0 + $1.size } + ) + } + } totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } + + if !cleanResult.errors.isEmpty { + cleanError = "\(cleanResult.errors.count) item\(cleanResult.errors.count == 1 ? "" : "s") couldn't be removed. Check the log for details." + } + scanState = .cleaned loadDiskInfo() diff --git a/PureMac/Views/Apps/AppFilesView.swift b/PureMac/Views/Apps/AppFilesView.swift index b0baf9f..9fbaeb8 100644 --- a/PureMac/Views/Apps/AppFilesView.swift +++ b/PureMac/Views/Apps/AppFilesView.swift @@ -121,6 +121,17 @@ struct AppFilesView: View { } private func fileSize(_ url: URL) -> Int64? { + // totalFileAllocatedSize recurses into directories; attributesOfItem + // returns the directory's own metadata size (≈0), which is why + // bundles and support folders previously displayed as 0 B. + if let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), + let size = values.totalFileAllocatedSize, size > 0 { + return Int64(size) + } + if let values = try? url.resourceValues(forKeys: [.fileAllocatedSizeKey]), + let size = values.fileAllocatedSize { + return Int64(size) + } guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attrs[.size] as? Int64 else { return nil } return size diff --git a/PureMac/Views/CategoryDetailView.swift b/PureMac/Views/CategoryDetailView.swift index 7adefa7..31e4d24 100644 --- a/PureMac/Views/CategoryDetailView.swift +++ b/PureMac/Views/CategoryDetailView.swift @@ -13,15 +13,22 @@ struct CategoryDetailView: View { } var body: some View { - Group { - if let result = result { - if result.items.isEmpty { - EmptyStateView("All Clean", systemImage: "checkmark.circle", description: "No junk files found in this category.") + VStack(spacing: 0) { + heroCard + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + + Group { + if let result = result { + if result.items.isEmpty { + EmptyStateView("All Clean", systemImage: "checkmark.circle", description: "No junk files found in this category.") + } else { + fileList(result) + } } else { - fileList(result) + EmptyStateView("Not Scanned", systemImage: category.icon, description: "Run a scan to analyze this category.", action: { appState.scanSingleCategory(category) }, actionLabel: "Scan Now") } - } else { - EmptyStateView("Not Scanned", systemImage: category.icon, description: "Run a scan to analyze this category.", action: { appState.scanSingleCategory(category) }, actionLabel: "Scan Now") } } .searchable(text: $searchText, prompt: "Filter files") @@ -81,6 +88,47 @@ struct CategoryDetailView: View { } } + // MARK: - Hero + + private var heroCard: some View { + let totalSize = result?.totalSize ?? 0 + let itemCount = result?.itemCount ?? 0 + let isScanning = appState.scanState.isActive + + return CardSurface(padding: 18) { + HStack(alignment: .center, spacing: 16) { + IconTile(systemName: category.icon, tint: category.color, size: 56, corner: 14) + + VStack(alignment: .leading, spacing: 4) { + Text(category.rawValue) + .font(.system(size: 22, weight: .bold)) + Text(category.description) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + if itemCount > 0 { + Text("\(itemCount) items · \(ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file))") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(category.color) + .padding(.top, 2) + } + } + + Spacer() + + Button { + appState.scanSingleCategory(category) + } label: { + Label(isScanning ? "Scanning…" : (result == nil ? "Scan" : "Rescan"), + systemImage: "arrow.clockwise") + .font(.system(size: 12.5, weight: .semibold)) + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isScanning) + } + } + } + // MARK: - File List private func fileList(_ result: CategoryResult) -> some View { diff --git a/PureMac/Views/Components/AppTheme.swift b/PureMac/Views/Components/AppTheme.swift new file mode 100644 index 0000000..c7d8051 --- /dev/null +++ b/PureMac/Views/Components/AppTheme.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// User-overridable appearance setting that lives independently of the system +/// preference, mirroring the prototype's titlebar light/dark toggle. +enum AppearanceMode: String, CaseIterable, Identifiable { + case system, light, dark + var id: String { rawValue } + + var label: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var icon: String { + switch self { + case .system: return "circle.lefthalf.filled" + case .light: return "sun.max.fill" + case .dark: return "moon.fill" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +@MainActor +final class ThemeManager: ObservableObject { + static let shared = ThemeManager() + + @AppStorage("PureMac.Appearance") private var rawValue: String = AppearanceMode.system.rawValue + + var appearance: AppearanceMode { + get { AppearanceMode(rawValue: rawValue) ?? .system } + set { rawValue = newValue.rawValue; objectWillChange.send() } + } +} + +/// Centralized accent palette. Keeping these in one place lets the dashboard +/// and sidebar share semantic tints (cleanup orange, performance green, etc.) +/// instead of scattered Color literals. +enum Tint { + static let blue = Color(red: 0.04, green: 0.52, blue: 1.00) + static let green = Color(red: 0.18, green: 0.78, blue: 0.47) + static let orange = Color(red: 1.00, green: 0.62, blue: 0.04) + static let purple = Color(red: 0.69, green: 0.32, blue: 0.87) + static let pink = Color(red: 1.00, green: 0.30, blue: 0.50) + static let cyan = Color(red: 0.30, green: 0.80, blue: 0.95) + static let red = Color(red: 1.00, green: 0.27, blue: 0.23) + static let yellow = Color(red: 1.00, green: 0.78, blue: 0.04) +} + +/// Tinted square icon container used in the sidebar and on dashboard cards. +struct IconTile: View { + let systemName: String + var tint: Color = Tint.blue + var size: CGFloat = 26 + var corner: CGFloat = 7 + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill(tint.opacity(0.16)) + Image(systemName: systemName) + .font(.system(size: size * 0.52, weight: .semibold)) + .foregroundStyle(tint) + } + .frame(width: size, height: size) + } +} + +/// Card surface used on the dashboard, suggestion list, and detail pages. +struct CardSurface: View { + var padding: CGFloat = 16 + @ViewBuilder var content: Content + + var body: some View { + content + .padding(padding) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.06), radius: 6, y: 2) + } +} diff --git a/PureMac/Views/Components/AppearancePill.swift b/PureMac/Views/Components/AppearancePill.swift new file mode 100644 index 0000000..b6d48c8 --- /dev/null +++ b/PureMac/Views/Components/AppearancePill.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Inline 3-segment toggle (system / light / dark) with an animated active +/// indicator that slides between segments. Replaces the SwiftUI `Menu` which +/// looked like a generic dropdown affordance. +struct AppearancePill: View { + @Binding var selection: AppearanceMode + @Namespace private var indicator + + var body: some View { + HStack(spacing: 2) { + ForEach(AppearanceMode.allCases) { mode in + Button { + withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { + selection = mode + } + } label: { + Image(systemName: mode.icon) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 28, height: 22) + .foregroundStyle(selection == mode ? Color.primary : .secondary) + .background( + ZStack { + if selection == mode { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.10)) + .matchedGeometryEffect(id: "indicator", in: indicator) + } + } + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(mode.label) + } + } + } +} diff --git a/PureMac/Views/DashboardView.swift b/PureMac/Views/DashboardView.swift new file mode 100644 index 0000000..262276c --- /dev/null +++ b/PureMac/Views/DashboardView.swift @@ -0,0 +1,585 @@ +import SwiftUI + +/// Landing screen modeled after the new prototype: +/// hero gauge + stats + quick actions + suggestion cards. +/// Replaces the old SmartScanView idle/completed states with a richer +/// at-a-glance view, and delegates active-scan progress to inline state UI. +struct DashboardView: View { + @EnvironmentObject var appState: AppState + @State private var showConfirmation = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + switch appState.scanState { + case .idle: + hero + stats + if !suggestionRows.isEmpty { + sectionHeader("Suggested for you") + suggestions + } + case .scanning: + scanningHero + if !appState.allResults.isEmpty { + sectionHeader("Found so far") + liveResults + } + case .completed: + completedHero + if appState.totalJunkSize > 0 { + sectionHeader("By category") + resultsList + } + case .cleaning: + cleaningHero + case .cleaned: + cleanedHero + } + } + .padding(.horizontal, 28) + .padding(.vertical, 24) + .frame(maxWidth: 920, alignment: .leading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .confirmationDialog( + "Clean \(ByteCountFormatter.string(fromByteCount: appState.totalSelectedSize, countStyle: .file))?", + isPresented: $showConfirmation, + titleVisibility: .visible + ) { + Button("Clean", role: .destructive) { appState.cleanAll() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete the selected files. This cannot be undone.") + } + } + + // MARK: - Hero (idle) + + private var hero: some View { + let total = appState.diskInfo.totalSpace + let used = appState.diskInfo.usedSpace + let free = appState.diskInfo.freeSpace + let percentUsed = total > 0 ? Double(used) / Double(total) : 0 + + return CardSurface(padding: 24) { + HStack(alignment: .center, spacing: 28) { + StorageGauge(percentUsed: percentUsed) + .frame(width: 180, height: 180) + + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("Storage") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + Text(ByteCountFormatter.string(fromByteCount: free, countStyle: .file)) + .font(.system(size: 30, weight: .bold)) + .monospacedDigit() + Text("free of \(ByteCountFormatter.string(fromByteCount: total, countStyle: .file))") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + appState.startSmartScan() + } label: { + Label("Smart Scan", systemImage: "sparkles") + .font(.system(size: 12.5, weight: .semibold)) + } + .buttonStyle(.bordered) + .controlSize(.regular) + } + + storageBreakdown(used: used, total: total) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private func storageBreakdown(used: Int64, total: Int64) -> some View { + let usedPct = total > 0 ? Double(used) / Double(total) : 0 + let purgePct = total > 0 ? Double(appState.diskInfo.purgeableSpace) / Double(total) : 0 + let junkPct = total > 0 ? min(0.4, Double(appState.totalJunkSize) / Double(total)) : 0 + + return VStack(alignment: .leading, spacing: 8) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + HStack(spacing: 0) { + Capsule() + .fill(LinearGradient(colors: [Tint.blue, Tint.purple], startPoint: .leading, endPoint: .trailing)) + .frame(width: geo.size.width * CGFloat(usedPct)) + } + if junkPct > 0 { + Capsule() + .fill(Tint.orange) + .frame(width: max(8, geo.size.width * CGFloat(junkPct))) + .offset(x: geo.size.width * CGFloat(usedPct - junkPct)) + .opacity(0.85) + } + } + } + .frame(height: 10) + + HStack(spacing: 16) { + LegendDot(color: Tint.blue, label: "Used", value: ByteCountFormatter.string(fromByteCount: used, countStyle: .file)) + if appState.totalJunkSize > 0 { + LegendDot(color: Tint.orange, label: "Junk", + value: ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file)) + } + if appState.diskInfo.purgeableSpace > 0 { + LegendDot(color: Tint.green, label: "Purgeable", + value: ByteCountFormatter.string(fromByteCount: appState.diskInfo.purgeableSpace, countStyle: .file)) + } + Spacer() + Text("\(Int(usedPct * 100))% used") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + + // MARK: - Stats + + private var stats: some View { + let free = appState.diskInfo.freeSpace + let total = appState.diskInfo.totalSpace + let percentUsed = total > 0 ? Double(total - free) / Double(total) : 0 + + return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), spacing: 12) { + StatCard( + icon: "internaldrive.fill", + tint: Tint.blue, + label: "Free Space", + value: ByteCountFormatter.string(fromByteCount: free, countStyle: .file), + delta: total > 0 ? "of \(ByteCountFormatter.string(fromByteCount: total, countStyle: .file)) · \(Int(percentUsed * 100))% used" : nil + ) + StatCard( + icon: "trash.circle.fill", + tint: Tint.orange, + label: "Junk Found", + value: appState.totalJunkSize > 0 + ? ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file) + : "—", + delta: appState.allResults.isEmpty ? "Run a scan" : "across \(appState.allResults.count) categories" + ) + StatCard( + icon: "square.grid.2x2.fill", + tint: Tint.purple, + label: "Apps", + value: "\(appState.installedApps.count)", + delta: "installed" + ) + StatCard( + icon: "memorychip.fill", + tint: Tint.green, + label: "Purgeable", + value: appState.diskInfo.purgeableSpace > 0 + ? ByteCountFormatter.string(fromByteCount: appState.diskInfo.purgeableSpace, countStyle: .file) + : "—", + delta: "APFS reclaimable" + ) + } + } + + // MARK: - Suggestions + + private var suggestions: some View { + VStack(spacing: 10) { + ForEach(suggestionRows) { row in + SuggestionRow(suggestion: row) + } + } + } + + private var suggestionRows: [Suggestion] { + var out: [Suggestion] = [] + // Surface the largest pending category as a contextual nudge. + if let biggest = appState.allResults.max(by: { $0.totalSize < $1.totalSize }), biggest.totalSize > 0 { + out.append(Suggestion( + icon: biggest.category.icon, + tint: biggest.category.color, + title: "\(biggest.category.rawValue) is using \(biggest.formattedSize)", + subtitle: biggest.category.description, + pill: biggest.formattedSize + )) + } + if !appState.hasFullDiskAccess { + out.append(Suggestion( + icon: "lock.shield.fill", + tint: Tint.orange, + title: "Grant Full Disk Access for full results", + subtitle: "Without it, most caches and uninstall flows fail.", + pill: "Action" + )) + } + return out + } + + // MARK: - Scanning state + + private var scanningHero: some View { + CardSurface(padding: 24) { + HStack(alignment: .center, spacing: 28) { + ScanningGauge(progress: appState.scanProgress) + .frame(width: 180, height: 180) + VStack(alignment: .leading, spacing: 8) { + Text("Scanning your Mac") + .font(.system(size: 22, weight: .bold)) + Text("Currently in: \(appState.currentScanCategory)") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + ProgressView(value: appState.scanProgress) + .progressViewStyle(.linear) + .frame(maxWidth: 320) + .padding(.top, 4) + } + Spacer(minLength: 0) + } + } + } + + private var liveResults: some View { + CardSurface(padding: 0) { + VStack(spacing: 0) { + ForEach(appState.allResults.prefix(8)) { result in + HStack(spacing: 12) { + IconTile(systemName: result.category.icon, tint: result.category.color, size: 26) + Text(result.category.rawValue) + .font(.system(size: 13)) + Spacer() + Text(result.formattedSize) + .font(.system(size: 13, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + if result.id != appState.allResults.prefix(8).last?.id { + Divider().padding(.leading, 54) + } + } + } + } + } + + // MARK: - Completed state + + private var completedHero: some View { + CardSurface(padding: 24) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + if appState.totalJunkSize > 0 { + Text(ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file)) + .font(.system(size: 36, weight: .bold)) + .monospacedDigit() + Text("found") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + } else { + Label("Your Mac is clean", systemImage: "checkmark.seal.fill") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(Tint.green) + } + Spacer() + Button("Scan Again") { appState.startSmartScan() } + .controlSize(.large) + } + if appState.totalJunkSize > 0 { + HStack { + if appState.totalSelectedSize > 0 { + Button { + showConfirmation = true + } label: { + Label( + "Clean \(ByteCountFormatter.string(fromByteCount: appState.totalSelectedSize, countStyle: .file))", + systemImage: "sparkles" + ) + .font(.system(size: 13, weight: .semibold)) + .padding(.horizontal, 6) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + Spacer() + } + } + } + } + } + + private var resultsList: some View { + CardSurface(padding: 0) { + VStack(spacing: 0) { + ForEach(appState.allResults) { result in + CategoryToggleRow(result: result) + if result.id != appState.allResults.last?.id { + Divider().padding(.leading, 54) + } + } + } + } + } + + private var cleaningHero: some View { + CardSurface(padding: 24) { + HStack(alignment: .center, spacing: 28) { + ScanningGauge(progress: appState.cleanProgress, tint: Tint.orange) + .frame(width: 180, height: 180) + VStack(alignment: .leading, spacing: 8) { + Text("Cleaning…") + .font(.system(size: 22, weight: .bold)) + Text("\(Int(appState.cleanProgress * 100))% complete") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + } + + private var cleanedHero: some View { + CardSurface(padding: 24) { + HStack(alignment: .center, spacing: 28) { + ZStack { + Circle().fill(Tint.green.opacity(0.18)) + Image(systemName: "checkmark") + .font(.system(size: 60, weight: .bold)) + .foregroundStyle(Tint.green) + } + .frame(width: 140, height: 140) + + VStack(alignment: .leading, spacing: 6) { + Text(ByteCountFormatter.string(fromByteCount: appState.totalFreedSpace, countStyle: .file)) + .font(.system(size: 36, weight: .bold)) + .monospacedDigit() + Text("freed") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + Button("Done") { appState.scanState = .idle } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.top, 4) + } + Spacer(minLength: 0) + } + } + } + + // MARK: - Helpers + + private func sectionHeader(_ text: String) -> some View { + Text(text) + .font(.system(size: 16, weight: .bold)) + .padding(.top, 4) + } +} + +// MARK: - Components + +private struct StatCard: View { + let icon: String + let tint: Color + let label: String + let value: String + let delta: String? + + var body: some View { + CardSurface(padding: 14) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + IconTile(systemName: icon, tint: tint, size: 26) + Text(label) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Text(value) + .font(.system(size: 22, weight: .bold)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.6) + if let delta { + Text(delta) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + } + } +} + +private struct Suggestion: Identifiable { + let id = UUID() + let icon: String + let tint: Color + let title: String + let subtitle: String + let pill: String? +} + +private struct SuggestionRow: View { + let suggestion: Suggestion + var body: some View { + CardSurface(padding: 14) { + HStack(spacing: 14) { + IconTile(systemName: suggestion.icon, tint: suggestion.tint, size: 36, corner: 10) + VStack(alignment: .leading, spacing: 2) { + Text(suggestion.title) + .font(.system(size: 13.5, weight: .semibold)) + Text(suggestion.subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + if let pill = suggestion.pill { + Text(pill) + .font(.system(size: 12, weight: .semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule().fill(suggestion.tint.opacity(0.15)) + ) + .foregroundStyle(suggestion.tint) + } + } + } + } +} + +// MARK: - Gauges + +private struct StorageGauge: View { + let percentUsed: Double // 0...1 + + var body: some View { + let pct = max(0, min(1, percentUsed)) + let displayPercent = Int(round(pct * 100)) + let stress = pct > 0.85 + ZStack { + Circle() + .stroke(Color.primary.opacity(0.10), lineWidth: 14) + Circle() + .trim(from: 0, to: CGFloat(pct)) + .stroke( + AngularGradient( + colors: stress + ? [Tint.orange, Tint.red] + : [Tint.blue, Tint.purple], + center: .center + ), + style: StrokeStyle(lineWidth: 14, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .shadow(color: (stress ? Tint.orange : Tint.blue).opacity(0.35), radius: 8, y: 2) + .animation(.easeOut(duration: 0.8), value: pct) + VStack(spacing: 2) { + Text("\(displayPercent)%") + .font(.system(size: 44, weight: .bold)) + .monospacedDigit() + .contentTransition(.numericText()) + Text("USED") + .font(.system(size: 10, weight: .semibold)) + .tracking(0.6) + .foregroundStyle(.secondary) + } + } + } +} + +private struct LegendDot: View { + let color: Color + let label: String + let value: String + + var body: some View { + HStack(spacing: 6) { + Circle().fill(color).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 0) { + Text(label) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 11.5, weight: .semibold)) + .monospacedDigit() + } + } + } +} + +private struct ScanningGauge: View { + let progress: Double + var tint: Color = Tint.blue + @State private var rotate = false + + var body: some View { + ZStack { + Circle() + .stroke(Color.primary.opacity(0.10), lineWidth: 14) + Circle() + .trim(from: 0, to: CGFloat(max(0.05, min(0.95, progress)))) + .stroke( + AngularGradient(colors: [tint, Tint.green], center: .center), + style: StrokeStyle(lineWidth: 14, lineCap: .round) + ) + .rotationEffect(.degrees(rotate ? 360 : 0)) + .animation(.linear(duration: 4).repeatForever(autoreverses: false), value: rotate) + VStack(spacing: 2) { + Text("\(Int(progress * 100))%") + .font(.system(size: 36, weight: .bold)) + .monospacedDigit() + Text("SCANNING") + .font(.system(size: 10, weight: .semibold)) + .tracking(0.6) + .foregroundStyle(.secondary) + } + } + .onAppear { rotate = true } + } +} + +// MARK: - Toggle row + +private struct CategoryToggleRow: View { + @EnvironmentObject var appState: AppState + let result: CategoryResult + + private var isFullySelected: Bool { + appState.selectedCountInCategory(result.category) == result.itemCount + } + + var body: some View { + Toggle(isOn: Binding( + get: { isFullySelected }, + set: { newValue in + if newValue { + appState.selectAllInCategory(result.category) + } else { + appState.deselectAllInCategory(result.category) + } + } + )) { + HStack(spacing: 12) { + IconTile(systemName: result.category.icon, tint: result.category.color, size: 28) + VStack(alignment: .leading, spacing: 1) { + Text(LocalizedStringKey(result.category.rawValue)) + .font(.system(size: 13.5, weight: .semibold)) + Text("\(result.itemCount) items") + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + } + Spacer() + Text(result.formattedSize) + .font(.system(size: 13, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } +} diff --git a/PureMac/Views/MainWindow.swift b/PureMac/Views/MainWindow.swift index d01b42c..754d3c8 100644 --- a/PureMac/Views/MainWindow.swift +++ b/PureMac/Views/MainWindow.swift @@ -2,6 +2,7 @@ import SwiftUI struct MainWindow: View { @EnvironmentObject var appState: AppState + @EnvironmentObject var theme: ThemeManager @State private var selectedSection: AppSection? = .cleaning(.smartScan) @State private var columnVisibility: NavigationSplitViewVisibility = .all @@ -9,57 +10,157 @@ struct MainWindow: View { NavigationSplitView(columnVisibility: $columnVisibility) { sidebar } detail: { - detailView + detailContainer + } + .navigationSplitViewColumnWidth(min: 232, ideal: 244, max: 320) + .frame(minWidth: 980, minHeight: 600) + .toolbar { + ToolbarItem(placement: .navigation) { + appearancePicker + } } - .navigationSplitViewColumnWidth(min: 220, ideal: 250, max: 320) - .frame(minWidth: 860, minHeight: 520) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in appState.checkFullDiskAccess() } - } - - // All sidebar items as a flat array so ForEach gives proper selectability. - // Smart Scan is pinned to the top so users always have a path back to it - - // otherwise the landing page disappears after selecting another tab (#69). - private var allSidebarItems: [SidebarItem] { - let smartSize = appState.categoryResults[.smartScan]?.totalSize ?? appState.totalJunkSize - let smartBadge = smartSize > 0 ? ByteCountFormatter.string(fromByteCount: smartSize, countStyle: .file) : nil - var items: [SidebarItem] = [ - SidebarItem(section: .cleaning(.smartScan), label: CleaningCategory.smartScan.rawValue, icon: CleaningCategory.smartScan.icon, badge: smartBadge, group: "Home"), - SidebarItem(section: .apps, label: "Installed Apps", icon: "square.grid.2x2", badge: "\(appState.installedApps.count)", group: "Applications"), - SidebarItem(section: .orphans, label: "Orphaned Files", icon: "doc.questionmark", badge: appState.orphanedFiles.count > 0 ? "\(appState.orphanedFiles.count)" : nil, group: "Applications"), - ] - for category in CleaningCategory.scannable { - let size = appState.categoryResults[category]?.totalSize ?? 0 - let badge = size > 0 ? ByteCountFormatter.string(fromByteCount: size, countStyle: .file) : nil - items.append(SidebarItem(section: .cleaning(category), label: category.rawValue, icon: category.icon, badge: badge, group: "Cleaning")) + .alert("Couldn't clean everything", isPresented: Binding( + get: { appState.cleanError != nil }, + set: { if !$0 { appState.cleanError = nil } } + )) { + Button("Open System Settings") { + FullDiskAccessManager.shared.openFullDiskAccessSettings() + appState.cleanError = nil + } + Button("OK", role: .cancel) { appState.cleanError = nil } + } message: { + Text(appState.cleanError ?? "") } - return items } + // MARK: - Sidebar + private var sidebar: some View { List(selection: $selectedSection) { - let grouped = Dictionary(grouping: allSidebarItems, by: \.group) - let order = ["Home", "Applications", "Cleaning"] - ForEach(order, id: \.self) { group in - Section(group) { - ForEach(grouped[group] ?? [], id: \.section) { item in - HStack { - Label(item.label, systemImage: item.icon) - Spacer() - if let badge = item.badge { - Text(badge) - .font(.caption) - .foregroundStyle(.secondary) - } - } - .tag(item.section) - } + Section { + navRow(section: .cleaning(.smartScan), label: "Dashboard", + icon: "sparkles", tint: Tint.blue, + badge: dashboardBadge) + } header: { sectionLabel("Overview") } + + Section { + navRow(section: .apps, label: "Installed Apps", + icon: "square.grid.2x2.fill", tint: Tint.purple, + badge: appState.installedApps.isEmpty ? nil : "\(appState.installedApps.count)") + navRow(section: .orphans, label: "Orphaned Files", + icon: "doc.questionmark.fill", tint: Tint.pink, + badge: appState.orphanedFiles.isEmpty ? nil : "\(appState.orphanedFiles.count)") + } header: { sectionLabel("Applications") } + + Section { + ForEach(CleaningCategory.scannable) { category in + navRow(section: .cleaning(category), + label: category.rawValue, + icon: category.icon, + tint: category.color, + badge: sizeBadge(for: category)) } - } + } header: { sectionLabel("Cleanup") } } .listStyle(.sidebar) .navigationTitle("PureMac") + .safeAreaInset(edge: .bottom) { + healthFooter + } + } + + private func sectionLabel(_ text: String) -> some View { + Text(text) + .font(.system(size: 10.5, weight: .semibold)) + .tracking(0.5) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + } + + private func navRow(section: AppSection, label: String, icon: String, + tint: Color, badge: String?) -> some View { + HStack(spacing: 10) { + IconTile(systemName: icon, tint: tint, size: 24) + Text(label) + .font(.system(size: 13)) + Spacer() + if let badge { + Text(badge) + .font(.system(size: 11, weight: .medium)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + Capsule().fill(Color.primary.opacity(0.06)) + ) + } + } + .padding(.vertical, 1) + .tag(section) + } + + private var dashboardBadge: String? { + appState.totalJunkSize > 0 + ? ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file) + : nil + } + + private func sizeBadge(for category: CleaningCategory) -> String? { + guard let size = appState.categoryResults[category]?.totalSize, size > 0 else { return nil } + return ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + + private var healthFooter: some View { + HStack(spacing: 10) { + Circle() + .fill(appState.hasFullDiskAccess ? Tint.green : Tint.orange) + .frame(width: 8, height: 8) + .background( + Circle() + .fill((appState.hasFullDiskAccess ? Tint.green : Tint.orange).opacity(0.25)) + .frame(width: 18, height: 18) + ) + VStack(alignment: .leading, spacing: 1) { + Text(appState.hasFullDiskAccess ? "Ready to clean" : "Limited access") + .font(.system(size: 12, weight: .semibold)) + Text(appState.hasFullDiskAccess ? "Full Disk Access granted" : "Grant FDA in Settings") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.bar) + } + + // MARK: - Toolbar + + private var appearancePicker: some View { + AppearancePill(selection: Binding( + get: { theme.appearance }, + set: { theme.appearance = $0 } + )) + } + + // MARK: - Detail + + @ViewBuilder + private var detailContainer: some View { + VStack(spacing: 0) { + if !appState.hasFullDiskAccess && !appState.fdaBannerDismissed { + fdaToast + .padding(.horizontal, 16) + .padding(.top, 12) + } + detailView + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) } @ViewBuilder @@ -71,20 +172,65 @@ struct MainWindow: View { OrphanListView() case .cleaning(let category): if category == .smartScan { - SmartScanView() + DashboardView() } else { CategoryDetailView(category: category) } case nil: - EmptyStateView("PureMac", systemImage: "sparkles", description: "Select a category from the sidebar to get started.") + EmptyStateView("PureMac", systemImage: "sparkles", + description: "Select a category from the sidebar to get started.") } } -} -private struct SidebarItem { - let section: AppSection - let label: String - let icon: String - let badge: String? - let group: String + // Card-shaped FDA toast that matches the dashboard surface aesthetic + // rather than the flat orange bar. + private var fdaToast: some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color.white.opacity(0.18)) + Image(systemName: "exclamationmark.shield.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 2) { + Text("Full Disk Access required") + .font(.system(size: 13.5, weight: .bold)) + .foregroundStyle(.white) + Text("macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access.") + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.92)) + } + + Spacer() + + Button("Grant Access") { + FullDiskAccessManager.shared.openFullDiskAccessSettings() + } + .buttonStyle(.borderedProminent) + .tint(.white) + .foregroundStyle(Tint.orange) + + Button { + appState.fdaBannerDismissed = true + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white.opacity(0.85)) + .padding(6) + } + .buttonStyle(.plain) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + LinearGradient(colors: [Tint.orange, Color(red: 1.0, green: 0.42, blue: 0.0)], + startPoint: .topLeading, endPoint: .bottomTrailing) + ) + ) + .shadow(color: Tint.orange.opacity(0.35), radius: 12, y: 4) + } } diff --git a/PureMac/Views/OnboardingView.swift b/PureMac/Views/OnboardingView.swift index 897c38a..68ef48f 100644 --- a/PureMac/Views/OnboardingView.swift +++ b/PureMac/Views/OnboardingView.swift @@ -5,11 +5,13 @@ struct OnboardingView: View { @State private var currentPage = 0 @State private var hasFullDiskAccess = false @State private var appeared = false + @State private var hasOpenedSettings = false + @State private var showDiagnostics = false // Per-path access checks @State private var accessResults: [ProtectedPath] = ProtectedPath.allPaths - private let timer = Timer.publish(every: 1.5, on: .main, in: .common).autoconnect() + private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() var body: some View { VStack(spacing: 0) { @@ -158,84 +160,180 @@ struct OnboardingView: View { // MARK: - Full Disk Access private var fdaPage: some View { - VStack(spacing: 16) { - Spacer() - + VStack(spacing: 14) { Image(systemName: hasFullDiskAccess ? "checkmark.shield.fill" : "lock.shield") - .font(.system(size: 44)) + .font(.system(size: 40)) .foregroundStyle(hasFullDiskAccess ? .green : .orange) .animation(.easeInOut(duration: 0.3), value: hasFullDiskAccess) + .padding(.top, 8) Text("Full Disk Access") .font(.title2.bold()) if hasFullDiskAccess { - Text("All permissions granted. You're all set!") - .foregroundStyle(.green) - .transition(.opacity.combined(with: .scale)) + grantedView + } else if hasOpenedSettings { + instructionsView } else { - Text("PureMac needs Full Disk Access to scan protected locations.") - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 400) + introView + } + + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.bottom, 8) + .onAppear { + FullDiskAccessManager.shared.triggerRegistration() + refreshAccessChecks() + } + .onChange(of: hasFullDiskAccess) { granted in + if granted, currentPage == 1 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + currentPage = 2 + } + } } + } + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } - // Permission checklist - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(accessResults.enumerated()), id: \.element.id) { index, path in - HStack(spacing: 10) { - Image(systemName: path.accessible ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(path.accessible ? .green : .red.opacity(0.7)) - .font(.system(size: 14)) + private var introView: some View { + VStack(spacing: 12) { + Text("PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches.") + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) - Image(systemName: path.icon) - .foregroundStyle(.secondary) - .frame(width: 16) + Button { + openSettingsAndAdvance() + } label: { + Label("Open System Settings", systemImage: "gear") + .frame(minWidth: 200) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut(.defaultAction) + .padding(.top, 4) - Text(path.label) - .font(.callout) + Text("We'll guide you through the next steps.") + .font(.caption) + .foregroundStyle(.tertiary) + } + } - Spacer() + private var instructionsView: some View { + VStack(spacing: 10) { + Text("In System Settings, do this:") + .font(.callout) + .foregroundStyle(.secondary) - Text(path.accessible ? "Accessible" : "Blocked") - .font(.caption) - .foregroundStyle(path.accessible ? .green : .orange) - } - .padding(.vertical, 4) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(path.accessible ? Color.green.opacity(0.06) : Color.orange.opacity(0.06)) - ) - .opacity(appeared ? 1 : 0) - .offset(x: appeared ? 0 : -20) - .animation(.easeOut(duration: 0.4).delay(Double(index) * 0.05), value: appeared) - } + VStack(alignment: .leading, spacing: 8) { + stepRow(number: 1, text: "Privacy & Security → Full Disk Access") + stepRow(number: 2, text: "Find **PureMac** and turn the toggle on") + stepRow(number: 3, text: "Authenticate with Touch ID or your password") } - .frame(maxWidth: 400) - .padding(.vertical, 4) + .frame(maxWidth: 420, alignment: .leading) - if !hasFullDiskAccess { + HStack(spacing: 8) { Button { FullDiskAccessManager.shared.openFullDiskAccessSettings() } label: { - Label("Open System Settings", systemImage: "gear") + Label("Reopen Settings", systemImage: "gear") + } + + Menu { + Button("PureMac isn't in the list — reveal it") { + FullDiskAccessManager.shared.revealAppInFinder() + } + Button("Reset permissions and re-prompt") { + _ = FullDiskAccessManager.shared.resetFullDiskAccess() + FullDiskAccessManager.shared.triggerRegistration() + refreshAccessChecks() + } + Divider() + Button(showDiagnostics ? "Hide diagnostics" : "Show diagnostics") { + showDiagnostics.toggle() + } + } label: { + Label("Trouble?", systemImage: "questionmark.circle") } - .buttonStyle(.borderedProminent) - .padding(.top, 4) + .menuStyle(.borderlessButton) + .fixedSize() + } + .padding(.top, 4) - Text("Enable PureMac in Privacy & Security → Full Disk Access") + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Waiting for permission…") .font(.caption) .foregroundStyle(.secondary) } + .padding(.top, 2) + + if showDiagnostics { + diagnosticsList + } + } + } + private var grantedView: some View { + VStack(spacing: 8) { + Text("Permission granted.") + .foregroundStyle(.green) + .font(.callout.weight(.semibold)) + Text("PureMac can now manage protected files.") + .font(.caption) + .foregroundStyle(.secondary) + } + .transition(.opacity.combined(with: .scale)) + } + + private func stepRow(number: Int, text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text("\(number)") + .font(.callout.bold()) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Circle().fill(Color.accentColor)) + Text(.init(text)) + .font(.callout) Spacer() } - .padding() - .transition(.asymmetric( - insertion: .move(edge: .trailing).combined(with: .opacity), - removal: .move(edge: .leading).combined(with: .opacity) - )) + } + + private var diagnosticsList: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(accessResults) { path in + HStack(spacing: 8) { + Image(systemName: path.accessible ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(path.accessible ? .green : .red.opacity(0.7)) + .font(.system(size: 11)) + Image(systemName: path.icon) + .foregroundStyle(.secondary) + .frame(width: 14) + Text(path.label) + .font(.caption) + Spacer() + Text(path.accessible ? "OK" : "Blocked") + .font(.caption2) + .foregroundStyle(path.accessible ? .green : .orange) + } + } + } + .frame(maxWidth: 360) + .padding(8) + .background(RoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.08))) + } + + private func openSettingsAndAdvance() { + FullDiskAccessManager.shared.openFullDiskAccessSettings() + withAnimation(.easeInOut(duration: 0.25)) { + hasOpenedSettings = true + } } // MARK: - Ready diff --git a/PureMac/Views/Orphans/OrphanListView.swift b/PureMac/Views/Orphans/OrphanListView.swift index cc47617..6305b26 100644 --- a/PureMac/Views/Orphans/OrphanListView.swift +++ b/PureMac/Views/Orphans/OrphanListView.swift @@ -106,6 +106,14 @@ struct OrphanListView: View { } private func fileSize(_ url: URL) -> Int64? { + if let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), + let size = values.totalFileAllocatedSize, size > 0 { + return Int64(size) + } + if let values = try? url.resourceValues(forKeys: [.fileAllocatedSizeKey]), + let size = values.fileAllocatedSize { + return Int64(size) + } guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let size = attrs[.size] as? Int64 else { return nil } return size diff --git a/PureMac/Views/SmartScanView.swift b/PureMac/Views/SmartScanView.swift deleted file mode 100644 index ebbdd9f..0000000 --- a/PureMac/Views/SmartScanView.swift +++ /dev/null @@ -1,253 +0,0 @@ -import SwiftUI - -struct SmartScanView: View { - @EnvironmentObject var appState: AppState - @State private var showConfirmation = false - - var body: some View { - VStack(spacing: 20) { - Spacer() - - switch appState.scanState { - case .idle: - idleView - case .scanning: - scanningView - case .completed: - completedView - case .cleaning: - cleaningView - case .cleaned: - cleanedView - } - - Spacer() - } - .padding() - } - - // MARK: - Idle - - private var idleView: some View { - VStack(spacing: 20) { - Image(systemName: "magnifyingglass") - .font(.system(size: 48)) - .foregroundStyle(.secondary) - - Text("Ready to Scan") - .font(.title2) - - GroupBox("Disk Usage") { - Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { - GridRow { - Text("Total") - .foregroundStyle(.secondary) - Text(appState.diskInfo.formattedTotal) - .fontWeight(.medium) - } - GridRow { - Text("Used") - .foregroundStyle(.secondary) - Text(appState.diskInfo.formattedUsed) - .fontWeight(.medium) - } - GridRow { - Text("Free") - .foregroundStyle(.secondary) - Text(appState.diskInfo.formattedFree) - .fontWeight(.medium) - } - if appState.diskInfo.purgeableSpace > 0 { - GridRow { - Text("Purgeable") - .foregroundStyle(.secondary) - Text(appState.diskInfo.formattedPurgeable) - .fontWeight(.medium) - } - } - } - .padding(.vertical, 4) - } - .frame(maxWidth: 300) - - Button("Start Scan") { - appState.startSmartScan() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - } - - // MARK: - Scanning - - private var scanningView: some View { - VStack(spacing: 20) { - Text("Scanning...") - .font(.title2) - - ProgressView(value: appState.scanProgress) { - Text(appState.currentScanCategory) - .foregroundStyle(.secondary) - } currentValueLabel: { - Text("\(Int(appState.scanProgress * 100))%") - } - .progressViewStyle(.linear) - .frame(maxWidth: 350) - - if !appState.allResults.isEmpty { - GroupBox("Results Found") { - VStack(alignment: .leading, spacing: 6) { - ForEach(appState.allResults.prefix(6)) { result in - HStack { - Image(systemName: result.category.icon) - .frame(width: 20) - Text(LocalizedStringKey(result.category.rawValue)) - Spacer() - Text(result.formattedSize) - .foregroundStyle(.secondary) - } - .font(.callout) - } - } - .padding(.vertical, 4) - } - .frame(maxWidth: 400) - } - } - } - - // MARK: - Completed - - private var completedView: some View { - VStack(spacing: 20) { - if appState.totalJunkSize > 0 { - Text(ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file)) - .font(.system(size: 36, weight: .bold, design: .rounded)) - - Text("junk found") - .font(.title3) - .foregroundStyle(.secondary) - - GroupBox { - VStack(alignment: .leading, spacing: 6) { - ForEach(appState.allResults) { result in - CategoryToggleRow(result: result) - } - } - .padding(.vertical, 4) - } - .frame(maxWidth: 450) - - HStack(spacing: 12) { - if appState.totalSelectedSize > 0 { - Button("Clean Selected (\(ByteCountFormatter.string(fromByteCount: appState.totalSelectedSize, countStyle: .file)))") { - showConfirmation = true - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - - Button("Scan Again") { - appState.startSmartScan() - } - .controlSize(.large) - } - .confirmationDialog("Clean \(ByteCountFormatter.string(fromByteCount: appState.totalSelectedSize, countStyle: .file))?", isPresented: $showConfirmation, titleVisibility: .visible) { - Button("Clean", role: .destructive) { - appState.cleanAll() - } - Button("Cancel", role: .cancel) {} - } message: { - Text("This will permanently delete the selected files. This cannot be undone.") - } - } else { - Image(systemName: "checkmark.circle") - .font(.system(size: 48)) - .foregroundStyle(.green) - - Text("Your Mac is clean") - .font(.title2) - - Button("Scan Again") { - appState.startSmartScan() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - } - } - - // MARK: - Cleaning - - private var cleaningView: some View { - VStack(spacing: 20) { - ProgressView(value: appState.cleanProgress) { - Text("Cleaning...") - .font(.title3) - } currentValueLabel: { - Text("\(Int(appState.cleanProgress * 100))%") - } - .progressViewStyle(.linear) - .frame(maxWidth: 350) - } - } - - // MARK: - Cleaned - - private var cleanedView: some View { - VStack(spacing: 20) { - Image(systemName: "checkmark.circle") - .font(.system(size: 48)) - .foregroundStyle(.green) - - Text(ByteCountFormatter.string(fromByteCount: appState.totalFreedSpace, countStyle: .file)) - .font(.system(size: 36, weight: .bold, design: .rounded)) - - Text("freed") - .font(.title3) - .foregroundStyle(.secondary) - - Button("Done") { - appState.scanState = .idle - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } - } -} - -// MARK: - Category Toggle Row - -private struct CategoryToggleRow: View { - @EnvironmentObject var appState: AppState - let result: CategoryResult - - private var isFullySelected: Bool { - appState.selectedCountInCategory(result.category) == result.itemCount - } - - var body: some View { - Toggle(isOn: Binding( - get: { isFullySelected }, - set: { newValue in - if newValue { - appState.selectAllInCategory(result.category) - } else { - appState.deselectAllInCategory(result.category) - } - } - )) { - HStack { - Image(systemName: result.category.icon) - .frame(width: 20) - Text(LocalizedStringKey(result.category.rawValue)) - Spacer() - Text("\(result.itemCount) items") - .foregroundStyle(.secondary) - Text(result.formattedSize) - .fontWeight(.medium) - } - } - .toggleStyle(.checkbox) - } -} diff --git a/docs/ui-prototype/new.html b/docs/ui-prototype/new.html new file mode 100644 index 0000000..ad7c451 --- /dev/null +++ b/docs/ui-prototype/new.html @@ -0,0 +1,808 @@ + + + + +PureMac — Proposed UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ PureMac +
+
+
+ + +
+ + +
+ +
+ + +
+
+
+ + + + diff --git a/docs/ui-prototype/old.html b/docs/ui-prototype/old.html new file mode 100644 index 0000000..edfe80f --- /dev/null +++ b/docs/ui-prototype/old.html @@ -0,0 +1,274 @@ + + + + +PureMac — Current UI (faithful prototype) + + + +
+
+
+
+
+
+
+
PureMac
+
+
+ +
+ + +
+
+ +
+
Full Disk Access required
+
macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access.
+
+ + +
+ +
+ +
+
+
+
+ + + +