import SwiftUI import Combine import UserNotifications import AppKit // InstalledApp is defined in AppInfoFetcher.swift enum AppSection: Hashable { case apps case orphans case cleaning(CleaningCategory) } extension Notification.Name { /// Posted by the Finder Services handler ("Uninstall with PureMac") with a /// `["path": String]` userInfo pointing at the right-clicked .app bundle. static let pureMacExternalUninstall = Notification.Name("PureMac.ExternalUninstall") } /// Cold-launch buffer for Finder Services. A "Uninstall with PureMac" request /// can arrive before the SwiftUI scene (and thus AppState) exists; the posted /// notification then has no subscriber and is lost (NotificationCenter has no /// replay). AppDelegate stashes the path here and AppState drains it in init. enum ExternalUninstallBuffer { // Written by the Finder Services handler and drained by AppState — both // run on the main thread, so a plain static is sufficient here. static var pendingPath: String? } /// Standalone observable for the live scan-path ticker. The scan engine reports /// the filesystem path it is touching ~10×/sec. Routing that through AppState's /// own `@Published` storage republished the *entire* view tree at that rate, /// which surfaced as window-drag / button-hover lag and a Smart Scan that /// looked frozen until you switched sidebar sections and forced a fresh render /// (issues #119, #120). Isolating the high-frequency value here means only the /// small ticker label observes it, so the rest of the UI stays still. @MainActor final class ScanProgressTicker: ObservableObject { @Published var path: String = "" } @MainActor final class AppState: ObservableObject { typealias AppFileScanner = @MainActor ( _ app: InstalledApp, _ locations: Locations, _ completion: @escaping (Set) -> Void ) -> Void // MARK: - Scan / Clean State @Published var selectedCategory: CleaningCategory = .smartScan @Published var scanState: ScanState = .idle @Published var categoryResults: [CleaningCategory: CategoryResult] = [:] @Published var diskInfo = DiskInfo() @Published var totalJunkSize: Int64 = 0 @Published var totalFreedSpace: Int64 = 0 @Published var scanProgress: Double = 0 @Published var cleanProgress: Double = 0 @Published var currentScanCategory: String = "" /// Live filesystem path the scan engine is touching, feeding the dashboard's /// ticker. Deliberately NOT a `@Published` on AppState — it updates ~10×/sec /// and would otherwise invalidate the whole view tree (issues #119, #120). /// Only the ticker label observes this object directly. let scanTicker = ScanProgressTicker() @Published var showCleanConfirmation = false @Published var lastCleanedDate: Date? @Published var selectedCleanupItems: Set = [] @Published var deselectedItems: Set = [] @Published var hasFullDiskAccess: Bool = true @Published var fdaBannerDismissed: Bool = false @Published var cleanError: String? /// True when the most recent clean error is rooted in a TCC/FDA refusal /// (i.e. items survived even the admin pass). MainWindow uses this to /// route the user into the PermissionSheet instead of the generic alert. @Published var cleanErrorIsFDAFixable: Bool = false /// Items that survived the most recent clean attempt — used to re-run the /// operation after the user grants Full Disk Access without forcing them /// to re-select anything. @Published var pendingPermissionRetryItems: [CleanableItem] = [] // MARK: - App Uninstaller State @Published var installedApps: [InstalledApp] = [] @Published var selectedApp: InstalledApp? @Published var discoveredFiles: [URL] = [] @Published var selectedFiles: Set = [] @Published var orphanedFiles: [URL] = [] @Published var isSearchingOrphans: Bool = false @Published var isLoadingApps: Bool = false @Published var isScanningAppFiles: Bool = false @Published var removalError: String? @Published var removalNeedsFullDiskAccess = false /// Snapshot of the URLs that failed the most recent uninstall due to a /// permission denial. Frozen at finishRemoval time so AppFilesView's /// retry path operates on the failed batch even if the user clicks a /// different app or mutates selection while the FDA sheet is open. @Published var lastFailedRemovalURLs: [URL] = [] @Published var appFileScanLocationCount: Int = 0 /// Set when a right-clicked app arrives via the Finder Services handler. /// MainWindow consumes it on both onChange AND onAppear so a request that /// lands before MainWindow mounts (cold launch, or while onboarding is /// still showing) is still surfaced — a one-shot token would be missed. @Published var pendingExternalApp: InstalledApp? private var externalUninstallObserver: AnyCancellable? // MARK: - Services var scheduler = SchedulerService() private let scanEngine = ScanEngine() private let cleaningEngine = CleaningEngine() private let locationsProvider: () -> Locations private let appFileScanner: AppFileScanner // MARK: - Computed var totalItemCount: Int { categoryResults.values.reduce(0) { $0 + $1.itemCount } } var currentCategoryResult: CategoryResult? { categoryResults[selectedCategory] } var allResults: [CategoryResult] { CleaningCategory.scannable.compactMap { categoryResults[$0] }.filter { $0.totalSize > 0 } } var totalSelectedSize: Int64 { allResults.flatMap { $0.items }.filter { isItemSelected($0) }.reduce(0) { $0 + $1.size } } var currentAppFileSearchLocationCount: Int { if isScanningAppFiles && appFileScanLocationCount > 0 { return appFileScanLocationCount } return discoveredFiles.count } // MARK: - Init init( performStartupTasks: Bool = true, locationsProvider: @escaping () -> Locations = Locations.init, appFileScanner: @escaping AppFileScanner = AppState.defaultAppFileScanner ) { self.locationsProvider = locationsProvider self.appFileScanner = appFileScanner // Listen for right-click "Uninstall with PureMac" hand-offs from the // Finder Services handler in AppDelegate. externalUninstallObserver = NotificationCenter.default .publisher(for: .pureMacExternalUninstall) .receive(on: RunLoop.main) .sink { [weak self] note in let path = (note.userInfo?["path"] as? String) ?? ExternalUninstallBuffer.pendingPath ExternalUninstallBuffer.pendingPath = nil guard let path else { return } Task { @MainActor in self?.presentExternalUninstall(appPath: path) } } // Drain a request that arrived before this AppState existed (cold launch // via Finder Services — the notification fired with no subscriber). if let buffered = ExternalUninstallBuffer.pendingPath { ExternalUninstallBuffer.pendingPath = nil presentExternalUninstall(appPath: buffered) } if performStartupTasks { loadDiskInfo() checkFullDiskAccess() loadInstalledApps() scheduler.setTrigger { [weak self] in await self?.runScheduledScan() } // Only arm the scheduler once onboarding has completed. Before // the first launch the defaults plist may have been // attacker-planted with autoClean=true; wait for human consent // via onboarding. if UserDefaults.standard.bool(forKey: "PureMac.OnboardingComplete") { scheduler.start() } } } // MARK: - App Loading func loadInstalledApps() { isLoadingApps = true Task.detached(priority: .userInitiated) { let apps = AppInfoFetcher.shared.fetchInstalledApps() await MainActor.run { [weak self] in self?.installedApps = apps self?.isLoadingApps = false } } } /// Resolve a right-clicked .app (via the Finder Services handler) into the /// uninstaller: select it, kick off the related-files scan, and signal /// MainWindow to surface the Installed Apps section. func presentExternalUninstall(appPath: String) { let url = URL(fileURLWithPath: appPath) if let cached = installedApps.first(where: { $0.path.standardizedFileURL == url.standardizedFileURL }) { applyExternalUninstall(cached) return } // Not in the cached list (non-standard location, or a cold start before // loadInstalledApps finished). Resolve off the main thread — fetchApp // walks the entire bundle to size it, which would beachball the UI for // a multi-gigabyte app. Task.detached(priority: .userInitiated) { let app = AppInfoFetcher.shared.fetchApp(at: url) await MainActor.run { [weak self] in guard let self else { return } guard let app else { Logger.shared.log("Finder uninstall request rejected (non-app or protected): \(appPath)", level: .warning) return } self.applyExternalUninstall(app) } } } private func applyExternalUninstall(_ app: InstalledApp) { selectedApp = app pendingExternalApp = app scanForAppFiles(app) } func scanForAppFiles(_ app: InstalledApp) { discoveredFiles = [] selectedFiles = [] isScanningAppFiles = true let locations = locationsProvider() appFileScanLocationCount = locations.appSearch.paths.count appFileScanner(app, locations) { [weak self] urls in guard let self else { return } let sorted = urls.sorted { $0.path < $1.path } self.discoveredFiles = sorted self.selectedFiles = urls self.isScanningAppFiles = false self.appFileScanLocationCount = 0 } } func removeSelectedFiles() { // Re-entrance guard: if a previous removal is still resolving and // the FDA sheet/retry hasn't finished, a second call would race- // overwrite `lastFailedRemovalURLs` before the first batch's retry // closure read it. We can't gate on `removalNeedsFullDiskAccess` // alone — AppFilesView clears that flag the moment it hands off to // the coordinator, leaving a window where a second remove call // would pass the guard while the coordinator is still polling. // PermissionCoordinator.isRequesting covers the full sheet-open + // retry-pending span. guard !removalNeedsFullDiskAccess, !PermissionCoordinator.shared.isRequesting else { Logger.shared.log("Refused duplicate removeSelectedFiles while FDA flow is active", level: .info) return } // Safety guard: never allow a high-risk home dotpath (listed in // 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 let isBlocked = highRiskHomeDotPaths.contains { root in resolved == root || resolved.hasPrefix(root + "/") } if isBlocked { acc.1.append(url) } else { acc.0.append(url) } } removalError = nil removalNeedsFullDiskAccess = false if !blocked.isEmpty { let blockedList = blocked.map(\.path).joined(separator: ", ") Logger.shared.log("Refused to delete \(blocked.count) high-risk home dotpath(s): \(blockedList)", level: .warning) selectedFiles.subtract(blocked) } guard !urls.isEmpty else { if !blocked.isEmpty { removalError = "Refused to delete \(blocked.count) protected item(s) (home credential directory or similar)." } return } trashDirectly(urls: urls) { [weak self] removed, needsFullDiskAccess, needsAdmin, failed in Task { @MainActor in guard let self else { return } self.applyRemovedAppFiles(removed) guard !needsAdmin.isEmpty else { self.finishRemoval( removedAny: !removed.isEmpty, needsFullDiskAccess: needsFullDiskAccess, attemptedAdmin: false, failed: failed, adminError: nil ) return } let items = needsAdmin.map { self.cleanableUninstallItem(for: $0) } let adminResult = await self.cleaningEngine.cleanWithAdminPrivileges(items: items) let adminRemoved = needsAdmin.filter { adminResult.cleanedPaths.contains($0.path) } let adminFailed = needsAdmin.filter { !adminResult.cleanedPaths.contains($0.path) } self.applyRemovedAppFiles(adminRemoved) for url in adminRemoved { Logger.shared.log("Removed \(url.path) with administrator privileges", level: .info) } self.finishRemoval( removedAny: !removed.isEmpty || !adminRemoved.isEmpty, needsFullDiskAccess: needsFullDiskAccess, attemptedAdmin: true, failed: failed + adminFailed, adminError: adminResult.errors.joined(separator: "; ") ) } } } /// 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], Bool, [URL], [URL]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { let hasFullDiskAccess = FullDiskAccessManager.shared.hasFullDiskAccess var removed: [URL] = [] var needsFullDiskAccess = false var needsAdmin: [URL] = [] var failed: [URL] = [] for url in urls { var resulting: NSURL? do { try FileManager.default.trashItem(at: url, resultingItemURL: &resulting) removed.append(url) } catch { let nsError = error as NSError if Self.isMissingFileError(nsError) { Logger.shared.log("Trash skipped for \(url.path): file no longer exists", level: .info) removed.append(url) } else if Self.isPermissionDeniedError(nsError) { if hasFullDiskAccess || Self.isLikelyAdministratorRemovalPath(url) { needsAdmin.append(url) } else { needsFullDiskAccess = true failed.append(url) } } else { Logger.shared.log("Trash failed for \(url.path): \(error.localizedDescription)", level: .error) failed.append(url) } } } completion(removed, needsFullDiskAccess, needsAdmin, failed) } } private func applyRemovedAppFiles(_ urls: [URL]) { guard !urls.isEmpty else { return } // Animate the row sweep-out (AppFilesView attaches the per-row // transitions). NSWorkspace is the Reduce Motion check available // outside a View's Environment. if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { discoveredFiles.removeAll { urls.contains($0) } } else { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { discoveredFiles.removeAll { urls.contains($0) } } } selectedFiles.subtract(urls) Logger.shared.log("Removed \(urls.count) file\(urls.count == 1 ? "" : "s")", level: .info) } private func finishRemoval( removedAny: Bool, needsFullDiskAccess: Bool, attemptedAdmin: Bool, failed: [URL], adminError: String? ) { // Freeze the failed batch before the FDA sheet opens so the retry // path can't be poisoned by later selection edits or app switches. lastFailedRemovalURLs = needsFullDiskAccess ? failed : [] removalNeedsFullDiskAccess = needsFullDiskAccess if let message = removalFailureMessage( needsFullDiskAccess: needsFullDiskAccess, attemptedAdmin: attemptedAdmin, failed: failed, adminError: adminError ) { removalError = message Logger.shared.log(message, level: .error) } if removedAny { pruneMissingInstalledApps() } } /// Public bridge so views (e.g. AppFilesView) can build CleanableItem rows /// from raw URLs when retrying via the PermissionCoordinator. func makeUninstallCleanableItem(for url: URL) -> CleanableItem { cleanableUninstallItem(for: url) } private func cleanableUninstallItem(for url: URL) -> CleanableItem { let values = try? url.resourceValues(forKeys: [ .totalFileAllocatedSizeKey, .fileAllocatedSizeKey, .contentModificationDateKey, ]) let size = Int64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0) return CleanableItem( name: url.lastPathComponent, path: url.path, size: size, category: .systemJunk, isSelected: true, lastModified: values?.contentModificationDate ) } private func removalFailureMessage( needsFullDiskAccess: Bool, attemptedAdmin: Bool, failed: [URL], adminError: String? ) -> String? { if needsFullDiskAccess { let prefix = failed.isEmpty ? "Some selected files" : "\(failed.count) file\(failed.count == 1 ? "" : "s")" return "\(prefix) could not be removed because PureMac does not have Full Disk Access. Grant Full Disk Access in System Settings, then try again." } if !failed.isEmpty { if attemptedAdmin { return "\(failed.count) file\(failed.count == 1 ? "" : "s") could not be removed with administrator privileges. The items may have changed or macOS denied access." } return "\(failed.count) file\(failed.count == 1 ? "" : "s") could not be removed. Check that the items still exist and are not in use." } if let adminError, !adminError.isEmpty { return "Administrator removal failed: \(adminError)" } return nil } private nonisolated static func isMissingFileError(_ nsError: NSError) -> Bool { (nsError.domain == NSCocoaErrorDomain && (nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError)) || (nsError.domain == NSPOSIXErrorDomain && nsError.code == Int(ENOENT)) } private nonisolated static func isPermissionDeniedError(_ nsError: NSError) -> Bool { (nsError.domain == NSCocoaErrorDomain && (nsError.code == NSFileReadNoPermissionError || nsError.code == NSFileWriteNoPermissionError)) || (nsError.domain == NSPOSIXErrorDomain && (nsError.code == Int(EACCES) || nsError.code == Int(EPERM))) } private nonisolated static func isLikelyAdministratorRemovalPath(_ url: URL) -> Bool { let path = (url.resolvingSymlinksInPath().path as NSString).standardizingPath let home = FileManager.default.homeDirectoryForCurrentUser.path return hasAppBundleComponent(path, rootedAt: "/Applications") || hasAppBundleComponent(path, rootedAt: "\(home)/Applications") || isDirectFile(path, in: "/private/var/db/receipts", extensions: ["plist", "bom"]) || isDirectFile(path, in: "/var/db/receipts", extensions: ["plist", "bom"]) || isDirectFile(path, in: "/Library/LaunchDaemons", extensions: ["plist"]) || isDirectFile(path, in: "/Library/LaunchAgents", extensions: ["plist"]) } private nonisolated static func hasAppBundleComponent(_ path: String, rootedAt root: String) -> Bool { let normalizedRoot = (root as NSString).standardizingPath guard path != normalizedRoot else { return false } let rootWithSeparator = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/" guard path.hasPrefix(rootWithSeparator) else { return false } let relative = String(path.dropFirst(rootWithSeparator.count)) return relative.split(separator: "/").contains { component in component.lowercased().hasSuffix(".app") } } private nonisolated static func isDirectFile(_ path: String, in root: String, extensions: Set) -> Bool { let parent = ((path as NSString).deletingLastPathComponent as NSString).standardizingPath guard parent == (root as NSString).standardizingPath else { return false } return extensions.contains((path as NSString).pathExtension.lowercased()) } private func pruneMissingInstalledApps() { let fileManager = FileManager.default installedApps.removeAll { !fileManager.fileExists(atPath: $0.path.path) } if let selectedApp, !fileManager.fileExists(atPath: selectedApp.path.path) { self.selectedApp = nil discoveredFiles = [] selectedFiles = [] } } func findOrphans() { isSearchingOrphans = true orphanedFiles = [] Task.detached(priority: .userInitiated) { let locations = Locations() let knownApps = await MainActor.run { self.installedApps } let knownIDs = Set(knownApps.map { $0.bundleIdentifier.normalizedForMatching() }) let knownNames = Set(knownApps.map { $0.appName.normalizedForMatching() }) // Paths the user marked "Always Ignore" (issue #114). These were // false positives for them, so they stay hidden from every scan // until the user forgets the list in Settings. let ignored = Set(UserDefaults.standard.stringArray(forKey: Self.ignoredOrphansKey) ?? []) var orphans: [URL] = [] let fm = FileManager.default for path in locations.reverseSearch.paths { guard let contents = try? fm.contentsOfDirectory(atPath: path) else { continue } for item in contents { let normalized = item.normalizedForMatching() // Skip known system items if skipReverse.contains(where: { normalized.hasPrefix($0) }) { continue } // Check if this item belongs to any known app let belongsToApp = knownIDs.contains(where: { normalized.contains($0) }) || knownNames.contains(where: { normalized.contains($0) }) if !belongsToApp { let fullPath = URL(fileURLWithPath: path).appendingPathComponent(item) if ignored.contains(fullPath.path) { continue } if OrphanSafetyPolicy.isSafeCandidate(fullPath) { orphans.append(fullPath) } } } } let sorted = orphans.sorted { $0.lastPathComponent < $1.lastPathComponent } await MainActor.run { [weak self] in self?.orphanedFiles = sorted self?.isSearchingOrphans = false } } } // MARK: - Orphan ignore list (#114) static let ignoredOrphansKey = "settings.orphans.ignored" /// Number of paths currently on the "always ignore" list. Read from /// UserDefaults each access so the Settings row tracks live changes. var ignoredOrphanCount: Int { UserDefaults.standard.stringArray(forKey: Self.ignoredOrphansKey)?.count ?? 0 } /// Permanently hide the given orphans from future scans and sweep them out /// of the current results. func ignoreOrphans(_ urls: [URL]) { guard !urls.isEmpty else { return } var ignored = Set(UserDefaults.standard.stringArray(forKey: Self.ignoredOrphansKey) ?? []) for url in urls { ignored.insert(url.path) } UserDefaults.standard.set(Array(ignored), forKey: Self.ignoredOrphansKey) let urlSet = Set(urls) if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { orphanedFiles.removeAll { urlSet.contains($0) } } else { withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { orphanedFiles.removeAll { urlSet.contains($0) } } } Logger.shared.log("Ignoring \(urls.count) orphan(s) in future scans", level: .info) } /// Forget every ignored path so they can surface again on the next scan. func clearIgnoredOrphans() { UserDefaults.standard.removeObject(forKey: Self.ignoredOrphansKey) objectWillChange.send() } // MARK: - Selection func isItemSelected(_ item: CleanableItem) -> Bool { if item.isSelected { return !deselectedItems.contains(item.id) } return selectedCleanupItems.contains(item.id) } func toggleItem(_ item: CleanableItem) { if isItemSelected(item) { if item.isSelected { deselectedItems.insert(item.id) } else { selectedCleanupItems.remove(item.id) } } else { if item.isSelected { deselectedItems.remove(item.id) } else { selectedCleanupItems.insert(item.id) } } } func selectAllInCategory(_ category: CleaningCategory) { guard let result = categoryResults[category] else { return } for item in result.items { if item.isSelected { deselectedItems.remove(item.id) } else { selectedCleanupItems.insert(item.id) } } } func deselectAllInCategory(_ category: CleaningCategory) { guard let result = categoryResults[category] else { return } for item in result.items { if item.isSelected { deselectedItems.insert(item.id) } else { selectedCleanupItems.remove(item.id) } } } func selectedSizeInCategory(_ category: CleaningCategory) -> Int64 { guard let result = categoryResults[category] else { return 0 } return result.items.filter { isItemSelected($0) }.reduce(0) { $0 + $1.size } } func selectedCountInCategory(_ category: CleaningCategory) -> Int { guard let result = categoryResults[category] else { return 0 } return result.items.filter { isItemSelected($0) }.count } // MARK: - Helper Methods func categorySize(for category: CleaningCategory) -> String { guard let result = categoryResults[category], result.totalSize > 0 else { return "" } return result.formattedSize } func categoryBinding(for category: CleaningCategory) -> Binding { Binding( get: { [weak self] in guard let self else { return false } return self.selectedCountInCategory(category) > 0 }, set: { [weak self] newValue in guard let self else { return } if newValue { self.selectAllInCategory(category) } else { self.deselectAllInCategory(category) } } ) } func itemBinding(for item: CleanableItem) -> Binding { Binding( get: { [weak self] in self?.isItemSelected(item) ?? false }, set: { [weak self] _ in self?.toggleItem(item) } ) } private func clearSelectionState() { selectedCleanupItems.removeAll() deselectedItems.removeAll() } private func clearSelectionState(for category: CleaningCategory) { guard let result = categoryResults[category] else { return } for item in result.items { selectedCleanupItems.remove(item.id) deselectedItems.remove(item.id) } } // MARK: - Full Disk Access func checkFullDiskAccess() { Task.detached { let granted = FullDiskAccessManager.shared.hasFullDiskAccess await MainActor.run { [weak self] in self?.hasFullDiskAccess = granted } } } func openFullDiskAccessSettings() { FullDiskAccessManager.shared.openFullDiskAccessSettings() } /// Request Full Disk Access via the rich PermissionCoordinator sheet and /// retry the supplied items once the user grants permission. Used by both /// the cleanup and app-uninstall flows so they share a single UI surface. /// /// The retry callback captures `items` directly rather than reading /// `pendingPermissionRetryItems` at fire time — that field is mutable /// app-wide and a second permission request would clobber it before the /// first callback resolves, sending the wrong items to retryCleanItems. func requestFullDiskAccessAndRetry(items: [CleanableItem], context: PermissionCoordinator.PromptContext) { pendingPermissionRetryItems = items let capturedItems = items PermissionCoordinator.shared.requestAccess( context: context, failedPaths: items.map { $0.path } ) { [weak self] in Task { @MainActor [weak self] in guard let self else { return } self.pendingPermissionRetryItems = [] self.cleanError = nil self.cleanErrorIsFDAFixable = false guard !capturedItems.isEmpty else { return } await self.retryCleanItems(capturedItems) } } } private func retryCleanItems(_ items: [CleanableItem]) async { scanState = .cleaning(progress: 0) cleanProgress = 0 var result = await cleaningEngine.cleanItems(items) { [weak self] progress in Task { @MainActor [weak self] in self?.cleanProgress = progress self?.scanState = .cleaning(progress: progress) } } 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() for (cat, catResult) in categoryResults { let remaining = catResult.items.filter { !result.cleanedPaths.contains($0.path) } let cleared = catResult.items.filter { result.cleanedPaths.contains($0.path) } for item in cleared { selectedCleanupItems.remove(item.id) deselectedItems.remove(item.id) } 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 } // Route survivors back through the same outcome path the original // cleanup uses. Without this, an FDA revocation between grant and // retry would silently drop errors instead of re-popping the sheet. let survivors = items.filter { !result.cleanedPaths.contains($0.path) } handleCleanOutcome(errors: result.errors, survivors: survivors) scanState = .cleaned loadDiskInfo() try? await Task.sleep(nanoseconds: 3_000_000_000) scanState = .idle totalFreedSpace = 0 } // MARK: - Disk Info func loadDiskInfo() { Task { let info = await scanEngine.getDiskInfo() self.diskInfo = info } } // MARK: - Scanning func startSmartScan() { guard !scanState.isActive else { return } scanState = .scanning(progress: 0, currentCategory: "Preparing...") categoryResults = [:] totalJunkSize = 0 scanProgress = 0 clearSelectionState() Task { let categories = CleaningCategory.scannable let total = categories.count for (index, category) in categories.enumerated() { let progress = Double(index) / Double(total) scanProgress = progress currentScanCategory = category.rawValue scanState = .scanning(progress: progress, currentCategory: category.rawValue) let result = await scanEngine.scanCategory(category) { [weak self] path in Task { @MainActor [weak self] in self?.scanTicker.path = path } } categoryResults[category] = result totalJunkSize += result.totalSize } scanProgress = 1.0 scanTicker.path = "" scanState = .completed loadDiskInfo() } } func scanSingleCategory(_ category: CleaningCategory) { guard !scanState.isActive else { return } scanState = .scanning(progress: 0, currentCategory: category.rawValue) scanProgress = 0 Task { scanProgress = 0.5 clearSelectionState(for: category) let result = await scanEngine.scanCategory(category) { [weak self] path in Task { @MainActor [weak self] in self?.scanTicker.path = path } } categoryResults[category] = result totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } scanProgress = 1.0 scanTicker.path = "" scanState = .completed } } // MARK: - Cleaning func cleanAll() { guard !scanState.isActive else { return } let itemsToClean = allResults.flatMap { $0.items }.filter { isItemSelected($0) } guard !itemsToClean.isEmpty else { return } scanState = .cleaning(progress: 0) cleanProgress = 0 Task { 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() if result.itemsCleaned > 0 { Haptics.successWithSound() } let survivors = itemsToClean.filter { !result.cleanedPaths.contains($0.path) } for (cat, catResult) in categoryResults { let remaining = catResult.items.filter { !result.cleanedPaths.contains($0.path) } let cleared = catResult.items.filter { result.cleanedPaths.contains($0.path) } for item in cleared { selectedCleanupItems.remove(item.id) deselectedItems.remove(item.id) } 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 } handleCleanOutcome(errors: result.errors, survivors: survivors) scanState = .cleaned loadDiskInfo() try? await Task.sleep(nanoseconds: 3_000_000_000) scanState = .idle totalFreedSpace = 0 } } func cleanCategory(_ category: CleaningCategory) { guard let result = categoryResults[category], !scanState.isActive else { return } let selectedItems = result.items.filter { isItemSelected($0) } guard !selectedItems.isEmpty else { return } scanState = .cleaning(progress: 0) cleanProgress = 0 Task { 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() if let existing = categoryResults[category] { let remaining = existing.items.filter { !cleanResult.cleanedPaths.contains($0.path) } let cleared = existing.items.filter { cleanResult.cleanedPaths.contains($0.path) } for item in cleared { selectedCleanupItems.remove(item.id) deselectedItems.remove(item.id) } 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 } let survivors = selectedItems.filter { !cleanResult.cleanedPaths.contains($0.path) } handleCleanOutcome(errors: cleanResult.errors, survivors: survivors) scanState = .cleaned loadDiskInfo() try? await Task.sleep(nanoseconds: 3_000_000_000) scanState = .idle totalFreedSpace = 0 } } /// Inspect a clean batch's leftovers and either route the user into the /// PermissionSheet (FDA is the most likely cause) or surface a richer /// error alert that lists actual paths instead of "Check the log". private func handleCleanOutcome(errors: [String], survivors: [CleanableItem]) { guard !errors.isEmpty || !survivors.isEmpty else { cleanError = nil cleanErrorIsFDAFixable = false pendingPermissionRetryItems = [] return } let fdaGranted = FullDiskAccessManager.shared.hasFullDiskAccess let likelyFDA = !fdaGranted && !survivors.isEmpty cleanErrorIsFDAFixable = likelyFDA pendingPermissionRetryItems = survivors if likelyFDA { cleanError = String( format: String(localized: "%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step."), Int64(survivors.count) ) } else if !survivors.isEmpty { let preview = survivors.prefix(2).map { ($0.path as NSString).lastPathComponent }.joined(separator: ", ") let extra = survivors.count > 2 ? String(format: String(localized: " and %lld more"), Int64(survivors.count - 2)) : "" cleanError = String( format: String(localized: "Couldn't remove %@%@. They may be in use or protected by macOS."), preview, extra ) } else if let first = errors.first { cleanError = first } } // MARK: - Purgeable func purgePurgeable() { guard !scanState.isActive else { return } scanState = .cleaning(progress: 0) Task { scanState = .cleaning(progress: 0.5) let freed = await cleaningEngine.purgePurgeableSpace() totalFreedSpace = freed scanState = .cleaned loadDiskInfo() try? await Task.sleep(nanoseconds: 3_000_000_000) scanState = .idle totalFreedSpace = 0 } } // MARK: - Scheduled Scan private func runScheduledScan() async { let categories = scheduler.config.categoriesToScan var totalFound: Int64 = 0 clearSelectionState() categoryResults = [:] for category in categories { let result = await scanEngine.scanCategory(category) categoryResults[category] = result totalFound += result.totalSize } totalJunkSize = totalFound if scheduler.config.autoClean && totalFound >= scheduler.config.minimumCleanSize { cleanAll() } // Purgeable space is intentionally NOT auto-purged: macOS reserves and // reclaims it on its own and PureMac does not claim to free it. See // CleaningCategory.scannable. if scheduler.config.notifyOnCompletion { sendNotification(freed: totalFound) } } private func sendNotification(freed: Int64) { let content = UNMutableNotificationContent() content.title = "PureMac" let sizeStr = ByteCountFormatter.string(fromByteCount: freed, countStyle: .file) content.body = String(format: NSLocalizedString("Found %@ of junk files.", comment: ""), sizeStr) content.sound = .default let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: nil ) UNUserNotificationCenter.current().add(request) } private static func defaultAppFileScanner( app: InstalledApp, locations: Locations, completion: @escaping (Set) -> Void ) { let appInfo = AppPathFinder.AppInfo( appName: app.appName, bundleIdentifier: app.bundleIdentifier, path: app.path, entitlements: nil, teamIdentifier: nil ) let finder = AppPathFinder(appInfo: appInfo, locations: locations) finder.findPathsAsync(completion: completion) } }