From 50b41a763bd3f43f1c73b84ed4accac10108a319 Mon Sep 17 00:00:00 2001 From: maxdp66 Date: Wed, 22 Apr 2026 18:13:08 +0000 Subject: [PATCH] fix: improve Full Disk Access detection and handling - Rewrite hasFullDiskAccess heuristic to use real TCC probing - Primary: try contentsOfDirectory on ~/Library/Mail; EPERM/EACCES = no FDA - Fallback 1: Desktop must have readable files (not just empty dir) - Fallback 2: Safari Bookmarks.plist readability - Fallback 3: Documents readable files - Remove broken TCC.db check and non-TCC .Trash check - Add continuous FDA monitoring - Periodic timer (60s) re-checks FDA status in AppState.init - On app activation (scenePhase == .active) immediately re-check FDA - Ensures revocation mid-session is detected quickly - Gate deletion with pre-flight FDA check - removeSelectedFiles() calls FullDiskAccessManager.shared.hasFullDiskAccess synchronously before invoking Finder trash - If FDA false: abort early with clear error message (no cryptic AppleScript failures) Fixes issue where user granted FDA but PureMac still reported it as denied ('Privacy already allowed' bug), blocking app deletion. Co-authored-by: Nyx --- PureMac/PureMacApp.swift | 9 ++ PureMac/Services/FullDiskAccessManager.swift | 93 ++++++++++++-------- PureMac/ViewModels/AppState.swift | 17 ++++ 3 files changed, 82 insertions(+), 37 deletions(-) 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 }