From f5ba9f88be158afd00f65b7f1ebc97b86a408bb3 Mon Sep 17 00:00:00 2001 From: ysicing Date: Mon, 28 Sep 2026 10:44:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(cleaner):=20=E4=BF=AE=E5=A4=8D=20Homebrew?= =?UTF-8?q?=20=E6=B8=85=E7=90=86=E6=89=A7=E8=A1=8C=E4=B8=8E=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 2 +- .../Sources/Cleaner/CleanEngine.swift | 44 ++++++++- .../Sources/Cleaner/CleanModels.swift | 2 + .../Sources/Cleaner/HomebrewCleanup.swift | 19 +++- .../Sources/Cleaner/ToolExecution.swift | 94 ++++++++++++++++++- .../Sources/Localization/Resources/ar.tsv | 2 + .../Sources/Localization/Resources/de.tsv | 2 + .../Sources/Localization/Resources/es.tsv | 2 + .../Sources/Localization/Resources/fr.tsv | 2 + .../Sources/Localization/Resources/ja.tsv | 2 + .../Sources/Localization/Resources/ko.tsv | 2 + .../Localization/Resources/zh-Hant.tsv | 2 + .../Sources/Localization/Translations.swift | 2 + .../Sources/XStatsUI/Panel/CleanerPage.swift | 14 ++- .../Tests/CleanerTests/CleanerTests.swift | 12 ++- .../CleanerTests/HomebrewCleanupTests.swift | 71 +++++++++++++- 16 files changed, 253 insertions(+), 21 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index dd500da..18c3591 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -63,7 +63,7 @@ GitHub Actions 只在分支 push 且 `server/**` 发生变化时构建并发布 `XSTATS_APP_PROFILE`、`XSTATS_WIDGET_PROFILE` 指定 profile 名称后再签名构建。 若钥匙串里有多张同名证书,应确保所选证书包含在两个 profile 中;可用 `SIGN_ID` 指定对应证书的 SHA-1 指纹。 -Homebrew 清理使用原生命令:扫描执行 `brew cleanup --prune=30 --dry-run`,确认后执行 `brew cleanup --prune=30`。候选路径和估算空间来自命令预览,释放空间来自执行输出;不会根据预览路径手动删除文件,也不会回退到扫描固定缓存目录。运行时禁用自动更新、自动移除依赖和分析上报,设置 60 秒超时、2 MB 输出上限并支持取消。Homebrew 缓存与日志排除在通用清理规则之外。 +Homebrew 清理使用原生命令:扫描执行 `brew cleanup --prune=30 --dry-run`,确认后执行 `brew cleanup --prune=30`。候选路径和估算空间来自命令预览,释放空间来自执行输出;日志仅记录命令执行汇总,不把预览候选记为已删除。命令中途失败或取消时释放量显示为未确认。不会根据预览路径手动删除文件,也不会回退到扫描固定缓存目录。运行时禁用自动更新、自动移除依赖和分析上报。预览设置 60 秒超时和 2 MB 输出上限;实际清理持续读取输出并只保留有限的末尾诊断文本,不因输出量中断命令。两者都支持取消。Homebrew 缓存与日志排除在通用清理规则之外。 清理页顶栏的“项目产物”入口打开独立窗口;项目扫描独立于普通缓存规则,只在用户点击后运行。默认扫描目录来自 Mole 的默认项目位置、Agent 工作树及自动发现的项目目录;“扫描位置”显示当前完整列表,可新增、移除默认或自定义目录,并恢复默认。第一次修改后保存完整列表,被移除的默认目录不会在下次扫描时自动加入;恢复默认会重新发现默认目录。目录配置只保存在本机偏好中,不加入跨设备备份。每个根目录最多向下查找 6 层;最近 7 天有活动的条目会标记提醒。应用包及其内部目录不会被当作项目扫描。Git 跟踪内容、嵌套仓库、部署密钥、符号链接及未完成安全检查的条目不会作为可清理候选;用户逐项选择并确认后,每项移到废纸篓前重新检查 Git 跟踪状态与路径身份。云盘候选会提示同步删除风险。实现不依赖 Mole 命令,也不会在后台常驻扫描。 diff --git a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift index 16b62ae..69731c6 100644 --- a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift +++ b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift @@ -132,9 +132,8 @@ public enum CleanEngine { report.failures += toolReport.failures report.trashedBytes += toolReport.trashedBytes report.wasCancelled = toolReport.wasCancelled - for item in scan.items { - log?.record(item: item, rule: scan.rule, action: "tool", detail: scan.rule.requiredTool) - } + report.hasUncertainFreedBytes = report.hasUncertainFreedBytes || toolReport.hasUncertainFreedBytes + log?.recordTool(rule: scan.rule, report: toolReport) if report.wasCancelled { return report } continue } @@ -146,13 +145,20 @@ public enum CleanEngine { report.removedCount += 1 } } catch { + if scan.rule.previewWithTool != nil { + 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)")) - for item in scan.items { - log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription) + if scan.rule.previewWithTool == nil { + for item in scan.items { + log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription) + } } } continue @@ -211,6 +217,34 @@ public struct CleanLog: Sendable { "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) diff --git a/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift b/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift index 7deb0e7..263ac97 100644 --- a/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift +++ b/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift @@ -143,6 +143,8 @@ public struct ToolCleanupPreview: Sendable { public struct CleanReport: Sendable { /// 取消是用户操作,保留已完成部分的统计,但不作为工具失败。 public var wasCancelled = false + /// 原生工具中途退出时,已释放空间可能未出现在报告中。 + public var hasUncertainFreedBytes = false public var freedBytes: UInt64 = 0 public var removedCount = 0 public var skippedCount = 0 diff --git a/Packages/XStatsKit/Sources/Cleaner/HomebrewCleanup.swift b/Packages/XStatsKit/Sources/Cleaner/HomebrewCleanup.swift index 5395582..d445c16 100644 --- a/Packages/XStatsKit/Sources/Cleaner/HomebrewCleanup.swift +++ b/Packages/XStatsKit/Sources/Cleaner/HomebrewCleanup.swift @@ -25,16 +25,27 @@ enum HomebrewCleanup { guard result.status == 0 else { throw ToolExecutionError.failed(tool: "brew", status: result.status, output: String(result.output.prefix(2_000))) } + if let report = result.cleanupReport { return report } var report = CleanReport() for line in result.output.components(separatedBy: .newlines) { - if line.hasPrefix("Removing: ") || line.hasPrefix("Pruning ") { report.removedCount += 1 } - if let bytes = try summarySize(line, prefix: "==> This operation has freed approximately ") { - report.freedBytes = bytes - } + try recordExecutionLine(line, report: &report) } return report } + static func recordExecutionLine(_ line: String, report: inout CleanReport) throws { + if line.hasPrefix("Removing: ") { + report.removedCount += 1 + } else if line.hasPrefix("Pruning ") { + // 格式为 "Pruning 57 files from: ...",数量是 brew 实际删除的文件数。 + let count = line.dropFirst("Pruning ".count).prefix { $0 != " " } + report.removedCount += Int(count) ?? 1 + } + if let bytes = try summarySize(line, prefix: "==> This operation has freed approximately ") { + report.freedBytes = bytes + } + } + static func parsePreview(_ output: String) throws -> ToolCleanupPreview { var items: [CleanItem] = [] var details: [String] = [] diff --git a/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift b/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift index fa0f022..c85febf 100644 --- a/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift +++ b/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift @@ -8,6 +8,14 @@ import Localization public struct ToolRunResult: Sendable { public let status: Int32 public let output: String + /// 真实 Homebrew 清理的完整输出统计,避免截断诊断文本后丢失汇总。 + public let cleanupReport: CleanReport? + + public init(status: Int32, output: String, cleanupReport: CleanReport? = nil) { + self.status = status + self.output = output + self.cleanupReport = cleanupReport + } } public enum ToolExecutionError: Error, LocalizedError, Sendable { @@ -42,7 +50,7 @@ enum DeveloperToolRunner { /// 外部工具可能执行较久;放到并发执行器,避免阻塞调用它的主 Actor。 @concurrent static func run(_ name: String, arguments: [String], home: String, - timeout: TimeInterval = 60, outputLimit: Int = 2_000_000) async throws -> ToolRunResult { + previewTimeout: TimeInterval = 60, outputLimit: Int = 2_000_000) async throws -> ToolRunResult { try Task.checkCancellation() guard let executable = executable(named: name, home: home) else { throw ToolExecutionError.unavailable(name) @@ -69,7 +77,11 @@ enum DeveloperToolRunner { process.environment = environment if name == "brew" { - return try await runHomebrew(process, timeout: timeout, outputLimit: outputLimit) + if arguments.first == "cleanup", !arguments.contains("--dry-run") { + // 真实清理可能已删除部分文件,不因超时或输出量中断;仍响应用户取消。 + return try await runHomebrewCleanup(process, outputLimit: outputLimit) + } + return try await runHomebrewPreview(process, timeout: previewTimeout, outputLimit: outputLimit) } let pipe = Pipe() @@ -91,8 +103,8 @@ enum DeveloperToolRunner { return ToolRunResult(status: process.terminationStatus, output: output) } - /// 预览需要完整输出才能解析。写临时文件避免管道堵塞,同时限制大小、执行时间并响应取消。 - private static func runHomebrew(_ process: Process, timeout: TimeInterval, outputLimit: Int) async throws -> ToolRunResult { + /// 预览需要完整输出才能解析;超过限制时终止只读命令。 + private static func runHomebrewPreview(_ process: Process, timeout: TimeInterval, outputLimit: Int) async throws -> ToolRunResult { let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-brew-\(UUID())") guard FileManager.default.createFile(atPath: outputURL.path, contents: nil) else { throw CocoaError(.fileWriteUnknown) @@ -131,6 +143,80 @@ enum DeveloperToolRunner { return ToolRunResult(status: process.terminationStatus, output: output) } + /// 持续排空管道,让清理执行到底;只保留有限的末尾诊断文本和逐行汇总。 + private static func runHomebrewCleanup(_ process: Process, outputLimit: Int) async throws -> ToolRunResult { + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + process.standardInput = FileHandle.nullDevice + try Task.checkCancellation() + do { try process.run() } catch { + throw ToolExecutionError.failed(tool: "brew", status: -1, output: error.localizedDescription) + } + try? pipe.fileHandleForWriting.close() + let reader = pipe.fileHandleForReading + let outputTask = Task.detached(priority: .utility) { + try readHomebrewCleanupOutput(reader, outputLimit: outputLimit) + } + defer { + stop(process) + try? reader.close() + } + while process.isRunning { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(25)) + } + try Task.checkCancellation() + let result = try await outputTask.value + guard process.terminationStatus == 0 else { + throw ToolExecutionError.failed(tool: "brew", status: process.terminationStatus, + output: String(result.output.suffix(2_000))) + } + guard !result.hasInvalidSummary else { throw HomebrewCleanup.PreviewError.unsupportedOutput } + return ToolRunResult(status: process.terminationStatus, output: result.output, cleanupReport: result.report) + } + + private struct CleanupOutput: Sendable { + let output: String + let report: CleanReport + let hasInvalidSummary: Bool + } + + private static func readHomebrewCleanupOutput(_ reader: FileHandle, outputLimit: Int) throws -> CleanupOutput { + var tail = Data() + var line = [UInt8]() + var lineTooLong = false + var report = CleanReport() + var hasInvalidSummary = false + while let chunk = try reader.read(upToCount: 64 * 1024), !chunk.isEmpty { + tail.append(chunk) + if tail.count > outputLimit { tail.removeFirst(tail.count - max(0, outputLimit)) } + for byte in chunk { + if byte == 0x0A { + if !lineTooLong { recordHomebrewLine(line, report: &report, hasInvalidSummary: &hasInvalidSummary) } + line.removeAll(keepingCapacity: true) + lineTooLong = false + } else if line.count < 64 * 1024 { + line.append(byte) + } else { + // 跳过异常长的单行诊断输出,继续读取后续的 Homebrew 汇总行。 + lineTooLong = true + } + } + } + if !line.isEmpty, !lineTooLong { + recordHomebrewLine(line, report: &report, hasInvalidSummary: &hasInvalidSummary) + } + return CleanupOutput(output: String(decoding: tail, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines), + report: report, hasInvalidSummary: hasInvalidSummary) + } + + private static func recordHomebrewLine(_ bytes: [UInt8], report: inout CleanReport, hasInvalidSummary: inout Bool) { + let line = String(decoding: bytes, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + do { try HomebrewCleanup.recordExecutionLine(line, report: &report) } + catch { hasInvalidSummary = true } + } + /// 只结束本次启动的进程;取消后仍给予短暂退出时间,防止清理子进程留在后台。 private static func stop(_ process: Process) { guard process.isRunning else { return } diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index cf4d0e1..5c74114 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P 已连接 متصل 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 بطارية لوحة مفاتيح أو ماوس أو سماعات متصلة دون 15%؛ تُفحص كل 10 دقائق 已选 {} 项 · {} تم تحديد {} · {} +释放量未确认 المساحة غير مؤكدة +部分结果未确认 بعض النتائج غير مؤكدة 已释放 تم تحريره 已释放 {} 缓存内存 حُرر {} من الذاكرة المؤقتة 布局 التخطيط diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index c2f1d42..f5e25a6 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py 已连接 Verbunden 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 Batteriestand einer verbundenen Tastatur, Maus, Kopfhörer o. Ä. unter 15 %; Prüfung alle 10 Minuten 已选 {} 项 · {} {} ausgewählt · {} +释放量未确认 Speicher unbestätigt +部分结果未确认 Einige Ergebnisse sind nicht bestätigt 已释放 Freigegeben 已释放 {} 缓存内存 {} Cache-Arbeitsspeicher freigegeben 布局 Layout diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 1e7a94b..f8e9def 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes 已连接 Conectado 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 Batería de teclado, ratón, auriculares u otros dispositivos por debajo del 15%; se comprueba cada 10 minutos 已选 {} 项 · {} {} seleccionados · {} +释放量未确认 Espacio sin confirmar +部分结果未确认 Algunos resultados no están confirmados 已释放 Liberado 已释放 {} 缓存内存 Se liberaron {} de memoria caché 布局 Distribución diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index b8e21a3..ec316d9 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P 已连接 Connecté 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 La batterie d’un clavier, d’une souris, d’écouteurs ou d’un autre appareil connecté passe sous 15 % ; vérification toutes les 10 minutes 已选 {} 项 · {} {} sélectionnés · {} +释放量未确认 Espace non confirmé +部分结果未确认 Certains résultats ne sont pas confirmés 已释放 Libéré 已释放 {} 缓存内存 {} de mémoire cache libérés 布局 Disposition diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index ffa2de9..bbcbc80 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ 已连接 接続済み 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 接続中のキーボード、マウス、ヘッドフォンなどの残量が15%未満か10分ごとに確認 已选 {} 项 · {} {}項目選択済み · {} +释放量未确认 解放量は未確認 +部分结果未确认 一部の結果は未確認 已释放 解放済み 已释放 {} 缓存内存 キャッシュメモリ{}を解放しました 布局 レイアウト diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index aed5265..5e93dc9 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -555,6 +555,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키 已连接 연결됨 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 연결된 키보드, 마우스, 헤드폰 등의 배터리가 15% 미만인지 10분마다 확인 已选 {} 项 · {} {}개 선택됨 · {} +释放量未确认 확보량 미확인 +部分结果未确认 일부 결과를 확인할 수 없음 已释放 확보됨 已释放 {} 缓存内存 캐시 메모리 {}을(를) 확보했습니다 布局 레이아웃 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 3cd9e9c..350425e 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -556,6 +556,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python 已连接 已連接 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 已連接的鍵盤、鼠標、耳機等電量低於 15%,每 10 分鐘檢查一次 已选 {} 项 · {} 已選 {} 項 · {} +释放量未确认 釋放量未確認 +部分结果未确认 部分結果未確認 已释放 已釋放 已释放 {} 缓存内存 已釋放 {} 緩存內存 布局 佈局 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index c45aa6b..9b51cde 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -672,6 +672,8 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and 已连接 Connected 已连接的键盘、鼠标、耳机等电量低于 15%,每 10 分钟检查一次 A connected keyboard, mouse, headphones or similar drops below 15%; checked every 10 minutes 已选 {} 项 · {} {} selected · {} +释放量未确认 Space unconfirmed +部分结果未确认 Some results are unconfirmed 已释放 Freed 已释放 {} 缓存内存 Freed {} of cached memory 布局 Layout diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift index f97a04e..2a9acb2 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift @@ -116,7 +116,10 @@ private struct SummaryCard: View { private var headline: String { let cleaner = model.cleaner if cleaner.phase == .idle { return tr("尚未扫描") } - if cleaner.phase == .finished { return Format.bytes(cleaner.report?.freedBytes ?? 0, base: .decimal) } + if cleaner.phase == .finished { + guard let report = cleaner.report else { return Format.bytes(UInt64(0), base: .decimal) } + return report.hasUncertainFreedBytes ? tr("释放量未确认") : Format.bytes(report.freedBytes, base: .decimal) + } return cleaner.scans.isEmpty ? tr("扫描中") : Format.bytes(cleaner.totalBytes, base: .decimal) } @@ -129,7 +132,14 @@ private struct SummaryCard: View { case .ready: return tr("可清理 · 已选 \(cleaner.selectedScans.count) 项") case .finished: guard let report = cleaner.report else { return "" } - var parts = [report.wasCancelled ? tr("已取消") : tr("已释放")] + var parts: [String] + if report.wasCancelled { + parts = [tr("已取消")] + } else if report.hasUncertainFreedBytes { + parts = [tr("部分结果未确认")] + } else { + parts = [tr("已释放")] + } if report.trashedBytes > 0 { parts.append(tr("另有 \(Format.bytes(report.trashedBytes, base: .decimal)) 移到废纸篓")) } if report.skippedCount > 0 { parts.append(tr("跳过 \(report.skippedCount) 项")) } if !report.failures.isEmpty { parts.append(tr("\(report.failures.count) 项失败")) } diff --git a/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift b/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift index 6df95d7..2afc6c3 100644 --- a/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift +++ b/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift @@ -149,12 +149,22 @@ struct DeveloperCacheRuleTests { #expect(scan.items.map(\.url) == [cellar]) #expect(scan.totalSize == 1_200_000) #expect(scan.rule.usesToolCleaner) + let logURL = home.root.appendingPathComponent("cleanup.log") let report = await CleanEngine.clean(scans, selected: [scan.id], preferTrash: true, - environment: environment, log: nil) + environment: environment, log: CleanLog(url: logURL)) #expect(report.freedBytes == 1_100_000) #expect(report.removedCount == 1) #expect(report.trashedBytes == 0) #expect(report.failures.isEmpty) + let logData = try Data(contentsOf: logURL) + let entries = try logData.split(separator: 0x0A).map { + try #require(JSONSerialization.jsonObject(with: Data($0)) as? [String: Any]) + } + #expect(entries.count == 1) + #expect(entries.first?["action"] as? String == "tool") + #expect(entries.first?["bytes"] as? Int == 1_100_000) + #expect(entries.first?["removedCount"] as? Int == 1) + #expect(entries.first?["path"] == nil) #expect(FileManager.default.fileExists(atPath: cached.path)) let generic = try RuleCatalog.userCaches.locate(environment) #expect(!generic.contains { $0.lastPathComponent == "Homebrew" }) diff --git a/Packages/XStatsKit/Tests/CleanerTests/HomebrewCleanupTests.swift b/Packages/XStatsKit/Tests/CleanerTests/HomebrewCleanupTests.swift index f32125d..3bc8ac1 100644 --- a/Packages/XStatsKit/Tests/CleanerTests/HomebrewCleanupTests.swift +++ b/Packages/XStatsKit/Tests/CleanerTests/HomebrewCleanupTests.swift @@ -47,6 +47,8 @@ import Testing } @Test func cleanupFailureKeepsNativeError() async throws { + let logURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-brew-log-\(UUID())") + defer { try? FileManager.default.removeItem(at: logURL) } let environment = CleanEnvironment(runningBundleIdentifiers: { [] }, isToolAvailable: { _ in true }, runTool: { _, arguments in if arguments.contains("--dry-run") { @@ -56,10 +58,19 @@ import Testing }) let scans = await CleanEngine.scan([RuleCatalog.homebrewCache], environment: environment) let report = await CleanEngine.clean(scans, selected: [RuleCatalog.homebrewCache.id], preferTrash: false, - environment: environment, log: nil) + environment: environment, log: CleanLog(url: logURL)) #expect(report.failures.count == 1) #expect(report.failures.first?.contains("cannot acquire lock") == true) #expect(report.freedBytes == 0) + #expect(report.hasUncertainFreedBytes) + let logData = try Data(contentsOf: logURL) + let entries = try logData.split(separator: 0x0A).map { + try #require(JSONSerialization.jsonObject(with: Data($0)) as? [String: Any]) + } + #expect(entries.count == 1) + #expect(entries.first?["action"] as? String == "fail") + #expect(entries.first?["path"] == nil) + #expect(entries.first?["bytes"] == nil) } @Test func cancelledCleanupIsNotReportedAsAToolFailure() async { @@ -76,6 +87,7 @@ import Testing #expect(report.failures.isEmpty) #expect(report.wasCancelled) #expect(report.removedCount == 0) + #expect(report.hasUncertainFreedBytes) } @Test func brewRunnerSetsSafetyEnvironmentAndKeepsArguments() async throws { @@ -93,20 +105,71 @@ import Testing let home = try stubBrew("#!/bin/sh\nexec /bin/sleep 30\n") defer { try? FileManager.default.removeItem(at: home) } do { - _ = try await DeveloperToolRunner.run("brew", arguments: [], home: home.path, timeout: 0.1) + _ = try await DeveloperToolRunner.run("brew", arguments: ["--dry-run"], home: home.path, previewTimeout: 0.1) Issue.record("超时命令不应成功") } catch ToolExecutionError.timedOut { } try "#!/bin/sh\nprintf '1234567890'\n".write(to: home.appendingPathComponent(".local/bin/brew"), atomically: false, encoding: .utf8) do { - _ = try await DeveloperToolRunner.run("brew", arguments: [], home: home.path, outputLimit: 8) + _ = try await DeveloperToolRunner.run("brew", arguments: ["--dry-run"], home: home.path, outputLimit: 8) Issue.record("超出限制的输出不应成功") } catch ToolExecutionError.outputTooLarge { } } + @Test func brewCleanupRunsToCompletionPastPreviewLimits() async throws { + let home = try stubBrew(""" + #!/bin/sh + /bin/sleep 0.3 + printf 'Removing: /tmp/old... (1MB)\\n' + i=0 + while [ "$i" -lt 2500 ]; do + printf 'Warning: verbose output %d\\n' "$i" + i=$((i + 1)) + done + printf 'Pruning 2 files from: /tmp/cache...\\n' + printf '==> This operation has freed approximately 2MB of disk space.\\n' + touch "$HOME/done" + """) + defer { try? FileManager.default.removeItem(at: home) } + let environment = CleanEnvironment(home: home.path, runningBundleIdentifiers: { [] }, + isToolAvailable: { _ in true }, + runTool: { tool, arguments in + try await DeveloperToolRunner.run(tool, arguments: arguments, home: home.path, + previewTimeout: 0.1, outputLimit: 64) + }) + let report = try #require(try await HomebrewCleanup.clean(environment)) + #expect(report.removedCount == 3) + #expect(report.freedBytes == 2_000_000) + #expect(FileManager.default.fileExists(atPath: home.appendingPathComponent("done").path), "真实清理不能因输出过大被中途结束") + } + + @Test func cleanupCountsEveryPrunedFile() async throws { + let environment = CleanEnvironment(runningBundleIdentifiers: { [] }, isToolAvailable: { _ in true }, + runTool: { _, arguments in + if arguments.contains("--dry-run") { + return ToolRunResult(status: 0, output: """ + Would prune 57 files from: /opt/homebrew/cache/downloads + ==> This operation would free approximately 2MB of disk space. + """) + } + return ToolRunResult(status: 0, output: """ + Removing: /opt/homebrew/Cellar/tool/1... (1MB) + Pruning 57 files from: /opt/homebrew/cache/downloads... + Pruning 1 file from: /opt/homebrew/cache/api... + ==> This operation has freed approximately 2MB of disk space. + """) + }) + let scans = await CleanEngine.scan([RuleCatalog.homebrewCache], environment: environment) + let report = await CleanEngine.clean(scans, selected: [RuleCatalog.homebrewCache.id], preferTrash: false, + environment: environment, log: nil) + #expect(report.failures.isEmpty) + #expect(report.removedCount == 59) + #expect(report.freedBytes == 2_000_000) + } + @Test func brewRunnerCancellationStopsTheStartedProcess() async throws { let home = try stubBrew("#!/bin/sh\necho $$ > \"$HOME/pid\"\nexec /bin/sleep 30\n") defer { try? FileManager.default.removeItem(at: home) } - let task = Task { try await DeveloperToolRunner.run("brew", arguments: [], home: home.path) } + let task = Task { try await DeveloperToolRunner.run("brew", arguments: ["cleanup"], home: home.path) } let deadline = ContinuousClock.now.advanced(by: .seconds(3)) let pidFile = home.appendingPathComponent("pid") while !FileManager.default.fileExists(atPath: pidFile.path), ContinuousClock.now < deadline {