diff --git a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift index b188fde..a6ee93b 100644 --- a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift +++ b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift @@ -116,24 +116,31 @@ public enum CleanEngine { let progressInterval = max(1, (totalSteps + 99) / 100) var completedSteps = 0 - for scan in selectedScans { + for (ruleIndex, scan) in selectedScans.enumerated() { if Task.isCancelled { report.wasCancelled = true; return report } - await onProgress?(CleanProgress(completed: completedSteps, total: totalSteps, - currentRule: scan.rule.title)) + 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 += scan.rule.usesToolCleaner ? 1 : scan.items.count - await onProgress?(CleanProgress(completed: completedSteps, total: totalSteps, - currentRule: scan.rule.title)) + 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?(CleanProgress(completed: completedSteps, total: totalSteps, - currentRule: scan.rule.title)) + await onProgress?(currentProgress()) continue } do { @@ -158,32 +165,24 @@ public enum CleanEngine { } } } catch { - if scan.rule.previewWithTool != nil { - report.hasUncertainFreedBytes = true - log?.recordToolInterruption(rule: scan.rule, error: error, - cancelled: error is CancellationError || Task.isCancelled) - } + // 外部命令可能已删除部分内容,失败或取消后都无法从扫描结果确认实际释放量。 + 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)")) - if scan.rule.previewWithTool == nil { - for item in scan.items { - log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription) - } - } } completedSteps += 1 - await onProgress?(CleanProgress(completed: completedSteps, total: totalSteps, - currentRule: scan.rule.title)) + await onProgress?(currentProgress()) continue } // 工具预览中的路径不受文件规则白名单约束,因此绝不能回退到手动删除。 guard scan.rule.previewWithTool == nil else { completedSteps += scan.items.count - await onProgress?(CleanProgress(completed: completedSteps, total: totalSteps, - currentRule: scan.rule.title)) + await onProgress?(currentProgress()) continue } // 废纸篓规则本身必须永久删除;其他可再生内容按用户偏好决定 @@ -196,9 +195,8 @@ public enum CleanEngine { report.skippedCount += 1 log?.record(item: item, rule: scan.rule, action: "skip", detail: reason.title) completedSteps += 1 - if completedSteps % progressInterval == 0 || completedSteps == totalSteps { - await onProgress?(CleanProgress(completed: completedSteps, total: totalSteps, - currentRule: scan.rule.title)) + if completedSteps % progressInterval == 0 || completedSteps - completedBeforeRule == currentRuleTotal { + await onProgress?(currentProgress()) } continue } @@ -217,9 +215,8 @@ public enum CleanEngine { log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription) } completedSteps += 1 - if completedSteps % progressInterval == 0 || completedSteps == totalSteps { - await onProgress?(CleanProgress(completed: completedSteps, total: totalSteps, - currentRule: scan.rule.title)) + if completedSteps % progressInterval == 0 || completedSteps - completedBeforeRule == currentRuleTotal { + await onProgress?(currentProgress()) } } } diff --git a/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift b/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift index 32342ea..03718c8 100644 --- a/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift +++ b/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift @@ -158,10 +158,27 @@ public struct CleanProgress: Sendable { public let completed: Int public let total: Int public let currentRule: String + /// 当前规则在选中规则中的位置,从 1 开始。 + public let ruleNumber: Int + public let ruleCount: Int + public let currentRuleCompleted: Int + public let currentRuleTotal: Int + public let currentRuleStartedAt: Date + /// 工具清理无法按文件报告进度;这里只提供扫描时的大小,不代表实际释放量。 + public let toolScannedBytes: UInt64? - public init(completed: Int, total: Int, currentRule: String) { + public init(completed: Int, total: Int, currentRule: String, + ruleNumber: Int, ruleCount: Int, currentRuleCompleted: Int, currentRuleTotal: Int, + currentRuleStartedAt: Date, + toolScannedBytes: UInt64? = nil) { self.completed = completed self.total = total self.currentRule = currentRule + self.ruleNumber = ruleNumber + self.ruleCount = ruleCount + self.currentRuleCompleted = currentRuleCompleted + self.currentRuleTotal = currentRuleTotal + self.currentRuleStartedAt = currentRuleStartedAt + self.toolScannedBytes = toolScannedBytes } } diff --git a/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift b/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift index c85febf..8a3b4f4 100644 --- a/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift +++ b/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift @@ -87,15 +87,34 @@ enum DeveloperToolRunner { 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: name, status: -1, output: error.localizedDescription) } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - let output = String(decoding: data, as: UTF8.self) - .trimmingCharacters(in: .whitespacesAndNewlines) + try? pipe.fileHandleForWriting.close() + let reader = pipe.fileHandleForReading + let outputTask = Task.detached(priority: .utility) { + var tail = Data() + let limit = max(0, outputLimit) + while let chunk = try reader.read(upToCount: 64 * 1024), !chunk.isEmpty { + tail.append(chunk) + if tail.count > limit { tail.removeFirst(tail.count - limit) } + } + return String(decoding: tail, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } + defer { + stop(process) + try? reader.close() + } + while process.isRunning { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(25)) + } + try Task.checkCancellation() + let output = try await outputTask.value guard process.terminationStatus == 0 else { throw ToolExecutionError.failed(tool: name, status: process.terminationStatus, output: String(output.prefix(2_000))) diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index c54048e..25e66e1 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 点击重新扫描以计算可清理空间 انقر على إعادة الفحص لحساب المساحة القابلة للاسترداد 正在重新扫描… جارٍ إعادة الفحص… 已处理 {}/{} 步 تمت معالجة {} من {} خطوات +正在停止清理… جارٍ إيقاف التنظيف… +正在核对剩余项目… جارٍ التحقق من العناصر المتبقية… +正在跳过核对… جارٍ تخطي التحقق… +跳过核对 تخطي التحقق +停止清理 إيقاف التنظيف +清理已结束,正在更新可清理空间 اكتمل التنظيف. جارٍ تحديث المساحة التي لا تزال قابلة للتنظيف. +第 {}/{} 项 العنصر {} من {} +已处理 {}/{} 项 تمت معالجة {} من {} عناصر +正在准备清理… جارٍ التحضير للتنظيف… +正在处理当前项目 جارٍ معالجة العنصر الحالي +已停止,已清理部分不会恢复 توقف التنظيف. لا يمكن استعادة العناصر التي نُظفت بالفعل. +扫描大小约 {} الحجم المفحوص: نحو {} 正在取消… جارٍ الإلغاء… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 77631cf..cb16f32 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 点击重新扫描以计算可清理空间 Klicken Sie auf Erneut scannen, um den freigebbaren Speicher zu berechnen 正在重新扫描… Erneuter Scan läuft… 已处理 {}/{} 步 {} von {} Schritten verarbeitet +正在停止清理… Bereinigung wird gestoppt … +正在核对剩余项目… Verbleibende Elemente werden geprüft … +正在跳过核对… Prüfung wird übersprungen … +跳过核对 Prüfung überspringen +停止清理 Bereinigung stoppen +清理已结束,正在更新可清理空间 Bereinigung beendet. Verbleibender bereinigbarer Speicher wird aktualisiert. +第 {}/{} 项 Element {} von {} +已处理 {}/{} 项 {} von {} Elementen verarbeitet +正在准备清理… Bereinigung wird vorbereitet … +正在处理当前项目 Aktuelles Element wird verarbeitet +已停止,已清理部分不会恢复 Gestoppt. Bereits bereinigte Inhalte können nicht wiederhergestellt werden. +扫描大小约 {} Erfasste Größe: ca. {} 正在取消… Wird abgebrochen… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 076907c..7260303 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 点击重新扫描以计算可清理空间 Pulsa Volver a analizar para calcular el espacio recuperable 正在重新扫描… Volviendo a analizar… 已处理 {}/{} 步 {} de {} pasos procesados +正在停止清理… Deteniendo la limpieza… +正在核对剩余项目… Comprobando los elementos restantes… +正在跳过核对… Omitiendo la comprobación… +跳过核对 Omitir comprobación +停止清理 Detener limpieza +清理已结束,正在更新可清理空间 Limpieza terminada. Actualizando el espacio que aún se puede liberar. +第 {}/{} 项 Elemento {} de {} +已处理 {}/{} 项 {} de {} elementos procesados +正在准备清理… Preparando la limpieza… +正在处理当前项目 Procesando el elemento actual +已停止,已清理部分不会恢复 Detenido. Los elementos ya eliminados no se pueden restaurar. +扫描大小约 {} Tamaño analizado: aprox. {} 正在取消… Cancelando… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 9c61154..5f4ea67 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 点击重新扫描以计算可清理空间 Cliquez sur Relancer l’analyse pour calculer l’espace récupérable 正在重新扫描… Nouvelle analyse en cours… 已处理 {}/{} 步 {} étapes sur {} traitées +正在停止清理… Arrêt du nettoyage… +正在核对剩余项目… Vérification des éléments restants… +正在跳过核对… Arrêt de la vérification… +跳过核对 Ignorer la vérification +停止清理 Arrêter le nettoyage +清理已结束,正在更新可清理空间 Nettoyage terminé. Mise à jour de l’espace encore récupérable. +第 {}/{} 项 Élément {} sur {} +已处理 {}/{} 项 {} éléments sur {} traités +正在准备清理… Préparation du nettoyage… +正在处理当前项目 Traitement de l’élément en cours +已停止,已清理部分不会恢复 Arrêté. Les éléments déjà supprimés ne peuvent pas être restaurés. +扫描大小约 {} Taille analysée : environ {} 正在取消… Annulation en cours… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index bf6ec62..5e61ff0 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 点击重新扫描以计算可清理空间 再スキャンをクリックして削除可能な容量を計算してください 正在重新扫描… 再スキャン中… 已处理 {}/{} 步 {}/{} ステップを処理済み +正在停止清理… クリーンアップを停止しています… +正在核对剩余项目… 残りの項目を確認しています… +正在跳过核对… 確認をスキップしています… +跳过核对 確認をスキップ +停止清理 クリーンアップを停止 +清理已结束,正在更新可清理空间 クリーンアップが完了しました。残りの削除可能な容量を更新しています。 +第 {}/{} 项 {}/{} 項目 +已处理 {}/{} 项 {}/{} 件を処理済み +正在准备清理… クリーンアップを準備しています… +正在处理当前项目 現在の項目を処理しています +已停止,已清理部分不会恢复 停止しました。すでに削除した内容は復元できません +扫描大小约 {} スキャン時のサイズ:約{} 正在取消… キャンセル中… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 0fd0a65..9354303 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 点击重新扫描以计算可清理空间 다시 스캔을 눌러 정리 가능한 공간을 계산하세요 正在重新扫描… 다시 스캔하는 중… 已处理 {}/{} 步 {} / {}단계 처리됨 +正在停止清理… 정리를 중지하는 중… +正在核对剩余项目… 남은 항목 확인 중… +正在跳过核对… 확인을 건너뛰는 중… +跳过核对 확인 건너뛰기 +停止清理 정리 중지 +清理已结束,正在更新可清理空间 정리가 끝났습니다. 남은 정리 가능 공간을 업데이트하는 중입니다. +第 {}/{} 项 {} / {}번째 항목 +已处理 {}/{} 项 {} / {}개 항목 처리됨 +正在准备清理… 정리 준비 중… +正在处理当前项目 현재 항목 처리 중 +已停止,已清理部分不会恢复 중지됨. 이미 정리된 항목은 복원할 수 없습니다. +扫描大小约 {} 스캔 크기 약 {} 正在取消… 취소하는 중… diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 385c2f4..18402b7 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1556,4 +1556,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依 点击重新扫描以计算可清理空间 按一下重新掃描以計算可清理空間 正在重新扫描… 正在重新掃描… 已处理 {}/{} 步 已處理 {}/{} 步 +正在停止清理… 正在停止清理… +正在核对剩余项目… 正在核對剩餘項目… +正在跳过核对… 正在跳過核對… +跳过核对 跳過核對 +停止清理 停止清理 +清理已结束,正在更新可清理空间 清理已結束,正在更新可清理空間 +第 {}/{} 项 第 {}/{} 項 +已处理 {}/{} 项 已處理 {}/{} 項 +正在准备清理… 正在準備清理… +正在处理当前项目 正在處理目前項目 +已停止,已清理部分不会恢复 已停止,已清理部分無法復原 +扫描大小约 {} 掃描大小約 {} 正在取消… 正在取消… diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index 01eedda..d624c05 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -953,6 +953,18 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and 正在取消… Cancelling… 正在重新扫描… Rescanning… 已处理 {}/{} 步 Processed {}/{} steps +正在停止清理… Stopping cleanup… +正在核对剩余项目… Checking remaining items… +正在跳过核对… Skipping verification… +跳过核对 Skip verification +停止清理 Stop cleanup +清理已结束,正在更新可清理空间 Cleanup is finished. Updating remaining cleanable space. +第 {}/{} 项 Item {} of {} +已处理 {}/{} 项 Processed {} of {} items +正在准备清理… Preparing cleanup… +正在处理当前项目 Processing current item +已停止,已清理部分不会恢复 Stopped. Already cleaned items cannot be restored. +扫描大小约 {} Scanned size: about {} 正在移除… Removing… 正在统计… Counting… 正在统计各进程流量… Measuring per-process traffic… diff --git a/Packages/XStatsKit/Sources/XStatsUI/Design/LiquidGlass.swift b/Packages/XStatsKit/Sources/XStatsUI/Design/LiquidGlass.swift index 7104cf4..b08e9c2 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Design/LiquidGlass.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Design/LiquidGlass.swift @@ -1,4 +1,6 @@ +import Cleaner import Localization +import Metrics import SwiftUI // MARK: - 液态玻璃 @@ -56,11 +58,28 @@ extension DS.Glass { // MARK: - 全局加载 +enum CleanerHUDStage { + case cleaning, stopping, verifying, skippingVerification + + var message: String { + switch self { + case .cleaning: tr("正在清理…") + case .stopping: tr("正在停止清理…") + case .verifying: tr("正在核对剩余项目…") + case .skippingVerification: tr("正在跳过核对…") + } + } + + var isVerifying: Bool { self == .verifying || self == .skippingVerification } + var isStopping: Bool { self == .stopping || self == .skippingVerification } +} + /// 需要等待、期间不宜继续操作的任务(清理、卸载、导出诊断、修改 DNS): /// 整个窗口压暗,中间浮一块玻璃加载框,写明正在做什么;任务结束自动消失 struct LoadingHUD: View { let message: String - let progress: (completed: Int, total: Int, detail: String)? + let progress: CleanProgress? + let cleanerStage: CleanerHUDStage? let onCancel: (() -> Void)? var body: some View { @@ -80,21 +99,15 @@ struct LoadingHUD: View { .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } - if let progress { - let detail = tr("已处理 \(progress.completed)/\(progress.total) 步") - ProgressView(value: Double(progress.completed), total: Double(max(1, progress.total))) - .accessibilityValue(detail) - HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) { - Text(verbatim: progress.detail) - .lineLimit(2) + if let cleanerStage { + cleanerDetails(cleanerStage) + HStack { Spacer(minLength: 0) - Text(detail) - .monospacedDigit() + Button(cleanerStage.isVerifying ? tr("跳过核对") : tr("停止清理")) { onCancel?() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(cleanerStage.isStopping || onCancel == nil) } - .dsFont(.xs) - .foregroundStyle(DS.Palette.textSecondary) - } - if let onCancel { + } else if let onCancel { HStack { Spacer(minLength: 0) Button(tr("取消"), action: onCancel) @@ -106,11 +119,66 @@ struct LoadingHUD: View { .padding(.vertical, DS.Space.s6) .frame(minWidth: DS.Size.sidebarWidth + DS.Space.s16, maxWidth: 420) .modifier(HUDBackground()) - .accessibilityElement(children: onCancel == nil ? .combine : .contain) + .accessibilityElement(children: cleanerStage == nil && onCancel == nil ? .combine : .contain) .accessibilityAddTraits(.updatesFrequently) } .transition(.opacity) } + + private func cleanerDetails(_ stage: CleanerHUDStage) -> some View { + VStack(alignment: .leading, spacing: DS.Space.s2) { + if stage.isVerifying { + Text(tr("清理已结束,正在更新可清理空间")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } else if let progress { + HStack(spacing: DS.Space.s2) { + Text(verbatim: progress.currentRule) + .dsFont(.sm, weight: .medium) + .foregroundStyle(DS.Palette.textPrimary) + .lineLimit(2) + Spacer(minLength: 0) + if progress.ruleCount > 1 { + Text(tr("第 \(progress.ruleNumber)/\(progress.ruleCount) 项")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .monospacedDigit() + } + } + if progress.toolScannedBytes == nil, progress.currentRuleTotal > 1 { + ProgressView(value: Double(progress.currentRuleCompleted), + total: Double(progress.currentRuleTotal)) + .accessibilityLabel(progress.currentRule) + .accessibilityValue(tr("已处理 \(progress.currentRuleCompleted)/\(progress.currentRuleTotal) 项")) + } + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) { + Text(verbatim: detail(for: progress)) + .lineLimit(2) + Spacer(minLength: 0) + Text(tr("已运行")) + Text(progress.currentRuleStartedAt, style: .timer) + .monospacedDigit() + } + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } else { + Text(tr("正在准备清理…")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } + } + .frame(maxWidth: .infinity, minHeight: DS.Space.s16, alignment: .topLeading) + } + + private func detail(for progress: CleanProgress) -> String { + if let bytes = progress.toolScannedBytes { + return tr("扫描大小约 \(Format.bytes(bytes, base: .decimal))") + } + if progress.currentRuleTotal > 1 { + return tr("已处理 \(progress.currentRuleCompleted)/\(progress.currentRuleTotal) 项") + } + return tr("正在处理当前项目") + } } private struct HUDBackground: ViewModifier { @@ -127,10 +195,10 @@ private struct HUDBackground: ViewModifier { extension View { /// 有文字时在上方盖一层全局加载框 - func loadingHUD(_ message: String?, progress: (completed: Int, total: Int, detail: String)? = nil, + func loadingHUD(_ message: String?, progress: CleanProgress? = nil, cleanerStage: CleanerHUDStage? = nil, onCancel: (() -> Void)? = nil) -> some View { overlay { - if let message { LoadingHUD(message: message, progress: progress, onCancel: onCancel) } + if let message { LoadingHUD(message: message, progress: progress, cleanerStage: cleanerStage, onCancel: onCancel) } } .animation(DS.Motion.quick, value: message) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift index 154b2b1..c9d1c56 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift @@ -134,7 +134,7 @@ private struct SummaryCard: View { guard let report = cleaner.report else { return "" } var parts: [String] if report.wasCancelled { - parts = [tr("已取消")] + parts = [tr("已停止,已清理部分不会恢复")] } else if report.hasUncertainFreedBytes { parts = [tr("部分结果未确认")] } else { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift index a7d29c7..6a1b5c3 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift @@ -4,6 +4,7 @@ // See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt. import AppKit +import Cleaner import Localization import Metrics import SwiftUI @@ -36,6 +37,7 @@ public struct MainWindowView: View { .background(DS.Palette.background) // 需要等待的任务进行中,整个窗口压暗并显示加载框 .loadingHUD(isSnapshot ? nil : busyMessage, progress: isSnapshot ? nil : busyProgress, + cleanerStage: isSnapshot ? nil : busyCleanerStage, onCancel: isSnapshot ? nil : busyCancel) // 内容延伸到透明标题栏下方,由顶栏高度留出红绿灯按钮的位置 .ignoresSafeArea() @@ -57,19 +59,24 @@ public struct MainWindowView: View { /// 需要等待、期间不宜继续操作的任务 private var busyMessage: String? { - if model.cleaner.phase == .cleaning { - if model.cleaner.isCancelling { return tr("正在取消…") } - return model.cleaner.report == nil ? tr("正在清理…") : tr("正在重新扫描…") - } + if let busyCleanerStage { return busyCleanerStage.message } if model.uninstaller.isRemoving { return tr("正在移除…") } if model.diagnostics.phase == .collecting { return tr("正在导出诊断信息…") } if model.maintenance.isApplyingDNS { return tr("正在修改 DNS…") } return nil } - private var busyProgress: (completed: Int, total: Int, detail: String)? { - guard model.cleaner.phase == .cleaning, let progress = model.cleaner.cleanProgress else { return nil } - return (progress.completed, progress.total, progress.currentRule) + private var busyCleanerStage: CleanerHUDStage? { + guard model.cleaner.phase == .cleaning else { return nil } + if model.cleaner.report != nil { + return model.cleaner.isCancelling ? .skippingVerification : .verifying + } + return model.cleaner.isCancelling ? .stopping : .cleaning + } + + private var busyProgress: CleanProgress? { + guard model.cleaner.phase == .cleaning else { return nil } + return model.cleaner.cleanProgress } private var busyCancel: (() -> Void)? { diff --git a/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift b/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift index 82d7361..8fd3d0e 100644 --- a/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift +++ b/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift @@ -335,6 +335,7 @@ struct DeveloperCacheRuleTests { #expect(report.failures.count == 1) #expect(report.failures.first?.contains("simulated failure") == true) #expect(report.failures.first?.contains("7") == true) + #expect(report.hasUncertainFreedBytes) #expect(FileManager.default.fileExists(atPath: cache.path)) } @@ -440,6 +441,52 @@ struct CleanEngineTests { let updates = await capture.values() #expect(updates.map(\.completed) == [0, 1, 2]) #expect(updates.allSatisfy { $0.total == 2 && $0.currentRule == RuleCatalog.userCaches.title }) + #expect(updates.map(\.currentRuleCompleted) == [0, 1, 2]) + #expect(updates.allSatisfy { $0.currentRuleTotal == 2 && $0.ruleNumber == 1 && $0.ruleCount == 1 }) + #expect(updates.allSatisfy { $0.toolScannedBytes == nil }) + } + + @Test func toolCleanupPreservesRuleProgressAndScannedSize() async throws { + let home = try FakeHome() + defer { home.remove() } + try home.file("Library/Caches/com.example.first/data.bin") + try home.file(".cache/uv/archive-v0/package/data") + try home.executable("uv", script: "#!/bin/sh\nexit 0\n") + let environment = home.environment() + let scans = await CleanEngine.scan([RuleCatalog.userCaches, RuleCatalog.uvCache], environment: environment) + let capture = CleanProgressCapture() + + _ = await CleanEngine.clean(scans, selected: [RuleCatalog.userCaches.id, RuleCatalog.uvCache.id], preferTrash: false, + environment: environment, log: nil, + onProgress: { await capture.record($0) }) + + let updates = await capture.values() + #expect(updates.map(\.completed) == [0, 1, 1, 2]) + #expect(updates.map(\.ruleNumber) == [1, 1, 2, 2]) + #expect(updates.map(\.currentRuleCompleted) == [0, 1, 0, 1]) + #expect(updates.allSatisfy { $0.ruleCount == 2 && $0.currentRuleTotal == 1 }) + #expect(updates.prefix(2).allSatisfy { $0.toolScannedBytes == nil }) + #expect(updates.suffix(2).allSatisfy { $0.toolScannedBytes == scans.last?.totalSize }) + } + + @Test func uvRunnerCancellationStopsTheStartedProcess() async throws { + let home = try FakeHome() + defer { home.remove() } + try home.executable("uv", script: "#!/bin/sh\necho $$ > \"$HOME/pid\"\nexec /bin/sleep 30\n") + let task = Task { try await DeveloperToolRunner.run("uv", arguments: ["cache", "clean"], home: home.path) } + let deadline = ContinuousClock.now.advanced(by: .seconds(3)) + let pidFile = home.root.appendingPathComponent("pid") + while !FileManager.default.fileExists(atPath: pidFile.path), ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + task.cancel() + do { + _ = try await task.value + Issue.record("取消命令不应成功") + } catch is CancellationError { } + let pidText = try String(contentsOf: pidFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + let pid = try #require(Int32(pidText)) + #expect(kill(pid, 0) != 0) } @Test func cleanRechecksRunningAppsBeforeDeleting() async throws {