import Foundation import Localization public enum CleanEngine { // MARK: 扫描 /// 并发扫描所有规则,只读,不修改任何文件 public static func scan(_ rules: [CleanRule], environment: CleanEnvironment) async -> [RuleScan] { let safety = SafetyGuard(home: environment.home) return await withTaskGroup(of: (Int, RuleScan).self) { group in for (index, rule) in rules.enumerated() { group.addTask { (index, await scanRule(rule, environment: environment, safety: safety)) } } var results = [(Int, RuleScan)]() for await result in group { results.append(result) } return results.sorted { $0.0 < $1.0 }.map(\.1) } } static func scanRule(_ rule: CleanRule, environment: CleanEnvironment, safety: SafetyGuard) async -> RuleScan { let running = environment.runningBundleIdentifiers() if let app = rule.blockingApps.first(where: { running.contains($0.bundleID) }) { return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .appRunning(app.name)) } if let previewWithTool = rule.previewWithTool { if let tool = rule.requiredTool, !environment.isToolAvailable(tool) { return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .toolUnavailable(tool)) } do { let preview = try await previewWithTool(environment) return RuleScan(rule: rule, items: preview.items, skippedCount: 0, blocked: nil, toolPreview: preview) } catch { return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .previewFailed(error.localizedDescription)) } } let candidates: [URL] do { candidates = try rule.locate(environment) } catch { return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .needsFullDiskAccess) } if !candidates.isEmpty, let tool = rule.requiredTool, !environment.isToolAvailable(tool) { return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .toolUnavailable(tool)) } var items: [CleanItem] = [] var skipped = 0 for url in candidates { if Task.isCancelled { break } // 工具托管的缓存由工具自身处理锁与内部目录;这里的路径只用于只读计量,不能套用 // “应用数据保护词”过滤,否则名为 xstats 等普通包会被误报为正在使用。 if !rule.usesToolCleaner, skipReason(for: url, rule: rule, running: running, environment: environment, safety: safety) != nil { skipped += 1 continue } guard let size = try? scanAllocatedSize(of: url) else { break } if size > 0 { items.append(CleanItem(url: url, size: size)) } } items.sort { $0.size > $1.size } return RuleScan(rule: rule, items: items, skippedCount: skipped, blocked: nil) } static func skipReason(for url: URL, rule: CleanRule, running: Set, environment: CleanEnvironment, safety: SafetyGuard) -> SkipReason? { guard (try? safety.validate(url)) != nil else { return .protected } if rule.checksOwnerApp, let owner = owningApp(of: url, running: running) { return .appRunning(owner) } if rule.minimumAge > 0, let modified = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate, environment.now().timeIntervalSince(modified) < rule.minimumAge { return .recentlyModified } return nil } /// 条目名为反向域名时,匹配正在运行的应用(名称相同,或是其子标识) static func owningApp(of url: URL, running: Set) -> String? { let name = url.lastPathComponent guard name.split(separator: ".").count >= 3 else { return nil } return running.first { name == $0 || name.hasPrefix($0 + ".") } } /// 实际占用的磁盘空间;不跟随符号链接 public static func allocatedSize(of url: URL) -> UInt64 { // 保持同步调用方的完整计量语义;可取消操作使用 throwing 入口,不能把部分结果当完整大小。 (try? scanAllocatedSize(of: url, checkCancellation: {})) ?? 0 } /// 完整体积计量,遍历期间响应取消;取消时抛错,不返回部分大小。 public static func scanAllocatedSize(of url: URL, checkCancellation: () throws -> Void = { try Task.checkCancellation() }) throws -> UInt64 { try checkCancellation() let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey] guard let values = try? url.resourceValues(forKeys: Set(keys)), values.isSymbolicLink != true else { return 0 } if values.isRegularFile == true { return UInt64(values.totalFileAllocatedSize ?? values.fileAllocatedSize ?? 0) } guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: keys, options: [], errorHandler: { _, _ in true }) else { return 0 } var total: UInt64 = 0 while let file = enumerator.nextObject() as? URL { try checkCancellation() guard let fileValues = try? file.resourceValues(forKeys: Set(keys)), fileValues.isRegularFile == true else { continue } total += UInt64(fileValues.totalFileAllocatedSize ?? fileValues.fileAllocatedSize ?? 0) } return total } // MARK: 清理 /// 清理选中规则的扫描结果。执行前逐项重新校验:扫描到执行之间应用可能已经启动 public static func clean(_ scans: [RuleScan], selected: Set, preferTrash: Bool, environment: CleanEnvironment, log: CleanLog? = CleanLog(), onProgress: (@Sendable (CleanProgress) async -> Void)? = nil) async -> CleanReport { let safety = SafetyGuard(home: environment.home) var report = CleanReport() let selectedScans = scans.filter { selected.contains($0.id) && $0.isCleanable } let totalSteps = selectedScans.reduce(0) { $0 + ($1.rule.usesToolCleaner ? 1 : $1.items.count) } let progressInterval = max(1, (totalSteps + 99) / 100) var completedSteps = 0 for (ruleIndex, scan) in selectedScans.enumerated() { if Task.isCancelled { report.wasCancelled = true; return report } let completedBeforeRule = completedSteps let currentRuleTotal = scan.rule.usesToolCleaner ? 1 : scan.items.count let currentRuleStartedAt = Date() let currentProgress = { CleanProgress(completed: completedSteps, total: totalSteps, currentRule: scan.rule.title, ruleNumber: ruleIndex + 1, ruleCount: selectedScans.count, currentRuleCompleted: completedSteps - completedBeforeRule, currentRuleTotal: currentRuleTotal, currentRuleStartedAt: currentRuleStartedAt, toolScannedBytes: scan.rule.usesToolCleaner ? scan.totalSize : nil) } await onProgress?(currentProgress()) let running = environment.runningBundleIdentifiers() if scan.rule.blockingApps.contains(where: { running.contains($0.bundleID) }) { report.skippedCount += scan.items.count completedSteps += currentRuleTotal await onProgress?(currentProgress()) continue } if let cleanWithTool = scan.rule.cleanWithTool { if let tool = scan.rule.requiredTool, !environment.isToolAvailable(tool) { report.failures.append(tr("\(scan.rule.title):未找到 \(tool),无法安全清理")) completedSteps += 1 await onProgress?(currentProgress()) continue } do { let toolReport = try await cleanWithTool(environment) if let toolReport { report.freedBytes += toolReport.freedBytes report.removedCount += toolReport.removedCount report.skippedCount += toolReport.skippedCount report.failures += toolReport.failures report.trashedBytes += toolReport.trashedBytes report.wasCancelled = toolReport.wasCancelled report.hasUncertainFreedBytes = report.hasUncertainFreedBytes || toolReport.hasUncertainFreedBytes log?.recordTool(rule: scan.rule, report: toolReport) if report.wasCancelled { return report } } else { for item in scan.items { let remaining = try scanAllocatedSize(of: item.url) log?.record(item: item, rule: scan.rule, action: "tool", detail: scan.rule.requiredTool) guard remaining < item.size else { continue } report.freedBytes += item.size - remaining report.removedCount += 1 } } } catch { // 外部命令可能已删除部分内容,失败或取消后都无法从扫描结果确认实际释放量。 report.hasUncertainFreedBytes = true log?.recordToolInterruption(rule: scan.rule, error: error, cancelled: error is CancellationError || Task.isCancelled) if error is CancellationError || Task.isCancelled { report.wasCancelled = true return report } report.failures.append(tr("\(scan.rule.title):\(error.localizedDescription)")) } completedSteps += 1 await onProgress?(currentProgress()) continue } // 工具预览中的路径不受文件规则白名单约束,因此绝不能回退到手动删除。 guard scan.rule.previewWithTool == nil else { completedSteps += scan.items.count await onProgress?(currentProgress()) continue } let useTrash = !scan.rule.deletesPermanently(preferTrash: preferTrash) for item in scan.items { if Task.isCancelled { report.wasCancelled = true; return report } if let reason = skipReason(for: item.url, rule: scan.rule, running: running, environment: environment, safety: safety) { report.skippedCount += 1 log?.record(item: item, rule: scan.rule, action: "skip", detail: reason.title) completedSteps += 1 if completedSteps % progressInterval == 0 || completedSteps - completedBeforeRule == currentRuleTotal { await onProgress?(currentProgress()) } continue } do { if useTrash { try FileManager.default.trashItem(at: item.url, resultingItemURL: nil) report.trashedBytes += item.size } else { try FileManager.default.removeItem(at: item.url) report.freedBytes += item.size } report.removedCount += 1 log?.record(item: item, rule: scan.rule, action: useTrash ? "trash" : "delete", detail: nil) } catch { report.failures.append(tr("\(item.url.lastPathComponent):\(error.localizedDescription)")) log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription) } completedSteps += 1 if completedSteps % progressInterval == 0 || completedSteps - completedBeforeRule == currentRuleTotal { await onProgress?(currentProgress()) } } } return report } } /// 清理操作日志:~/Library/Logs/XStats/cleanup.log,每行一条 JSON public struct CleanLog: Sendable { public let url: URL public init(url: URL = URL(fileURLWithPath: NSHomeDirectory() + "/Library/Logs/XStats/cleanup.log")) { self.url = url } func record(item: CleanItem, rule: CleanRule, action: String, detail: String?) { record(url: item.url, ruleID: rule.id, bytes: item.size, action: action, detail: detail) } func record(url: URL, ruleID: String, bytes: UInt64, action: String, detail: String?) { var entry: [String: Any] = [ "time": ISO8601DateFormatter().string(from: Date()), "rule": ruleID, "action": action, "path": url.path, "bytes": bytes, ] if let detail { entry["detail"] = detail } write(entry) } /// 原生命令只报告汇总结果;扫描候选不能作为实际删除记录。 func recordTool(rule: CleanRule, report: CleanReport) { var entry: [String: Any] = [ "time": ISO8601DateFormatter().string(from: Date()), "rule": rule.id, "action": "tool", "bytes": report.freedBytes, "removedCount": report.removedCount, ] if let tool = rule.requiredTool { entry["detail"] = tool } write(entry) } func recordToolInterruption(rule: CleanRule, error: Error, cancelled: Bool) { var entry: [String: Any] = [ "time": ISO8601DateFormatter().string(from: Date()), "rule": rule.id, "action": cancelled ? "cancel" : "fail", ] if let tool = rule.requiredTool { entry["tool"] = tool } if !cancelled { entry["detail"] = error.localizedDescription } write(entry) } private func write(_ entry: [String: Any]) { guard var data = try? JSONSerialization.data(withJSONObject: entry, options: [.sortedKeys]) else { return } data.append(0x0A) try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) if let handle = try? FileHandle(forWritingTo: url) { defer { try? handle.close() } _ = try? handle.seekToEnd() try? handle.write(contentsOf: data) } else { try? data.write(to: url) } } }