项目文件夹

文件
wehub-resource-sync 869b84f27c
Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:29:30 +08:00

1097 行
44 KiB
Swift

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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)
}
}