feat(cleaner): 优化清理进度与停止反馈

This commit is contained in:
ysicing committed 2026-09-28 13:02:49 +08:00
1 parent c8937b77c3
commit b618a06d2b
15 files changed
+309 -58

No files matched your search

@@ -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())
}
}
}
@@ -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
}
}
@@ -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)))
@@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
点击重新扫描以计算可清理空间 انقر على إعادة الفحص لحساب المساحة القابلة للاسترداد
正在重新扫描… جارٍ إعادة الفحص…
已处理 {}/{} 步 تمت معالجة {} من {} خطوات
正在停止清理… جارٍ إيقاف التنظيف…
正在核对剩余项目… جارٍ التحقق من العناصر المتبقية…
正在跳过核对… جارٍ تخطي التحقق…
跳过核对 تخطي التحقق
停止清理 إيقاف التنظيف
清理已结束,正在更新可清理空间 اكتمل التنظيف. جارٍ تحديث المساحة التي لا تزال قابلة للتنظيف.
第 {}/{} 项 العنصر {} من {}
已处理 {}/{} 项 تمت معالجة {} من {} عناصر
正在准备清理… جارٍ التحضير للتنظيف…
正在处理当前项目 جارٍ معالجة العنصر الحالي
已停止,已清理部分不会恢复 توقف التنظيف. لا يمكن استعادة العناصر التي نُظفت بالفعل.
扫描大小约 {} الحجم المفحوص: نحو {}
正在取消… جارٍ الإلغاء…
1 # Copyright (C) 2026 ysicing
1555 点击重新扫描以计算可清理空间
1556 正在重新扫描…
1557 已处理 {}/{} 步
1558 正在停止清理…
1559 正在核对剩余项目…
1560 正在跳过核对…
1561 跳过核对
1562 停止清理
1563 清理已结束,正在更新可清理空间
1564 第 {}/{} 项
1565 已处理 {}/{} 项
1566 正在准备清理…
1567 正在处理当前项目
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
@@ -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…
1 # Copyright (C) 2026 ysicing
1555 点击重新扫描以计算可清理空间
1556 正在重新扫描…
1557 已处理 {}/{} 步
1558 正在停止清理…
1559 正在核对剩余项目…
1560 正在跳过核对…
1561 跳过核对
1562 停止清理
1563 清理已结束,正在更新可清理空间
1564 第 {}/{} 项
1565 已处理 {}/{} 项
1566 正在准备清理…
1567 正在处理当前项目
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
@@ -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…
1 # Copyright (C) 2026 ysicing
1555 点击重新扫描以计算可清理空间
1556 正在重新扫描…
1557 已处理 {}/{} 步
1558 正在停止清理…
1559 正在核对剩余项目…
1560 正在跳过核对…
1561 跳过核对
1562 停止清理
1563 清理已结束,正在更新可清理空间
1564 第 {}/{} 项
1565 已处理 {}/{} 项
1566 正在准备清理…
1567 正在处理当前项目
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
@@ -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…
1 # Copyright (C) 2026 ysicing
1555 点击重新扫描以计算可清理空间
1556 正在重新扫描…
1557 已处理 {}/{} 步
1558 正在停止清理…
1559 正在核对剩余项目…
1560 正在跳过核对…
1561 跳过核对
1562 停止清理
1563 清理已结束,正在更新可清理空间
1564 第 {}/{} 项
1565 已处理 {}/{} 项
1566 正在准备清理…
1567 正在处理当前项目
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
@@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
点击重新扫描以计算可清理空间 再スキャンをクリックして削除可能な容量を計算してください
正在重新扫描… 再スキャン中…
已处理 {}/{} 步 {}/{} ステップを処理済み
正在停止清理… クリーンアップを停止しています…
正在核对剩余项目… 残りの項目を確認しています…
正在跳过核对… 確認をスキップしています…
跳过核对 確認をスキップ
停止清理 クリーンアップを停止
清理已结束,正在更新可清理空间 クリーンアップが完了しました。残りの削除可能な容量を更新しています。
第 {}/{} 项 {}/{} 項目
已处理 {}/{} 项 {}/{} 件を処理済み
正在准备清理… クリーンアップを準備しています…
正在处理当前项目 現在の項目を処理しています
已停止,已清理部分不会恢复 停止しました。すでに削除した内容は復元できません
扫描大小约 {} スキャン時のサイズ:約{}
正在取消… キャンセル中…
1 # Copyright (C) 2026 ysicing
1555 点击重新扫描以计算可清理空间
1556 正在重新扫描…
1557 已处理 {}/{} 步
1558 正在停止清理…
1559 正在核对剩余项目…
1560 正在跳过核对…
1561 跳过核对
1562 停止清理
1563 清理已结束,正在更新可清理空间
1564 第 {}/{} 项
1565 已处理 {}/{} 项
1566 正在准备清理…
1567 正在处理当前项目
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
@@ -1555,4 +1555,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
点击重新扫描以计算可清理空间 다시 스캔을 눌러 정리 가능한 공간을 계산하세요
正在重新扫描… 다시 스캔하는 중…
已处理 {}/{} 步 {} / {}단계 처리됨
正在停止清理… 정리를 중지하는 중…
正在核对剩余项目… 남은 항목 확인 중…
正在跳过核对… 확인을 건너뛰는 중…
跳过核对 확인 건너뛰기
停止清理 정리 중지
清理已结束,正在更新可清理空间 정리가 끝났습니다. 남은 정리 가능 공간을 업데이트하는 중입니다.
第 {}/{} 项 {} / {}번째 항목
已处理 {}/{} 项 {} / {}개 항목 처리됨
正在准备清理… 정리 준비 중…
正在处理当前项目 현재 항목 처리 중
已停止,已清理部分不会恢复 중지됨. 이미 정리된 항목은 복원할 수 없습니다.
扫描大小约 {} 스캔 크기 약 {}
正在取消… 취소하는 중…
1 # Copyright (C) 2026 ysicing
1555 点击重新扫描以计算可清理空间
1556 正在重新扫描…
1557 已处理 {}/{} 步
1558 正在停止清理…
1559 正在核对剩余项目…
1560 正在跳过核对…
1561 跳过核对
1562 停止清理
1563 清理已结束,正在更新可清理空间
1564 第 {}/{} 项
1565 已处理 {}/{} 项
1566 正在准备清理…
1567 正在处理当前项目
1568 已停止,已清理部分不会恢复
1569 扫描大小约 {}
1570 正在取消…
@@ -1556,4 +1556,16 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
点击重新扫描以计算可清理空间 按一下重新掃描以計算可清理空間
正在重新扫描… 正在重新掃描…
已处理 {}/{} 步 已處理 {}/{} 步
正在停止清理… 正在停止清理…
正在核对剩余项目… 正在核對剩餘項目…
正在跳过核对… 正在跳過核對…
跳过核对 跳過核對
停止清理 停止清理
清理已结束,正在更新可清理空间 清理已結束,正在更新可清理空間
第 {}/{} 项 第 {}/{} 項
已处理 {}/{} 项 已處理 {}/{} 項
正在准备清理… 正在準備清理…
正在处理当前项目 正在處理目前項目
已停止,已清理部分不会恢复 已停止,已清理部分無法復原
扫描大小约 {} 掃描大小約 {}
正在取消… 正在取消…
1 # Copyright (c) 2026 GiantAccel, LLC
1556 点击重新扫描以计算可清理空间
1557 正在重新扫描…
1558 已处理 {}/{} 步
1559 正在停止清理…
1560 正在核对剩余项目…
1561 正在跳过核对…
1562 跳过核对
1563 停止清理
1564 清理已结束,正在更新可清理空间
1565 第 {}/{} 项
1566 已处理 {}/{} 项
1567 正在准备清理…
1568 正在处理当前项目
1569 已停止,已清理部分不会恢复
1570 扫描大小约 {}
1571 正在取消…
@@ -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…
@@ -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)
}
@@ -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 {
@@ -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)? {
@@ -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 {