diff --git a/PureMac/PureMacApp.swift b/PureMac/PureMacApp.swift index fde9bff..030d840 100644 --- a/PureMac/PureMacApp.swift +++ b/PureMac/PureMacApp.swift @@ -14,6 +14,7 @@ struct PureMacApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var appState = AppState() @AppStorage("PureMac.OnboardingComplete") private var onboardingComplete = false + @Environment(\.scenePhase) private var scenePhase init() { // Enter CLI mode only when the first arg is a known command. Xcode and @@ -42,6 +43,14 @@ struct PureMacApp: App { .commands { CommandGroup(replacing: .newItem) {} } + // When the app becomes active (e.g., after returning from System Settings) + // re-check Full Disk Access immediately so permissions are detected + // without waiting for the 60 s periodic timer. + .onChange(of: scenePhase) { newPhase in + if newPhase == .active { + appState.checkFullDiskAccess() + } + } Settings { SettingsView() diff --git a/PureMac/Services/FullDiskAccessManager.swift b/PureMac/Services/FullDiskAccessManager.swift index 82f4ac4..0c7a6e0 100644 --- a/PureMac/Services/FullDiskAccessManager.swift +++ b/PureMac/Services/FullDiskAccessManager.swift @@ -6,57 +6,76 @@ import Foundation /// ~/.Trash, and other app containers even for non-sandboxed apps. 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 location. The heuristic is: + /// - Try to read a file from a TCC-protected directory (~/Library/Mail) + /// - If we get EPERM/EACCES, FDA is denied + /// - If we can read the file (or the file doesn't exist), FDA is likely granted + /// - Fallback: check that Desktop exists and is readable 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 = [ - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Mail").path, - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Safari/Bookmarks.plist").path, - "/Library/Application Support/com.apple.TCC/TCC.db", - ] + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser.path + + // Primary probe: ~/Library/Mail directory contents. + // Even if the user has never used Mail, the directory may not exist. + // In that case, fall back to checking Desktop readability. + let mailPath = "\(home)/Library/Mail" - for path in protectedPaths { - if FileManager.default.isReadableFile(atPath: path) { + if fileManager.fileExists(atPath: mailPath) { + // TCC blocks _reading file contents_ but not directory traversal. + // The best signal: can we read a known mailbox file if it exists? + // Mail stores messages as individual files in subdirectories. + // Try enumerating the directory; if enumeration fails with EPERM, FDA denied. + // If enumeration succeeds, FDA grants at least some access to that location. + do { + _ = try fileManager.contentsOfDirectory(atPath: mailPath) + // If we got here, we successfully enumerated Mail dir → FDA likely granted return true + } catch { + // EPERM or EACCES = permission denied by TCC = no FDA + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && + (nsError.code == NSFileReadNoPermissionError || + nsError.code == NSFileWriteNoPermissionError) { + return false + } + // Some other error (file not found, etc.) — try fallback checks } } - // 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 + // Fallback 1: Check Desktop has readable files (not just empty dir). + // An empty Desktop is ambiguous; a non-empty readable Desktop suggests FDA. + let desktopPath = "\(home)/Desktop" + if let desktopContents = try? fileManager.contentsOfDirectory(atPath: desktopPath), + !desktopContents.isEmpty { + // Verify at least one file is actually readable (TCC might hide contents) + let hasReadable = desktopContents.contains { item in + let fullPath = (desktopPath as NSString).appendingPathComponent(item) + return fileManager.isReadableFile(atPath: fullPath) + } + if hasReadable { return true } } - // 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 - return false + // Fallback 2: Safari Bookmarks (historically TCC-protected for some macOS versions) + let safariPath = "\(home)/Library/Safari/Bookmarks.plist" + if fileManager.isReadableFile(atPath: safariPath) { + return true } - // 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) + // Fallback 3: Documents directory has readable files + let docsPath = "\(home)/Documents" + if let docsContents = try? fileManager.contentsOfDirectory(atPath: docsPath), + !docsContents.isEmpty { + let hasReadable = docsContents.contains { item in + let fullPath = (docsPath as NSString).appendingPathComponent(item) + return fileManager.isReadableFile(atPath: fullPath) + } + if hasReadable { return true } } - // Can't determine definitively - default to warning the user + // Cannot confirm FDA — assume not granted to be safe return false } diff --git a/PureMac/ViewModels/AppState.swift b/PureMac/ViewModels/AppState.swift index 891817e..51a2a0c 100644 --- a/PureMac/ViewModels/AppState.swift +++ b/PureMac/ViewModels/AppState.swift @@ -48,6 +48,7 @@ final class AppState: ObservableObject { var scheduler = SchedulerService() private let scanEngine = ScanEngine() private let cleaningEngine = CleaningEngine() + private var cancellables = Set() // MARK: - Computed @@ -72,6 +73,12 @@ final class AppState: ObservableObject { init() { loadDiskInfo() checkFullDiskAccess() + // Re-check FDA every 60 seconds to detect System Settings changes. + // If FDA is revoked mid-session, UI updates to reflect it immediately. + Timer.publish(every: 60, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in self?.checkFullDiskAccess() } + .store(in: &cancellables) loadInstalledApps() scheduler.setTrigger { [weak self] in await self?.runScheduledScan() @@ -149,6 +156,16 @@ final class AppState: ObservableObject { } return } + + // Pre-deletion FDA check — fail fast with a clearer message if FDA is off. + // This avoids invoking Finder only to get a cryptic error. + // Synchronous direct check for freshest status. + if !FullDiskAccessManager.shared.hasFullDiskAccess { + removalError = "Full Disk Access is required to delete files from protected locations. Open System Settings → Privacy & Security → Full Disk Access and ensure PureMac is enabled." + Logger.shared.log("Deletion blocked — FDA not available", level: .error) + return + } + trashViaFinder(urls: urls) { [weak self] success in DispatchQueue.main.async { guard let self else { return }