import Foundation import MacToolsPluginKit typealias DiskCleanScanProgressHandler = @Sendable (DiskCleanScanLogMessage) async -> Void protocol DiskCleanScanning: Sendable { func scan( choices: Set, progress: DiskCleanScanProgressHandler ) async throws -> DiskCleanScanResult } extension DiskCleanScanning { func scan(choices: Set) async throws -> DiskCleanScanResult { try await scan(choices: choices, progress: { _ in }) } } protocol DiskCleanProcessInspecting: Sendable { func runningProcessName(from names: [String]) -> String? } struct LocalDiskCleanProcessInspector: DiskCleanProcessInspecting { func runningProcessName(from names: [String]) -> String? { for name in names where isProcessRunning(name) { return name } return nil } private func isProcessRunning(_ name: String) -> Bool { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") process.arguments = ["-x", name] do { try process.run() process.waitUntilExit() return process.terminationStatus == 0 } catch { return false } } } struct DiskCleanScanner: DiskCleanScanning { private struct ExpandedItem { let rule: DiskCleanRule let item: DiskCleanFileItem } let ruleCatalog: DiskCleanRuleCatalog let fileSystem: DiskCleanFileSystemProviding let safetyPolicy: DiskCleanSafetyPolicy let processInspector: DiskCleanProcessInspecting let now: @Sendable () -> Date let localization: PluginLocalization init( ruleCatalog: DiskCleanRuleCatalog = .moleFirstVersion, fileSystem: DiskCleanFileSystemProviding = LocalDiskCleanFileSystem(), safetyPolicy: DiskCleanSafetyPolicy = DiskCleanSafetyPolicy(), processInspector: DiskCleanProcessInspecting = LocalDiskCleanProcessInspector(), now: @escaping @Sendable () -> Date = Date.init, localization: PluginLocalization = PluginLocalization(bundle: .main) ) { self.ruleCatalog = ruleCatalog self.fileSystem = fileSystem self.safetyPolicy = safetyPolicy self.processInspector = processInspector self.now = now self.localization = localization } func scan( choices: Set, progress: DiskCleanScanProgressHandler ) async throws -> DiskCleanScanResult { var expandedItems: [ExpandedItem] = [] var currentChoice: DiskCleanChoice? for rule in ruleCatalog.rules where choices.contains(rule.choice) { try Task.checkCancellation() if currentChoice != rule.choice { currentChoice = rule.choice await progress( DiskCleanScanLogMessage( text: localization.format( "scanLog.scope", defaultValue: "扫描范围:%@", rule.choice.title(localization: localization) ), tone: .info ) ) } await progress( DiskCleanScanLogMessage( text: localization.format("scanLog.expandRule", defaultValue: "展开规则:%@", rule.title), tone: .info ) ) for target in rule.targets { try Task.checkCancellation() let items = try expand(target) await progress( DiskCleanScanLogMessage( text: localization.format( "scanLog.matched", defaultValue: "匹配 %d 项:%@", items.count, rule.title ), tone: items.isEmpty ? .info : .success ) ) expandedItems += items.map { ExpandedItem(rule: rule, item: $0) } } } let firstItemByPath = Dictionary( expandedItems.map { ($0.item.path, $0) }, uniquingKeysWith: { first, _ in first } ) let deduplicatedPaths = fileSystem.deduplicatedParentChildPaths(expandedItems.map(\.item.path)) var candidates: [DiskCleanCandidate] = [] for path in deduplicatedPaths { try Task.checkCancellation() guard let expandedItem = firstItemByPath[path] else { continue } let candidate = makeCandidate(from: expandedItem) candidates.append(candidate) await progress(logMessage(for: candidate)) } await progress( DiskCleanScanLogMessage( text: localization.format( "scanLog.completed", defaultValue: "扫描完成:%d 项,%d 项可清理", candidates.count, candidates.filter { $0.safety.isCleanable }.count ), tone: .success ) ) return DiskCleanScanResult( choices: choices, candidates: candidates, scannedAt: now() ) } private func expand(_ target: DiskCleanRule.Target) throws -> [DiskCleanFileItem] { switch target { case let .path(pattern): return try fileSystem.expandPathPattern(pattern) case let .dynamic(dynamicRule): return try expand(dynamicRule) } } private func expand(_ dynamicRule: DiskCleanDynamicRule) throws -> [DiskCleanFileItem] { var items: [DiskCleanFileItem] = [] for pattern in staticFallbackPatterns(for: dynamicRule) { items += try fileSystem.expandPathPattern(pattern) } return items } private func staticFallbackPatterns(for dynamicRule: DiskCleanDynamicRule) -> [String] { switch dynamicRule { case .xcodeDerivedData: return ["~/Library/Developer/Xcode/DerivedData/*"] case .unavailableSimulators: return [] case .npmCache: return [ "~/.npm/_cacache/*", "~/.npm/_npx/*", "~/.npm/_logs/*", "~/.npm/_prebuilds/*" ] case .pnpmStore: return ["~/Library/pnpm/store/*"] case .bunCache: return ["~/.bun/install/cache/*"] case .pipCache: return [ "~/Library/Caches/pip/*", "~/.cache/pip/*" ] case .goBuildCache: return [ "~/Library/Caches/go-build/*", "~/.cache/go-build/*" ] case .goModuleCache: return ["~/go/pkg/mod/*"] case .miseCache: return ["~/Library/Caches/mise/*"] case .jetbrainsToolboxOldVersions: return [] case .aiAgentOldVersions: return [] case .serviceWorkerCache: return [ "~/Library/Application Support/Google/Chrome/*/Service Worker/CacheStorage/*/*", "~/Library/Application Support/Arc/*/Service Worker/CacheStorage/*/*", "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Service Worker/CacheStorage/*/*", "~/Library/Application Support/Vivaldi/*/Service Worker/CacheStorage/*/*", "~/Library/Application Support/Code/Service Worker/CacheStorage/*/*", "~/Library/Application Support/Cursor/Service Worker/CacheStorage/*/*" ] case .oldBrowserVersions: return [] } } private func makeCandidate(from expandedItem: ExpandedItem) -> DiskCleanCandidate { let rule = expandedItem.rule let item = expandedItem.item let safety = safetyStatus(for: rule, item: item) let size = (try? fileSystem.sizeOfItem(at: item.path)) ?? 0 return DiskCleanCandidate( id: "\(rule.id)::\(item.path)", ruleID: rule.id, choice: rule.choice, title: rule.title, path: item.path, sizeBytes: size, safety: safety, risk: rule.risk ) } private func logMessage(for candidate: DiskCleanCandidate) -> DiskCleanScanLogMessage { switch candidate.safety { case .allowed: return DiskCleanScanLogMessage( text: localization.format("scanLog.candidate.allowed", defaultValue: "可清理:%@", candidate.path), tone: .success ) case .whitelisted: return DiskCleanScanLogMessage( text: localization.format("scanLog.candidate.whitelisted", defaultValue: "白名单保护:%@", candidate.path), tone: .warning ) case .protected: return DiskCleanScanLogMessage( text: localization.format("scanLog.candidate.protected", defaultValue: "敏感数据保护:%@", candidate.path), tone: .warning ) case .invalid: return DiskCleanScanLogMessage( text: localization.format("scanLog.candidate.invalid", defaultValue: "路径安全保护:%@", candidate.path), tone: .warning ) case .requiresAdmin: return DiskCleanScanLogMessage( text: localization.format("scanLog.candidate.requiresAdmin", defaultValue: "需要管理员权限:%@", candidate.path), tone: .warning ) case let .inUse(processName): return DiskCleanScanLogMessage( text: localization.format( "scanLog.candidate.inUse", defaultValue: "正在使用(%@):%@", processName, candidate.path ), tone: .warning ) } } private func safetyStatus( for rule: DiskCleanRule, item: DiskCleanFileItem ) -> DiskCleanSafetyStatus { if let processName = processInspector.runningProcessName(from: rule.skipWhenProcessIsRunning) { return .inUse(processName: processName) } if rule.requiresAdmin { return .requiresAdmin(reason: "admin privileges required") } return safetyPolicy.safetyStatus( for: item.path, isSymlink: item.isSymlink, resolvedSymlinkTarget: item.resolvedSymlinkTarget ) } }