Files
wehub-resource-sync 869b84f27c
Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:29:30 +08:00

1097 lines
44 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<URL>) -> 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<UUID> = []
@Published var deselectedItems: Set<UUID> = []
@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<URL> = []
@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<String>) -> 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<Bool> {
Binding<Bool>(
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<Bool> {
Binding<Bool>(
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<URL>) -> 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)
}
}