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