From 84fb89808618fcb4b2639ec2440f783ae8d7bc49 Mon Sep 17 00:00:00 2001 From: github-6800134 <> Date: Sun, 10 May 2026 05:36:40 +0800 Subject: [PATCH] fix: add admin privilege fallback and remove deleted apps from list When uninstalling an app, Finder AppleScript deletion would fail on permission-protected files with only a "Grant Full Disk Access" message. Now failed files are retried with administrator privileges (matching the orphan deletion pattern), and apps whose .app bundle is successfully deleted are removed from the installed apps list. Co-Authored-By: Claude Opus 4.7 --- PureMac/ViewModels/AppState.swift | 83 +++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/PureMac/ViewModels/AppState.swift b/PureMac/ViewModels/AppState.swift index 891817e..4635a88 100644 --- a/PureMac/ViewModels/AppState.swift +++ b/PureMac/ViewModels/AppState.swift @@ -157,12 +157,36 @@ final class AppState: ObservableObject { if !removed.isEmpty { self.discoveredFiles.removeAll { removed.contains($0) } self.selectedFiles.subtract(removed) - Logger.shared.log("Removed \(removed.count) files", level: .info) + Logger.shared.log("Removed \(removed.count) files via Finder", level: .info) + self.removeDeletedAppsFromList(removedURLs: removed) } - 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) + + let failedURLs = urls.filter { FileManager.default.fileExists(atPath: $0.path) } + if failedURLs.isEmpty { return } + + // Fallback: try removing failed files with administrator privileges + Logger.shared.log("Attempting admin-privileged removal for \(failedURLs.count) files", level: .info) + DispatchQueue.global(qos: .userInitiated).async { + let adminSuccess = self.removeWithAdminPrivileges(failedURLs) + DispatchQueue.main.async { + let adminRemoved = failedURLs.filter { !FileManager.default.fileExists(atPath: $0.path) } + if !adminRemoved.isEmpty { + self.discoveredFiles.removeAll { adminRemoved.contains($0) } + self.selectedFiles.subtract(adminRemoved) + Logger.shared.log("Removed \(adminRemoved.count) files with admin privileges", level: .info) + self.removeDeletedAppsFromList(removedURLs: adminRemoved) + } + + let finalFailed = failedURLs.count - adminRemoved.count + if finalFailed > 0 { + if adminSuccess { + self.removalError = "\(finalFailed) file\(finalFailed == 1 ? "" : "s") could not be removed. The file may be protected by System Integrity Protection (SIP) or currently in use." + } else { + self.removalError = "\(finalFailed) file\(finalFailed == 1 ? "" : "s") could not be removed. Grant Full Disk Access in System Settings → Privacy & Security, or the file may be protected by SIP." + } + Logger.shared.log("Failed to remove \(finalFailed) files after admin fallback", level: .error) + } + } } } } @@ -193,6 +217,55 @@ final class AppState: ObservableObject { } } + /// Attempts to delete files using administrator privileges via AppleScript. + /// Used as a fallback when Finder trash or FileManager fails due to permission errors. + private func removeWithAdminPrivileges(_ urls: [URL]) -> Bool { + guard !urls.isEmpty else { return true } + + let quotedPaths = urls.map { url in + "'\(url.path.replacingOccurrences(of: "'", with: "'\\\"'\\\"'"))'" + } + let shellCommand = "rm -rf -- \(quotedPaths.joined(separator: " "))" + let appleScriptCommand = shellCommand + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let script = "do shell script \"\(appleScriptCommand)\" with administrator privileges" + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + Logger.shared.log("Admin remove failed with exit code \(process.terminationStatus)", level: .error) + return false + } + return true + } catch { + Logger.shared.log("Admin remove error: \(error)", level: .error) + return false + } + } + + /// Removes apps from the installed list whose .app bundle was deleted. + private func removeDeletedAppsFromList(removedURLs: [URL]) { + let removedPaths = Set(removedURLs.map { $0.resolvingSymlinksInPath().path }) + let appsToRemove = installedApps.filter { removedPaths.contains($0.path.resolvingSymlinksInPath().path) } + if appsToRemove.isEmpty { return } + + for app in appsToRemove { + installedApps.removeAll { $0.id == app.id } + if selectedApp?.id == app.id { + selectedApp = nil + discoveredFiles = [] + selectedFiles = [] + } + } + Logger.shared.log("Removed \(appsToRemove.count) app(s) from installed list", level: .info) + } + func findOrphans() { isSearchingOrphans = true orphanedFiles = []