From 0cafa65187dbffc8bd201e89d4939a639c4e416c Mon Sep 17 00:00:00 2001 From: 3manu31 <166739282+3manu31@users.noreply.github.com> Date: Sun, 17 May 2026 17:37:18 +0200 Subject: [PATCH 1/2] fix: uninstall protected items with admin fallback --- PureMac/ViewModels/AppState.swift | 93 ++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/PureMac/ViewModels/AppState.swift b/PureMac/ViewModels/AppState.swift index 8fdc233..cfd2e56 100644 --- a/PureMac/ViewModels/AppState.swift +++ b/PureMac/ViewModels/AppState.swift @@ -151,7 +151,7 @@ final class AppState: ObservableObject { } return } - trashDirectly(urls: urls) { [weak self] removed, failed in + trashDirectly(urls: urls) { [weak self] removed, needsAdmin, failed in DispatchQueue.main.async { guard let self else { return } if !removed.isEmpty { @@ -159,9 +159,28 @@ final class AppState: ObservableObject { self.selectedFiles.subtract(removed) Logger.shared.log("Removed \(removed.count) files", level: .info) } - 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) + var failedPaths = failed + if !needsAdmin.isEmpty { + if self.removeWithAdminPrivileges(needsAdmin) { + for url in needsAdmin { + if !FileManager.default.fileExists(atPath: url.path) { + self.discoveredFiles.removeAll { $0 == url } + self.selectedFiles.remove(url) + Logger.shared.log("Removed \(url.path) with administrator privileges", level: .info) + } else { + failedPaths.append(url) + } + } + } else { + failedPaths.append(contentsOf: needsAdmin) + } + } + if !failedPaths.isEmpty { + self.removalError = "\(failedPaths.count) file\(failedPaths.count == 1 ? "" : "s") could not be removed. Some items may require administrator privileges." + Logger.shared.log("Failed to remove \(failedPaths.count) files — likely missing FDA or admin privileges", level: .error) + } + if !removed.isEmpty || !needsAdmin.isEmpty { + self.pruneMissingInstalledApps() } } } @@ -172,9 +191,10 @@ final class AppState: ObservableObject { /// 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) { + private func trashDirectly(urls: [URL], completion: @escaping ([URL], [URL], [URL]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { var removed: [URL] = [] + var needsAdmin: [URL] = [] var failed: [URL] = [] for url in urls { var resulting: NSURL? @@ -182,11 +202,68 @@ final class AppState: ObservableObject { 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) + let nsError = error as NSError + let isMissingFile = + nsError.domain == NSCocoaErrorDomain && + (nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError) + || (nsError.domain == NSPOSIXErrorDomain && nsError.code == Int(ENOENT)) + let permissionDeniedCodes = [ + NSFileReadNoPermissionError, + NSFileWriteNoPermissionError, + NSFileWriteUnknownError, + 257, + 513, + ] + + if isMissingFile { + Logger.shared.log("Trash skipped for \(url.path): file no longer exists", level: .info) + removed.append(url) + } else + if permissionDeniedCodes.contains(nsError.code) { + needsAdmin.append(url) + } else { + Logger.shared.log("Trash failed for \(url.path): \(error.localizedDescription)", level: .error) + failed.append(url) + } } } - completion(removed, failed) + completion(removed, needsAdmin, failed) + } + } + + 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() + return process.terminationStatus == 0 + } catch { + return false + } + } + + 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 = [] } } From 071bd3e3b5cffeef897384912d64e5cb6c5ef3db Mon Sep 17 00:00:00 2001 From: 3manu31 <166739282+3manu31@users.noreply.github.com> Date: Mon, 18 May 2026 13:20:00 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20uninstall=20protected=20items=20with?= =?UTF-8?q?=20admin=20fallback=20=E2=80=94=20use=20CleaningEngine=20for=20?= =?UTF-8?q?admin=20removal,=20add=20minimal=20uninstall=20allow-list,=20an?= =?UTF-8?q?d=20tighten=20permission=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PureMac/Services/CleaningEngine.swift | 21 ++++++- PureMac/ViewModels/AppState.swift | 80 ++++++++++++++------------- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/PureMac/Services/CleaningEngine.swift b/PureMac/Services/CleaningEngine.swift index 8ce28eb..7ccd609 100644 --- a/PureMac/Services/CleaningEngine.swift +++ b/PureMac/Services/CleaningEngine.swift @@ -123,7 +123,26 @@ actor CleaningEngine { if item.category == .largeFiles { return isExplicitSingleFileDeletable(resolvedPath: resolved) } - return isSafeToDelete(resolvedPath: resolved) + // Allow-list for typical uninstall roots (narrow and explicit). + func isUninstallRoot(_ path: String) -> Bool { + let home = fileManager.homeDirectoryForCurrentUser.path + // /Applications/*.app and ~/Applications/*.app + if path.hasPrefix("/Applications/") || path.hasPrefix("\(home)/Applications/") { + if path.hasSuffix(".app") { return true } + } + // /var/db/receipts/com.*.{plist,bom} + if path.hasPrefix("/var/db/receipts/") { + let name = (path as NSString).lastPathComponent.lowercased() + if name.hasSuffix(".plist") || name.hasSuffix(".bom") { return true } + } + // /Library/LaunchDaemons/*.plist and /Library/LaunchAgents/*.plist + if path.hasPrefix("/Library/LaunchDaemons/") || path.hasPrefix("/Library/LaunchAgents/") { + if path.hasSuffix(".plist") { return true } + } + return false + } + + return isSafeToDelete(resolvedPath: resolved) || isUninstallRoot(resolved) }() if !accepted { Logger.shared.log("Refusing admin escalation for unsafe path: \(item.path)", level: .warning) diff --git a/PureMac/ViewModels/AppState.swift b/PureMac/ViewModels/AppState.swift index cfd2e56..d915a42 100644 --- a/PureMac/ViewModels/AppState.swift +++ b/PureMac/ViewModels/AppState.swift @@ -161,18 +161,44 @@ final class AppState: ObservableObject { } var failedPaths = failed if !needsAdmin.isEmpty { - if self.removeWithAdminPrivileges(needsAdmin) { - for url in needsAdmin { - if !FileManager.default.fileExists(atPath: url.path) { - self.discoveredFiles.removeAll { $0 == url } - self.selectedFiles.remove(url) - Logger.shared.log("Removed \(url.path) with administrator privileges", level: .info) - } else { - failedPaths.append(url) + // Convert URLs to CleanableItem to use CleaningEngine's + // vetted admin-clean path which performs allow-list + // validation and uses a NUL-separated temp file consumed + // by `xargs -0 rm -rf` (no quoting issues). + let items: [CleanableItem] = needsAdmin.map { url in + var size: Int64 = 0 + var modified: Date? = nil + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) { + size = (attrs[.size] as? Int64) ?? 0 + modified = attrs[.modificationDate] as? Date + } + return CleanableItem(name: url.lastPathComponent, path: url.path, size: size, category: .systemJunk, isSelected: true, lastModified: modified) + } + + Task { [weak self] in + guard let self else { return } + let adminResult = await self.cleaningEngine.cleanWithAdminPrivileges(items: items) + DispatchQueue.main.async { + for url in needsAdmin { + if adminResult.cleanedPaths.contains(url.path) { + self.discoveredFiles.removeAll { $0 == url } + self.selectedFiles.remove(url) + Logger.shared.log("Removed \(url.path) with administrator privileges", level: .info) + } else { + failedPaths.append(url) + } + } + if !adminResult.errors.isEmpty { + self.removalError = adminResult.errors.joined(separator: "; ") + } + if !failedPaths.isEmpty { + self.removalError = "\(failedPaths.count) file\(failedPaths.count == 1 ? "" : "s") could not be removed. Some items may require administrator privileges." + Logger.shared.log("Failed to remove \(failedPaths.count) files — likely missing FDA or admin privileges", level: .error) + } + if !removed.isEmpty || !needsAdmin.isEmpty { + self.pruneMissingInstalledApps() } } - } else { - failedPaths.append(contentsOf: needsAdmin) } } if !failedPaths.isEmpty { @@ -210,9 +236,8 @@ final class AppState: ObservableObject { let permissionDeniedCodes = [ NSFileReadNoPermissionError, NSFileWriteNoPermissionError, - NSFileWriteUnknownError, - 257, - 513, + Int(EACCES), + Int(EPERM), ] if isMissingFile { @@ -231,30 +256,11 @@ final class AppState: ObservableObject { } } - 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() - return process.terminationStatus == 0 - } catch { - return false - } - } + // Use the vetted admin-clean path provided by `CleaningEngine` instead + // of building ad-hoc shell commands. The engine stages paths to a + // NUL-separated temp file and calls `xargs -0 rm -rf` via AppleScript + // with administrator privileges which avoids shell quoting issues and + // re-validates each path against the allow-list. private func pruneMissingInstalledApps() { let fileManager = FileManager.default