diff --git a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift index 555f962..0fafa69 100644 --- a/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift +++ b/Packages/XStatsKit/Sources/Cleaner/CleanEngine.swift @@ -29,12 +29,18 @@ public enum CleanEngine { } catch { return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .needsFullDiskAccess) } + if !candidates.isEmpty, let tool = rule.requiredTool, !environment.isToolAvailable(tool) { + return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .toolUnavailable(tool)) + } var items: [CleanItem] = [] var skipped = 0 for url in candidates { if Task.isCancelled { break } - if skipReason(for: url, rule: rule, running: running, environment: environment, safety: safety) != nil { + // 工具托管的缓存由工具自身处理锁与内部目录;这里的路径只用于只读计量,不能套用 + // “应用数据保护词”过滤,否则名为 xstats 等普通包会被误报为正在使用。 + if !rule.usesToolCleaner, + skipReason(for: url, rule: rule, running: running, environment: environment, safety: safety) != nil { skipped += 1 continue } @@ -99,6 +105,28 @@ public enum CleanEngine { report.skippedCount += scan.items.count continue } + if let cleanWithTool = scan.rule.cleanWithTool { + if let tool = scan.rule.requiredTool, !environment.isToolAvailable(tool) { + report.failures.append(tr("\(scan.rule.title):未找到 \(tool),无法安全清理")) + continue + } + do { + try await cleanWithTool(environment) + for item in scan.items { + let remaining = allocatedSize(of: item.url) + log?.record(item: item, rule: scan.rule, action: "tool", detail: scan.rule.requiredTool) + guard remaining < item.size else { continue } + report.freedBytes += item.size - remaining + report.removedCount += 1 + } + } catch { + report.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) + } + } + continue + } // 废纸篓规则本身必须永久删除;其他可再生内容按用户偏好决定 let useTrash = scan.rule.id != RuleCatalog.trash.id && (scan.rule.policy == .trash || preferTrash) diff --git a/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift b/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift index 1210909..7a62172 100644 --- a/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift +++ b/Packages/XStatsKit/Sources/Cleaner/CleanModels.swift @@ -27,6 +27,7 @@ public enum DeletionPolicy: Sendable { public enum SkipReason: Sendable, Equatable, Hashable { case appRunning(String) case needsFullDiskAccess + case toolUnavailable(String) case recentlyModified case protected @@ -34,6 +35,7 @@ public enum SkipReason: Sendable, Equatable, Hashable { switch self { case .appRunning(let name): tr("\(name) 正在运行") case .needsFullDiskAccess: tr("需要完全磁盘访问权限") + case .toolUnavailable(let tool): tr("未找到 \(tool),无法安全清理") case .recentlyModified: tr("刚刚被使用") case .protected: tr("受保护") } @@ -45,13 +47,19 @@ public struct CleanEnvironment: Sendable { public var home: String public var runningBundleIdentifiers: @Sendable () -> Set public var now: @Sendable () -> Date + public var isToolAvailable: @Sendable (String) -> Bool + public var runTool: @Sendable (String, [String]) async throws -> ToolRunResult public init(home: String = NSHomeDirectory(), runningBundleIdentifiers: @escaping @Sendable () -> Set, - now: @escaping @Sendable () -> Date = Date.init) { + now: @escaping @Sendable () -> Date = Date.init, + isToolAvailable: (@Sendable (String) -> Bool)? = nil, + runTool: (@Sendable (String, [String]) async throws -> ToolRunResult)? = nil) { self.home = home self.runningBundleIdentifiers = runningBundleIdentifiers self.now = now + self.isToolAvailable = isToolAvailable ?? { DeveloperToolRunner.executable(named: $0, home: home) != nil } + self.runTool = runTool ?? { try await DeveloperToolRunner.run($0, arguments: $1, home: home) } } } @@ -69,13 +77,16 @@ public struct CleanRule: Sendable, Identifiable { public let checksOwnerApp: Bool /// 最近修改过的条目视为正在使用 public let minimumAge: TimeInterval + public let requiredTool: String? + let cleanWithTool: (@Sendable (CleanEnvironment) async throws -> Void)? /// 列出候选条目(顶层文件或目录) let locate: @Sendable (CleanEnvironment) throws -> [URL] init(id: String, category: CleanCategory, title: String, detail: String, symbol: String, selectedByDefault: Bool = true, policy: DeletionPolicy = .delete, blockingApps: [(bundleID: String, name: String)] = [], checksOwnerApp: Bool = false, - minimumAge: TimeInterval = 120, + minimumAge: TimeInterval = 120, requiredTool: String? = nil, + cleanWithTool: (@Sendable (CleanEnvironment) async throws -> Void)? = nil, locate: @escaping @Sendable (CleanEnvironment) throws -> [URL]) { self.id = id self.category = category @@ -87,8 +98,12 @@ public struct CleanRule: Sendable, Identifiable { self.blockingApps = blockingApps self.checksOwnerApp = checksOwnerApp self.minimumAge = minimumAge + self.requiredTool = requiredTool + self.cleanWithTool = cleanWithTool self.locate = locate } + + public var usesToolCleaner: Bool { cleanWithTool != nil } } public struct CleanItem: Sendable, Identifiable, Hashable { @@ -103,7 +118,7 @@ public struct RuleScan: Sendable, Identifiable { /// 按大小降序 public let items: [CleanItem] public let skippedCount: Int - /// 整条规则被阻止的原因(浏览器运行中、缺少权限) + /// 整条规则被阻止的原因(浏览器运行中、缺少权限或对应工具不可用) public let blocked: SkipReason? public var totalSize: UInt64 { items.reduce(0) { $0 + $1.size } } diff --git a/Packages/XStatsKit/Sources/Cleaner/RuleCatalog.swift b/Packages/XStatsKit/Sources/Cleaner/RuleCatalog.swift index b1ce15f..f0ad0d2 100644 --- a/Packages/XStatsKit/Sources/Cleaner/RuleCatalog.swift +++ b/Packages/XStatsKit/Sources/Cleaner/RuleCatalog.swift @@ -4,7 +4,9 @@ import Localization /// 清理规则。目录清单参考了 Mole 的清理范围(仅参考范围,代码为独立实现)。 public enum RuleCatalog { public static func rules() -> [CleanRule] { - [userCaches, logs] + browsers.map(browserRule) + [xcodeDerivedData, simulatorCaches, npmCache, xcodeArchives, + [userCaches, logs] + browsers.map(browserRule) + [xcodeDerivedData, simulatorCaches, + npmCache, yarnCache, pnpmCache, bunCache, + goCache, rustCache, uvCache, xcodeArchives, incompleteDownloads, installers, trash] } @@ -127,12 +129,92 @@ public enum RuleCatalog { static let npmCache = CleanRule( id: "developer.npm", category: .developer, title: tr("npm 缓存"), - detail: tr("~/.npm/_cacache,安装依赖时自动重新下载"), symbol: "shippingbox" + detail: tr("npm 下载缓存,由 npm 自行清理"), symbol: "tool-npm", + selectedByDefault: false, minimumAge: 0, + requiredTool: "npm", cleanWithTool: { env in + _ = try await env.runTool("npm", ["cache", "clean", "--force"]) + } ) { env in let path = env.home + "/.npm/_cacache" return FileManager.default.fileExists(atPath: path) ? [URL(fileURLWithPath: path)] : [] } + static let yarnCache = CleanRule( + id: "developer.yarn", category: .developer, title: tr("Yarn 缓存"), + detail: tr("Yarn 全局与离线镜像缓存,由 Yarn 自行清理"), symbol: "tool-yarn", + selectedByDefault: false, minimumAge: 0, + requiredTool: "yarn", cleanWithTool: { env in + let version = try await env.runTool("yarn", ["--version"]).output + let major = Int(version.split(separator: ".").first ?? "1") ?? 1 + let arguments = major >= 2 ? ["cache", "clean", "--all"] : ["cache", "clean"] + _ = try await env.runTool("yarn", arguments) + } + ) { env in + try childrenIfExists(of: env.home + "/Library/Caches/Yarn") + + childrenIfExists(of: env.home + "/.yarn/berry/cache") + } + + static let pnpmCache = CleanRule( + id: "developer.pnpm", category: .developer, title: tr("pnpm 缓存"), + detail: tr("pnpm 内容寻址存储,仅清理未被引用的包"), symbol: "tool-pnpm", + selectedByDefault: false, minimumAge: 0, + requiredTool: "pnpm", cleanWithTool: { env in + _ = try await env.runTool("pnpm", ["store", "prune"]) + } + ) { env in + try childrenIfExists(of: env.home + "/Library/pnpm/store") + + childrenIfExists(of: env.home + "/.pnpm-store") + } + + static let bunCache = CleanRule( + id: "developer.bun", category: .developer, title: tr("Bun 缓存"), + detail: tr("Bun 下载的包缓存,由 Bun 自行清理"), symbol: "tool-bun", + selectedByDefault: false, minimumAge: 0, + requiredTool: "bun", cleanWithTool: { env in + _ = try await env.runTool("bun", ["pm", "cache", "rm"]) + } + ) { env in + try childrenIfExists(of: env.home + "/.bun/install/cache") + } + + static let goCache = CleanRule( + id: "developer.go", category: .developer, title: tr("Go 缓存"), + detail: tr("Go 编译与完整模块缓存,由 go clean 清理"), symbol: "tool-go", + selectedByDefault: false, minimumAge: 0, + requiredTool: "go", cleanWithTool: { env in + _ = try await env.runTool("go", ["clean", "-cache", "-modcache"]) + } + ) { env in + let fileManager = FileManager.default + let buildCache = URL(fileURLWithPath: env.home + "/Library/Caches/go-build") + var urls = fileManager.fileExists(atPath: buildCache.path) ? [buildCache] : [] + urls += try childrenIfExists(of: env.home + "/go/pkg/mod") + return urls + } + + static let rustCache = CleanRule( + id: "developer.rust", category: .developer, title: tr("Rust 缓存"), + detail: tr("Cargo 注册表与 Git 依赖缓存,需要 cargo-cache"), symbol: "tool-rust", + selectedByDefault: false, minimumAge: 0, + requiredTool: "cargo-cache", cleanWithTool: { env in + _ = try await env.runTool("cargo-cache", ["-r", "all"]) + } + ) { env in + try childrenIfExists(of: env.home + "/.cargo/registry") + + childrenIfExists(of: env.home + "/.cargo/git") + } + + static let uvCache = CleanRule( + id: "developer.uv", category: .developer, title: tr("uv 缓存"), + detail: tr("uv 下载的 Python 包与构建缓存,由 uv 自行清理"), symbol: "tool-uv", + selectedByDefault: false, minimumAge: 0, + requiredTool: "uv", cleanWithTool: { env in + _ = try await env.runTool("uv", ["cache", "clean"]) + } + ) { env in + try childrenIfExists(of: env.home + "/.cache/uv") + } + static let xcodeArchives = CleanRule( id: "developer.archives", category: .developer, title: tr("Xcode 归档"), detail: tr("打包生成的 .xcarchive,删除后无法重新符号化旧版本崩溃日志"), symbol: "archivebox.fill", diff --git a/Packages/XStatsKit/Sources/Cleaner/SafetyGuard.swift b/Packages/XStatsKit/Sources/Cleaner/SafetyGuard.swift index 28b67bc..1d51387 100644 --- a/Packages/XStatsKit/Sources/Cleaner/SafetyGuard.swift +++ b/Packages/XStatsKit/Sources/Cleaner/SafetyGuard.swift @@ -36,6 +36,14 @@ public struct SafetyGuard: Sendable { "Library/Developer/Xcode/Archives", "Library/Developer/CoreSimulator/Caches", ".npm", + "go/pkg/mod", + ".cargo/registry", + ".cargo/git", + ".cache/uv", + ".yarn/berry/cache", + "Library/pnpm/store", + ".pnpm-store", + ".bun/install/cache", "Downloads", ".Trash", ] diff --git a/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift b/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift new file mode 100644 index 0000000..cdb866c --- /dev/null +++ b/Packages/XStatsKit/Sources/Cleaner/ToolExecution.swift @@ -0,0 +1,105 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Localization + +public struct ToolRunResult: Sendable { + public let status: Int32 + public let output: String +} + +public enum ToolExecutionError: Error, LocalizedError, Sendable { + case unavailable(String) + case failed(tool: String, status: Int32, output: String) + + public var errorDescription: String? { + switch self { + case .unavailable(let tool): + tr("未找到 \(tool),无法安全清理") + case .failed(let tool, let status, let output): + output.isEmpty + ? tr("\(tool) 清理失败(退出码 \(status))") + : tr("\(tool) 清理失败(退出码 \(status)):\(output)") + } + } +} + +enum DeveloperToolRunner { + /// 只从常见的用户工具目录和系统工具目录解析,避免依赖 GUI 进程通常不完整的 PATH。 + static func executable(named name: String, home: String) -> URL? { + guard !name.isEmpty, !name.contains("/") else { return nil } + return searchDirectories(home: home) + .map { $0.appendingPathComponent(name) } + .first { FileManager.default.isExecutableFile(atPath: $0.path) } + } + + /// 外部工具可能执行较久;放到并发执行器,避免阻塞调用它的主 Actor。 + @concurrent + static func run(_ name: String, arguments: [String], home: String) async throws -> ToolRunResult { + guard let executable = executable(named: name, home: home) else { + throw ToolExecutionError.unavailable(name) + } + + let process = Process() + process.executableURL = executable + process.arguments = arguments + process.currentDirectoryURL = FileManager.default.temporaryDirectory + + var environment = ProcessInfo.processInfo.environment + environment["HOME"] = home + environment["PATH"] = ([executable.deletingLastPathComponent()] + searchDirectories(home: home)) + .map(\.path).uniqued().joined(separator: ":") + environment["NO_COLOR"] = "1" + environment["TERM"] = "dumb" + process.environment = environment + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + 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) + guard process.terminationStatus == 0 else { + throw ToolExecutionError.failed(tool: name, status: process.terminationStatus, + output: String(output.prefix(2_000))) + } + return ToolRunResult(status: process.terminationStatus, output: output) + } + + private static func searchDirectories(home: String) -> [URL] { + let homeURL = URL(fileURLWithPath: home, isDirectory: true) + var directories = [ + homeURL.appendingPathComponent(".local/bin"), + homeURL.appendingPathComponent(".cargo/bin"), + homeURL.appendingPathComponent(".bun/bin"), + homeURL.appendingPathComponent(".volta/bin"), + homeURL.appendingPathComponent(".asdf/shims"), + homeURL.appendingPathComponent(".local/share/mise/shims"), + ] + + let nvmRoot = homeURL.appendingPathComponent(".nvm/versions/node") + let nvmVersions = ((try? FileManager.default.contentsOfDirectory( + at: nvmRoot, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + )) ?? []).sorted { + $0.lastPathComponent.compare($1.lastPathComponent, options: .numeric) == .orderedDescending + } + directories += nvmVersions.map { $0.appendingPathComponent("bin") } + directories += ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] + .map { URL(fileURLWithPath: $0, isDirectory: true) } + return directories.uniqued() + } +} + +private extension Sequence where Element: Hashable { + func uniqued() -> [Element] { + var seen = Set() + return filter { seen.insert($0).inserted } + } +} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 2b2467c..133685d 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -95,6 +95,25 @@ Xcode 编译缓存 ذاكرة بناء Xcode المؤقتة cleanip.io 对这次评分的把握:各数据源结论越一致越高 مدى ثقة cleanip.io في التقييم: ترتفع كلما زاد اتفاق المصادر macOS 系统目录(系统组件) مجلد نظام macOS (مكوّن نظام) npm 缓存 ذاكرة npm المؤقتة +Go 缓存 ذاكرة Go المؤقتة +Rust 缓存 ذاكرة Rust المؤقتة +uv 缓存 ذاكرة uv المؤقتة +Yarn 缓存 ذاكرة Yarn المؤقتة +pnpm 缓存 ذاكرة pnpm المؤقتة +Bun 缓存 ذاكرة Bun المؤقتة +npm 下载缓存,由 npm 自行清理 ذاكرة تنزيل npm المؤقتة، ينظفها npm بنفسه +Yarn 全局与离线镜像缓存,由 Yarn 自行清理 ذاكرة Yarn العامة ومرآة العمل دون اتصال، ينظفها Yarn بنفسه +pnpm 内容寻址存储,仅清理未被引用的包 مخزن pnpm المعنون بالمحتوى؛ تزال الحزم غير المشار إليها فقط +Bun 下载的包缓存,由 Bun 自行清理 ذاكرة تنزيل حزم Bun المؤقتة، ينظفها Bun بنفسه +Go 编译与完整模块缓存,由 go clean 清理 ذاكرة بناء Go والوحدات الكاملة، ينظفها go clean +Cargo 注册表与 Git 依赖缓存,需要 cargo-cache ذاكرة سجل Cargo وتبعيات Git المؤقتة؛ تتطلب cargo-cache +uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم Python والبناء التي نزّلها uv، ينظفها uv بنفسه +未找到 {},无法安全清理 لم يتم العثور على {}، لذلك لا يمكن تنظيفه بأمان +{} 清理失败(退出码 {}) فشل تنظيف {} (رمز الخروج {}) +{} 清理失败(退出码 {}):{} فشل تنظيف {} (رمز الخروج {}): {} +{}:未找到 {},无法安全清理 {}: لم يتم العثور على {}، لذلك لا يمكن تنظيفه بأمان +支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 تُنقل العناصر المدعومة إلى سلة المهملات أولًا؛ وتُنظف ذاكرة الأدوات مباشرة بأوامرها. +支持的缓存可以恢复;工具缓存始终由对应命令直接清理 يمكن استعادة الذاكرة المؤقتة المدعومة؛ وتُنظف ذاكرة الأدوات دائمًا مباشرة بأوامرها {} {} 英寸 {} بحجم {} بوصة {} · {} 个 {} · {} {} · {} 核 {} · {} نوى diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index 0f76855..ce9875c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -95,6 +95,25 @@ Xcode 编译缓存 Xcode-Build-Cache cleanip.io 对这次评分的把握:各数据源结论越一致越高 Zuverlässigkeit der Bewertung von cleanip.io: steigt mit der Übereinstimmung der Datenquellen macOS 系统目录(系统组件) macOS-Systemordner (Systemkomponente) npm 缓存 npm-Cache +Go 缓存 Go-Cache +Rust 缓存 Rust-Cache +uv 缓存 uv-Cache +Yarn 缓存 Yarn-Cache +pnpm 缓存 pnpm-Cache +Bun 缓存 Bun-Cache +npm 下载缓存,由 npm 自行清理 npm-Download-Cache; wird von npm selbst bereinigt +Yarn 全局与离线镜像缓存,由 Yarn 自行清理 Globaler Yarn- und Offline-Mirror-Cache; wird von Yarn selbst bereinigt +pnpm 内容寻址存储,仅清理未被引用的包 Inhaltsadressierter pnpm-Speicher; nur nicht referenzierte Pakete werden entfernt +Bun 下载的包缓存,由 Bun 自行清理 Bun-Paketdownload-Cache; wird von Bun selbst bereinigt +Go 编译与完整模块缓存,由 go clean 清理 Go-Build- und vollständiger Modul-Cache; wird mit go clean bereinigt +Cargo 注册表与 Git 依赖缓存,需要 cargo-cache Cargo-Registry- und Git-Abhängigkeits-Cache; benötigt cargo-cache +uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Python-Paket- und Build-Cache; wird von uv selbst bereinigt +未找到 {},无法安全清理 {} wurde nicht gefunden und kann daher nicht sicher bereinigt werden +{} 清理失败(退出码 {}) Bereinigung von {} fehlgeschlagen (Exit-Code {}) +{} 清理失败(退出码 {}):{} Bereinigung von {} fehlgeschlagen (Exit-Code {}): {} +{}:未找到 {},无法安全清理 {}: {} wurde nicht gefunden und kann daher nicht sicher bereinigt werden +支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 Unterstützte Inhalte werden zuerst in den Papierkorb verschoben; Tool-Caches werden direkt durch ihre Befehle bereinigt. +支持的缓存可以恢复;工具缓存始终由对应命令直接清理 Unterstützte Caches können wiederhergestellt werden; Tool-Caches werden immer direkt durch ihre Befehle bereinigt {} {} 英寸 {} {} Zoll {} · {} 个 {} · {} {} · {} 核 {} · {} Kerne diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index d966ebc..5c4c81f 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -95,6 +95,25 @@ Xcode 编译缓存 Caché de compilación de Xcode cleanip.io 对这次评分的把握:各数据源结论越一致越高 Confianza de cleanip.io en la puntuación: aumenta cuanto más coinciden sus fuentes macOS 系统目录(系统组件) Carpeta del sistema macOS (componente del sistema) npm 缓存 Caché de npm +Go 缓存 Caché de Go +Rust 缓存 Caché de Rust +uv 缓存 Caché de uv +Yarn 缓存 Caché de Yarn +pnpm 缓存 Caché de pnpm +Bun 缓存 Caché de Bun +npm 下载缓存,由 npm 自行清理 Caché de descargas de npm, limpiada por npm +Yarn 全局与离线镜像缓存,由 Yarn 自行清理 Caché global y espejo sin conexión de Yarn, limpiadas por Yarn +pnpm 内容寻址存储,仅清理未被引用的包 Almacén direccionado por contenido de pnpm; solo se eliminan paquetes sin referencias +Bun 下载的包缓存,由 Bun 自行清理 Caché de descarga de paquetes de Bun, limpiada por Bun +Go 编译与完整模块缓存,由 go clean 清理 Cachés de compilación y módulos completos de Go, limpiadas con go clean +Cargo 注册表与 Git 依赖缓存,需要 cargo-cache Cachés del registro de Cargo y dependencias Git; requiere cargo-cache +uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes de Python y compilación descargadas por uv, limpiadas por uv +未找到 {},无法安全清理 No se encontró {} y no se puede limpiar de forma segura +{} 清理失败(退出码 {}) Falló la limpieza de {} (código de salida {}) +{} 清理失败(退出码 {}):{} Falló la limpieza de {} (código de salida {}): {} +{}:未找到 {},无法安全清理 {}: no se encontró {} y no se puede limpiar de forma segura +支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 Los elementos compatibles se mueven primero a la papelera; las cachés de herramientas se limpian directamente con sus comandos. +支持的缓存可以恢复;工具缓存始终由对应命令直接清理 Las cachés compatibles se pueden restaurar; las cachés de herramientas siempre se limpian directamente con sus comandos {} {} 英寸 {} de {} pulgadas {} · {} 个 {} · {} {} · {} 核 {} · {} núcleos diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 74261be..8c6ad4a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -95,6 +95,25 @@ Xcode 编译缓存 Cache de compilation Xcode cleanip.io 对这次评分的把握:各数据源结论越一致越高 Fiabilité du score selon cleanip.io : plus les sources concordent, plus elle est élevée macOS 系统目录(系统组件) Dossier système macOS (composant système) npm 缓存 Cache npm +Go 缓存 Cache Go +Rust 缓存 Cache Rust +uv 缓存 Cache uv +Yarn 缓存 Cache Yarn +pnpm 缓存 Cache pnpm +Bun 缓存 Cache Bun +npm 下载缓存,由 npm 自行清理 Cache de téléchargement npm, nettoyé par npm +Yarn 全局与离线镜像缓存,由 Yarn 自行清理 Caches global et miroir hors ligne de Yarn, nettoyés par Yarn +pnpm 内容寻址存储,仅清理未被引用的包 Stockage adressé par contenu de pnpm ; seuls les paquets non référencés sont supprimés +Bun 下载的包缓存,由 Bun 自行清理 Cache de téléchargement des paquets Bun, nettoyé par Bun +Go 编译与完整模块缓存,由 go clean 清理 Caches de compilation et de modules complets Go, nettoyés par go clean +Cargo 注册表与 Git 依赖缓存,需要 cargo-cache Caches du registre Cargo et des dépendances Git ; nécessite cargo-cache +uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets Python et de compilation téléchargés par uv, nettoyés par uv +未找到 {},无法安全清理 {} est introuvable et ne peut pas être nettoyé en toute sécurité +{} 清理失败(退出码 {}) Échec du nettoyage de {} (code de sortie {}) +{} 清理失败(退出码 {}):{} Échec du nettoyage de {} (code de sortie {}) : {} +{}:未找到 {},无法安全清理 {} : {} est introuvable et ne peut pas être nettoyé en toute sécurité +支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 Les éléments pris en charge sont d’abord placés dans la corbeille ; les caches d’outils sont nettoyés directement par leurs commandes. +支持的缓存可以恢复;工具缓存始终由对应命令直接清理 Les caches pris en charge peuvent être restaurés ; les caches d’outils sont toujours nettoyés directement par leurs commandes {} {} 英寸 {} {} pouces {} · {} 个 {} · {} {} · {} 核 {} · {} cœurs diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 59b4e11..41121ab 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -95,6 +95,25 @@ Xcode 编译缓存 Xcodeビルドキャッシュ cleanip.io 对这次评分的把握:各数据源结论越一致越高 cleanip.ioの評価の確度:情報源の結果が一致するほど高くなります macOS 系统目录(系统组件) macOSシステムフォルダ(システムコンポーネント) npm 缓存 npmキャッシュ +Go 缓存 Goキャッシュ +Rust 缓存 Rustキャッシュ +uv 缓存 uvキャッシュ +Yarn 缓存 Yarnキャッシュ +pnpm 缓存 pnpmキャッシュ +Bun 缓存 Bunキャッシュ +npm 下载缓存,由 npm 自行清理 npmのダウンロードキャッシュ。npmでクリーンアップします +Yarn 全局与离线镜像缓存,由 Yarn 自行清理 Yarnのグローバルキャッシュとオフラインミラー。Yarnでクリーンアップします +pnpm 内容寻址存储,仅清理未被引用的包 pnpmのコンテンツアドレスストア。参照されていないパッケージのみ削除します +Bun 下载的包缓存,由 Bun 自行清理 Bunのパッケージダウンロードキャッシュ。Bunでクリーンアップします +Go 编译与完整模块缓存,由 go clean 清理 Goのビルドキャッシュと完全なモジュールキャッシュ。go cleanでクリーンアップします +Cargo 注册表与 Git 依赖缓存,需要 cargo-cache CargoのレジストリとGit依存関係キャッシュ。cargo-cacheが必要です +uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッケージとビルドキャッシュ。uvでクリーンアップします +未找到 {},无法安全清理 {}が見つからないため、安全にクリーンアップできません +{} 清理失败(退出码 {}) {}のクリーンアップに失敗しました(終了コード{}) +{} 清理失败(退出码 {}):{} {}のクリーンアップに失敗しました(終了コード{}):{} +{}:未找到 {},无法安全清理 {}:{}が見つからないため、安全にクリーンアップできません +支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 対応する項目は先にゴミ箱へ移動し、ツールキャッシュは各コマンドで直接削除します。 +支持的缓存可以恢复;工具缓存始终由对应命令直接清理 対応するキャッシュは復元できますが、ツールキャッシュは常に各コマンドで直接削除します {} {} 英寸 {} {}インチ {} · {} 个 {} · {}個 {} · {} 核 {} · {}コア diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 4e75ef2..0a569c9 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -95,6 +95,25 @@ Xcode 编译缓存 Xcode 빌드 캐시 cleanip.io 对这次评分的把握:各数据源结论越一致越高 cleanip.io의 점수 확신도: 정보 출처의 결과가 일치할수록 높습니다 macOS 系统目录(系统组件) macOS 시스템 폴더(시스템 구성 요소) npm 缓存 npm 캐시 +Go 缓存 Go 캐시 +Rust 缓存 Rust 캐시 +uv 缓存 uv 캐시 +Yarn 缓存 Yarn 캐시 +pnpm 缓存 pnpm 캐시 +Bun 缓存 Bun 캐시 +npm 下载缓存,由 npm 自行清理 npm 다운로드 캐시이며 npm이 직접 정리합니다 +Yarn 全局与离线镜像缓存,由 Yarn 自行清理 Yarn 전역 및 오프라인 미러 캐시이며 Yarn이 직접 정리합니다 +pnpm 内容寻址存储,仅清理未被引用的包 pnpm 콘텐츠 주소 지정 저장소이며 참조되지 않은 패키지만 정리합니다 +Bun 下载的包缓存,由 Bun 自行清理 Bun 패키지 다운로드 캐시이며 Bun이 직접 정리합니다 +Go 编译与完整模块缓存,由 go clean 清理 Go 빌드 및 전체 모듈 캐시이며 go clean으로 정리합니다 +Cargo 注册表与 Git 依赖缓存,需要 cargo-cache Cargo 레지스트리 및 Git 종속성 캐시이며 cargo-cache가 필요합니다 +uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키지 및 빌드 캐시이며 uv가 직접 정리합니다 +未找到 {},无法安全清理 {}을(를) 찾지 못해 안전하게 정리할 수 없습니다 +{} 清理失败(退出码 {}) {} 정리에 실패했습니다(종료 코드 {}) +{} 清理失败(退出码 {}):{} {} 정리에 실패했습니다(종료 코드 {}): {} +{}:未找到 {},无法安全清理 {}: {}을(를) 찾지 못해 안전하게 정리할 수 없습니다 +支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 지원되는 항목은 먼저 휴지통으로 이동하고 도구 캐시는 해당 명령으로 직접 정리합니다. +支持的缓存可以恢复;工具缓存始终由对应命令直接清理 지원되는 캐시는 복원할 수 있으며 도구 캐시는 항상 해당 명령으로 직접 정리합니다 {} {} 英寸 {} {}인치 {} · {} 个 {} · {}개 {} · {} 核 {} · {}코어 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index 0162543..a28fab0 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -96,6 +96,25 @@ Xcode 编译缓存 Xcode 編譯緩存 cleanip.io 对这次评分的把握:各数据源结论越一致越高 cleanip.io 對這次評分的把握:各數據源結論越一致越高 macOS 系统目录(系统组件) macOS 系統目錄(系統組件) npm 缓存 npm 緩存 +Go 缓存 Go 緩存 +Rust 缓存 Rust 緩存 +uv 缓存 uv 緩存 +Yarn 缓存 Yarn 緩存 +pnpm 缓存 pnpm 緩存 +Bun 缓存 Bun 緩存 +npm 下载缓存,由 npm 自行清理 npm 下載緩存,由 npm 自行清理 +Yarn 全局与离线镜像缓存,由 Yarn 自行清理 Yarn 全局與離線鏡像緩存,由 Yarn 自行清理 +pnpm 内容寻址存储,仅清理未被引用的包 pnpm 內容尋址存儲,僅清理未被引用的包 +Bun 下载的包缓存,由 Bun 自行清理 Bun 下載的包緩存,由 Bun 自行清理 +Go 编译与完整模块缓存,由 go clean 清理 Go 編譯與完整模組緩存,由 go clean 清理 +Cargo 注册表与 Git 依赖缓存,需要 cargo-cache Cargo 註冊表與 Git 依賴緩存,需要 cargo-cache +uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python 包與構建緩存,由 uv 自行清理 +未找到 {},无法安全清理 未找到 {},無法安全清理 +{} 清理失败(退出码 {}) {} 清理失敗(退出碼 {}) +{} 清理失败(退出码 {}):{} {} 清理失敗(退出碼 {}):{} +{}:未找到 {},无法安全清理 {}:未找到 {},無法安全清理 +支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 支持的內容先移到廢紙簍;工具緩存由對應命令直接清理。 +支持的缓存可以恢复;工具缓存始终由对应命令直接清理 支持的緩存可以恢復;工具緩存始終由對應命令直接清理 {} {} 英寸 {} {} 英吋 {} · {} 个 {} · {} 個 {} · {} 核 {} · {} 核 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index b588555..a5e3a6e 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -100,6 +100,25 @@ Xcode 编译缓存 Xcode Build Cache cleanip.io 对这次评分的把握:各数据源结论越一致越高 How sure cleanip.io is about this score: the more its sources agree, the higher macOS 系统目录(系统组件) macOS system folder (system component) npm 缓存 npm Cache +Go 缓存 Go Cache +Rust 缓存 Rust Cache +uv 缓存 uv Cache +Yarn 缓存 Yarn Cache +pnpm 缓存 pnpm Cache +Bun 缓存 Bun Cache +npm 下载缓存,由 npm 自行清理 npm download cache; cleaned by npm +Yarn 全局与离线镜像缓存,由 Yarn 自行清理 Yarn global and offline mirror caches; cleaned by Yarn +pnpm 内容寻址存储,仅清理未被引用的包 pnpm content-addressable store; only unreferenced packages are removed +Bun 下载的包缓存,由 Bun 自行清理 Bun package download cache; cleaned by Bun +Go 编译与完整模块缓存,由 go clean 清理 Go build and complete module caches; cleaned by go clean +Cargo 注册表与 Git 依赖缓存,需要 cargo-cache Cargo registry and Git dependency caches; requires cargo-cache +uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and build caches downloaded by uv; cleaned by uv +未找到 {},无法安全清理 {} was not found, so it cannot be cleaned safely +{} 清理失败(退出码 {}) {} cleanup failed (exit code {}) +{} 清理失败(退出码 {}):{} {} cleanup failed (exit code {}): {} +{}:未找到 {},无法安全清理 {}: {} was not found, so it cannot be cleaned safely +支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 Supported items are moved to Trash first; tool caches are cleaned directly by their commands. +支持的缓存可以恢复;工具缓存始终由对应命令直接清理 Supported caches can be restored; tool caches are always cleaned directly by their commands {} {} 英寸 {} {}-inch {} · {} 个 {} · {} {} · {} 核 {} · {} cores diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift index b234999..f7fd308 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/CleanerPage.swift @@ -134,7 +134,9 @@ private struct SummaryCard: View { private var confirmationText: String { let cleaner = model.cleaner let count = cleaner.selectedScans.reduce(0) { $0 + $1.items.count } - let trashNote = model.settings.cleanPrefersTrash ? tr("所有内容先移到废纸篓。") : tr("缓存与日志直接删除,下载内容移到废纸篓。") + let trashNote = model.settings.cleanPrefersTrash + ? tr("支持的内容先移到废纸篓;工具缓存由对应命令直接清理。") + : tr("缓存与日志直接删除,下载内容移到废纸篓。") return tr("将清理 \(count) 个项目,共 \(Format.bytes(cleaner.selectedBytes, base: .decimal))。\(trashNote)") } @@ -266,10 +268,7 @@ private struct RuleRow: View { HStack(spacing: DS.Space.s2) { DSCheckbox(isOn: selected) { cleaner.toggle(scan) } .disabled(!scan.isCleanable || cleaner.isBusy) - Image(systemName: scan.rule.symbol) - .font(.system(size: DS.TextSize.sm.rawValue)) - .foregroundStyle(DS.Palette.textSecondary) - .frame(width: DS.Size.iconStandalone) + RuleIcon(name: scan.rule.symbol) VStack(alignment: .leading, spacing: 0) { Text(scan.rule.title) .dsFont(.sm, weight: .medium) @@ -335,6 +334,27 @@ private struct RuleRow: View { } } +private struct RuleIcon: View { + let name: String + + var body: some View { + Group { + if let image = LogoCache.shared.image(named: name, template: true) { + Image(nsImage: image) + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: DS.Size.iconStandalone, height: DS.TextSize.sm.rawValue) + } else { + Image(systemName: name) + .font(.system(size: DS.TextSize.sm.rawValue)) + } + } + .foregroundStyle(DS.Palette.textSecondary) + .frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone) + } +} + // MARK: - 底部 private struct CleanerFooter: View { @@ -347,7 +367,8 @@ private struct CleanerFooter: View { DSToggle(isOn: $settings.cleanPrefersTrash, label: tr("缓存也先移到废纸篓")) VStack(alignment: .leading, spacing: 0) { Text(tr("缓存也先移到废纸篓")).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary) - Text(tr("可以恢复,但清空废纸篓前不会释放空间")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + Text(tr("支持的缓存可以恢复;工具缓存始终由对应命令直接清理")) + .dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) } Spacer() Button(tr("查看清理日志")) { model.cleaner.revealLog() } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-bun.svg b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-bun.svg new file mode 100644 index 0000000..cb297bc --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-bun.svg @@ -0,0 +1,2 @@ + +Bun diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-go.svg b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-go.svg new file mode 100644 index 0000000..2b1f4ef --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-go.svg @@ -0,0 +1,2 @@ + +Go diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-npm.svg b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-npm.svg new file mode 100644 index 0000000..0abbb05 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-npm.svg @@ -0,0 +1,2 @@ + +npm diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-pnpm.svg b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-pnpm.svg new file mode 100644 index 0000000..9f29857 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-pnpm.svg @@ -0,0 +1,2 @@ + +pnpm diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-rust.svg b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-rust.svg new file mode 100644 index 0000000..7b9cfb4 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-rust.svg @@ -0,0 +1,2 @@ + +Rust diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-uv.svg b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-uv.svg new file mode 100644 index 0000000..23f5bfa --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-uv.svg @@ -0,0 +1,2 @@ + +uv diff --git a/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-yarn.svg b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-yarn.svg new file mode 100644 index 0000000..95bed98 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-yarn.svg @@ -0,0 +1,2 @@ + +Yarn diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 2a85ea1..be1aca9 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -499,6 +499,16 @@ public final class AppSettings { public var cleanPrefersTrash: Bool { didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) } } + /// 清理页的规则选择;nil 表示用户从未调整过,使用规则默认值。空集合表示用户明确全部取消。 + public var cleanSelectedRuleIDs: Set? { + didSet { + if let cleanSelectedRuleIDs { + defaults.set(cleanSelectedRuleIDs.sorted(), forKey: Keys.cleanSelectedRuleIDs) + } else { + defaults.removeObject(forKey: Keys.cleanSelectedRuleIDs) + } + } + } public static let refreshOptions = [1, 2, 3, 5] public static let batteryFloorOptions = [10, 20, 30, 40] @@ -532,6 +542,7 @@ public final class AppSettings { showDockIcon = defaults.bool(forKey: Keys.showDockIcon) speedTestBudget = defaults.string(forKey: Keys.speedTestBudget).flatMap(SpeedTestBudget.init(rawValue:)) ?? .full cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash) + cleanSelectedRuleIDs = defaults.stringArray(forKey: Keys.cleanSelectedRuleIDs).map(Set.init) menuBarLayout = defaults.string(forKey: Keys.menuBarLayout).flatMap(MenuBarLayout.init(rawValue:)) ?? .separate expandedPopoverSections = Set((defaults.stringArray(forKey: Keys.expandedPopoverSections) ?? []).compactMap(PopoverSection.init(rawValue:))) hiddenPopoverSections = defaults.stringArray(forKey: Keys.hiddenPopoverSections) @@ -604,6 +615,7 @@ public final class AppSettings { static let showDockIcon = "showDockIcon" static let speedTestBudget = "speedTestBudget" static let cleanPrefersTrash = "cleanPrefersTrash" + static let cleanSelectedRuleIDs = "cleanSelectedRuleIDs" static let menuBarLayout = "menuBarLayout" static let hiddenPopoverSections = "hiddenPopoverSections" static let expandedPopoverSections = "expandedPopoverSections" diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/CleanerController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/CleanerController.swift index 1807326..ca30f8c 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/CleanerController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/CleanerController.swift @@ -14,7 +14,9 @@ public final class CleanerController { public private(set) var scans: [RuleScan] = [] public private(set) var lastScan: Date? public private(set) var report: CleanReport? - public var selection: Set + public var selection: Set { + didSet { settings.cleanSelectedRuleIDs = selection } + } public var isConfirming = false @ObservationIgnored private let settings: AppSettings @@ -23,7 +25,9 @@ public final class CleanerController { init(settings: AppSettings) { self.settings = settings - selection = Set(rules.filter(\.selectedByDefault).map(\.id)) + let validIDs = Set(rules.map(\.id)) + let initial = settings.cleanSelectedRuleIDs ?? Set(rules.filter(\.selectedByDefault).map(\.id)) + selection = initial.intersection(validIDs) } var isBusy: Bool { phase == .scanning || phase == .cleaning } diff --git a/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift b/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift index 6aa51eb..3fae451 100644 --- a/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift +++ b/Packages/XStatsKit/Tests/CleanerTests/CleanerTests.swift @@ -26,8 +26,17 @@ private struct FakeHome { return url } - func environment(running: Set = []) -> CleanEnvironment { - CleanEnvironment(home: path, runningBundleIdentifiers: { running }) + @discardableResult + func executable(_ name: String, script: String) throws -> URL { + let url = root.appendingPathComponent(".local/bin/\(name)") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } + + func environment(running: Set = [], now: Date = Date()) -> CleanEnvironment { + CleanEnvironment(home: path, runningBundleIdentifiers: { running }, now: { now }) } func remove() { try? FileManager.default.removeItem(at: root) } @@ -41,13 +50,25 @@ struct SafetyGuardTests { try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Caches/com.example.app")) try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Logs/DiagnosticReports")) try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Application Support/Google/Chrome/Default/Code Cache")) + try guardian.validate(URL(fileURLWithPath: "/Users/tester/go/pkg/mod/cache/download/example.zip")) + try guardian.validate(URL(fileURLWithPath: "/Users/tester/go/pkg/mod/github.com/example/module")) + try guardian.validate(URL(fileURLWithPath: "/Users/tester/.cargo/registry/cache/index.crates.io")) + try guardian.validate(URL(fileURLWithPath: "/Users/tester/.cargo/git/db/example")) + try guardian.validate(URL(fileURLWithPath: "/Users/tester/.cache/uv/archive-v0")) } @Test func rejectsRootsAndOutsidePaths() { #expect(throws: SafetyError.isRoot) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Caches")) } + #expect(throws: SafetyError.isRoot) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/go/pkg/mod")) } + #expect(throws: SafetyError.isRoot) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/.cargo/registry")) } + #expect(throws: SafetyError.isRoot) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/.cargo/git")) } + #expect(throws: SafetyError.isRoot) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/.cache/uv")) } #expect(throws: SafetyError.outsideAllowedRoots) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/Documents/a")) } #expect(throws: SafetyError.outsideAllowedRoots) { try guardian.validate(URL(fileURLWithPath: "/System/Library/Caches/x")) } #expect(throws: SafetyError.outsideAllowedRoots) { try guardian.validate(URL(fileURLWithPath: "/Users/other/Library/Caches/x")) } + #expect(throws: SafetyError.outsideAllowedRoots) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/go/src/project")) } + #expect(throws: SafetyError.outsideAllowedRoots) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/.cargo/bin/cargo")) } + #expect(throws: SafetyError.outsideAllowedRoots) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/.cache/pip/http-v2")) } } @Test func rejectsTraversalAndControlCharacters() { @@ -95,6 +116,194 @@ struct SafetyGuardTests { } } +@Suite("开发工具缓存规则") +struct DeveloperCacheRuleTests { + @Test func catalogIncludesPackageManagerCaches() { + let rules = Dictionary(uniqueKeysWithValues: RuleCatalog.rules().map { ($0.id, $0) }) + + for id in ["developer.npm", "developer.yarn", "developer.pnpm", "developer.bun", + "developer.go", "developer.rust", "developer.uv"] { + #expect(rules[id]?.category == .developer) + #expect(rules[id]?.selectedByDefault == false) + } + } + + @Test func packageManagerCachesUseTheirBrandLogos() { + let rules = Dictionary(uniqueKeysWithValues: RuleCatalog.rules().map { ($0.id, $0) }) + let expected = [ + "developer.npm": "tool-npm", + "developer.yarn": "tool-yarn", + "developer.pnpm": "tool-pnpm", + "developer.bun": "tool-bun", + "developer.go": "tool-go", + "developer.rust": "tool-rust", + "developer.uv": "tool-uv", + ] + + for (id, logo) in expected { + #expect(rules[id]?.symbol == logo) + } + } + + @Test func rulesOnlyLocateRegenerableCacheDirectories() throws { + let home = try FakeHome() + defer { home.remove() } + try home.file("Library/Caches/go-build/compile-cache/action") + try home.file("go/pkg/mod/cache/download/example.zip") + try home.file("go/pkg/mod/github.com/example/module@v1.0.0/source.go") + try home.file("go/src/project/main.go") + try home.file(".cargo/registry/cache/index.crates.io/package.crate") + try home.file(".cargo/registry/src/index.crates.io/package/src/lib.rs") + try home.file(".cargo/git/db/example/objects/pack/data") + try home.file(".cargo/git/checkouts/example/revision/src/lib.rs") + try home.file(".cargo/bin/cargo") + try home.file(".cache/uv/archive-v0/package/module.py") + try home.file(".cache/pip/http-v2/response") + try home.file("Library/Caches/Yarn/v6/npm-example/package.tgz") + try home.file(".yarn/berry/cache/example.zip") + try home.file("Library/pnpm/store/v11/files/example") + try home.file(".pnpm-store/v3/files/example") + try home.file(".bun/install/cache/example/package.tgz") + + let rules = Dictionary(uniqueKeysWithValues: RuleCatalog.rules().map { ($0.id, $0) }) + let goRule = try #require(rules["developer.go"]) + let rustRule = try #require(rules["developer.rust"]) + let uvRule = try #require(rules["developer.uv"]) + let yarnRule = try #require(rules["developer.yarn"]) + let pnpmRule = try #require(rules["developer.pnpm"]) + let bunRule = try #require(rules["developer.bun"]) + + #expect(try relativePaths(goRule.locate(home.environment()), home: home) == [ + "Library/Caches/go-build", "go/pkg/mod/cache", "go/pkg/mod/github.com", + ]) + #expect(try relativePaths(rustRule.locate(home.environment()), home: home) == [ + ".cargo/git/checkouts", ".cargo/git/db", ".cargo/registry/cache", ".cargo/registry/src", + ]) + #expect(try relativePaths(uvRule.locate(home.environment()), home: home) == [ + ".cache/uv/archive-v0", + ]) + #expect(try relativePaths(yarnRule.locate(home.environment()), home: home) == [ + ".yarn/berry/cache/example.zip", "Library/Caches/Yarn/v6", + ]) + #expect(try relativePaths(pnpmRule.locate(home.environment()), home: home) == [ + ".pnpm-store/v3", "Library/pnpm/store/v11", + ]) + #expect(try relativePaths(bunRule.locate(home.environment()), home: home) == [ + ".bun/install/cache/example", + ]) + } + + @Test func packageManagerCachesUseToolsInsteadOfDirectDeletion() async throws { + let home = try FakeHome() + defer { home.remove() } + let marker = home.root.appendingPathComponent("tool-invocations.txt") + let script = """ + #!/bin/sh + if [ "$1" = "--version" ]; then + echo "1.22.22" + exit 0 + fi + printf '%s|%s\\n' "$(basename "$0")" "$*" >> "\(marker.path)" + """ + for tool in ["npm", "yarn", "pnpm", "bun", "go", "cargo-cache", "uv"] { + try home.executable(tool, script: script) + } + + let cacheFiles = [ + try home.file(".npm/_cacache/content/data"), + try home.file("Library/Caches/Yarn/v6/npm-example/package.tgz"), + try home.file("Library/pnpm/store/v11/files/data"), + try home.file(".bun/install/cache/example/package.tgz"), + try home.file("Library/Caches/go-build/action/data"), + try home.file("go/pkg/mod/cache/download/data"), + try home.file(".cargo/registry/cache/index/package.crate"), + try home.file(".cache/uv/archive-v0/package/data"), + ] + let ids = Set(["developer.npm", "developer.yarn", "developer.pnpm", "developer.bun", + "developer.go", "developer.rust", "developer.uv"]) + let rules = RuleCatalog.rules().filter { ids.contains($0.id) } + let environment = home.environment(now: Date().addingTimeInterval(86_400)) + let scans = await CleanEngine.scan(rules, environment: environment) + + let report = await CleanEngine.clean(scans, selected: ids, preferTrash: false, + environment: environment, log: nil) + + #expect(report.failures.isEmpty) + #expect(cacheFiles.allSatisfy { FileManager.default.fileExists(atPath: $0.path) }) + let invocations = try String(contentsOf: marker, encoding: .utf8) + .split(separator: "\n").map(String.init).sorted() + #expect(invocations == [ + "bun|pm cache rm", + "cargo-cache|-r all", + "go|clean -cache -modcache", + "npm|cache clean --force", + "pnpm|store prune", + "uv|cache clean", + "yarn|cache clean", + ]) + } + + @Test func missingToolBlocksCacheWithoutDeletingIt() async throws { + let home = try FakeHome() + defer { home.remove() } + let cache = try home.file(".npm/_cacache/content/data") + let environment = CleanEnvironment( + home: home.path, + runningBundleIdentifiers: { [] }, + isToolAvailable: { _ in false }, + runTool: { _, _ in throw TestError.unexpectedToolRun } + ) + + let scans = await CleanEngine.scan([RuleCatalog.npmCache], environment: environment) + + #expect(scans.first?.blocked == .toolUnavailable("npm")) + #expect(FileManager.default.fileExists(atPath: cache.path)) + } + + @Test func failedToolCommandReportsFailureWithoutDeletingCache() async throws { + let home = try FakeHome() + defer { home.remove() } + try home.executable("npm", script: "#!/bin/sh\necho 'simulated failure' >&2\nexit 7\n") + let cache = try home.file(".npm/_cacache/content/data") + let environment = home.environment(now: Date().addingTimeInterval(86_400)) + let scans = await CleanEngine.scan([RuleCatalog.npmCache], environment: environment) + + let report = await CleanEngine.clean(scans, selected: [RuleCatalog.npmCache.id], preferTrash: false, + environment: environment, log: nil) + + #expect(report.failures.count == 1) + #expect(report.failures.first?.contains("simulated failure") == true) + #expect(report.failures.first?.contains("7") == true) + #expect(FileManager.default.fileExists(atPath: cache.path)) + } + + @Test func toolManagedCacheDoesNotTreatPackageNamesAsProtectedAppData() async throws { + let home = try FakeHome() + defer { home.remove() } + try home.executable("bun", script: "#!/bin/sh\nexit 0\n") + try home.file(".bun/install/cache/xstats-package/data") + + let scans = await CleanEngine.scan([RuleCatalog.bunCache], environment: home.environment()) + + #expect(scans.first?.skippedCount == 0) + #expect(scans.first?.items.map(\.url.lastPathComponent) == ["xstats-package"]) + } + + private func relativePaths(_ urls: [URL], home: FakeHome) throws -> [String] { + let prefix = home.path + "/" + return try urls.map { url in + let path = url.standardizedFileURL.path + guard path.hasPrefix(prefix) else { throw TestError.outsideHome } + return String(path.dropFirst(prefix.count)) + }.sorted() + } + + private enum TestError: Error { + case outsideHome + case unexpectedToolRun + } +} + @Suite("扫描与清理", .serialized) struct CleanEngineTests { @Test func scanSkipsRunningAppsAndAppleCaches() async throws { diff --git a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift index 5d8f7e6..9846a80 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift @@ -11,6 +11,18 @@ private func isolatedDefaults() -> UserDefaults { return defaults } +@MainActor +@Suite struct ToolBrandLogoTests { + @Test(arguments: ["tool-npm", "tool-yarn", "tool-pnpm", "tool-bun", + "tool-go", "tool-rust", "tool-uv"]) + func packagedSVGCanBeRenderedAsTemplate(_ name: String) throws { + let image = try #require(LogoCache.shared.image(named: name, template: true)) + #expect(image.isTemplate) + #expect(image.size.width > 0) + #expect(image.size.height > 0) + } +} + @MainActor @Suite struct BluetoothDeviceCacheTests { private func device(_ name: String, percent: Int) -> BluetoothDevice { @@ -170,6 +182,41 @@ private func isolatedDefaults() -> UserDefaults { } } +@MainActor +@Suite struct CleanerSelectionTests { + private let toolRuleIDs: Set = [ + "developer.npm", "developer.yarn", "developer.pnpm", "developer.bun", + "developer.go", "developer.rust", "developer.uv", + ] + + @Test func toolCachesStartUnselected() { + let controller = CleanerController(settings: AppSettings(defaults: isolatedDefaults())) + + #expect(controller.selection.isDisjoint(with: toolRuleIDs)) + #expect(controller.selection.contains("system.caches")) + } + + @Test func userSelectionSurvivesControllerAndSettingsRecreation() { + let defaults = isolatedDefaults() + let controller = CleanerController(settings: AppSettings(defaults: defaults)) + controller.selection.insert("developer.pnpm") + + let reloaded = CleanerController(settings: AppSettings(defaults: defaults)) + + #expect(reloaded.selection.contains("developer.pnpm")) + } + + @Test func explicitlyClearingSelectionDoesNotRestoreDefaults() { + let defaults = isolatedDefaults() + let controller = CleanerController(settings: AppSettings(defaults: defaults)) + controller.selection.removeAll() + + let reloaded = CleanerController(settings: AppSettings(defaults: defaults)) + + #expect(reloaded.selection.isEmpty) + } +} + @Suite struct PanelTabTests { @Test func sidebarGroupsCoverEveryTabOnce() { let grouped = PanelTab.monitors + PanelTab.tools + PanelTab.settings diff --git a/README.en.md b/README.en.md index 0faf2ef..17785e1 100644 --- a/README.en.md +++ b/README.en.md @@ -158,11 +158,13 @@ unclean exit restores them at the next boot. ## Cleanup - **What it scans.** App caches, logs and crash reports, browser caches (Chrome, Edge, - Brave, Arc, Firefox, Safari), Xcode DerivedData, simulator caches, the npm cache, Xcode - archives, unfinished downloads, installers and the Trash. + Brave, Arc, Firefox, Safari), Xcode DerivedData, simulator caches, npm, Yarn, pnpm, Bun, + Go, Rust and uv caches, Xcode archives, unfinished downloads, installers and the Trash. - **Review first.** Sizes by category, every rule expandable to its items, a second click to confirm. Caches and logs are deleted outright so the space comes back at once; downloads go - to the Trash. One switch sends everything to the Trash instead. + to the Trash. Developer-tool caches are cleaned by their own commands rather than by + deleting internal folders; the Trash preference applies only to folder-based caches. + Developer-tool caches start unselected and remember their local selection afterwards. - **Safety.** Only allow-listed folders are ever touched. Keychains, password managers, VPNs, cookies and history are off limits. Caches of running apps are skipped, browsers must be quit first, and every item is checked again right before it goes. Each action is logged diff --git a/README.ja.md b/README.ja.md index 98fc958..461186f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -138,8 +138,8 @@ XStats の終了時やクラッシュ時には自動制御へ戻り、蓋を閉 ## クリーンアップ -- **対象**:アプリやブラウザーのキャッシュ、ログ、クラッシュレポート、Xcode・シミュレーター・npm のキャッシュ、Xcode アーカイブ、未完了ダウンロード、インストーラー、ゴミ箱。 -- **削除前に確認**:種類別の容量とファイルを表示し、実行前に再確認します。通常、キャッシュとログは直接削除し、ダウンロードはゴミ箱へ移動します。すべてゴミ箱へ移動する設定もあります。 +- **対象**:アプリやブラウザーのキャッシュ、ログ、クラッシュレポート、Xcode・シミュレーター・npm・Yarn・pnpm・Bun・Go・Rust・uv のキャッシュ、Xcode アーカイブ、未完了ダウンロード、インストーラー、ゴミ箱。 +- **削除前に確認**:種類別の容量とファイルを表示し、実行前に再確認します。開発ツールのキャッシュは内部フォルダーを直接削除せず、各ツールのコマンドでクリーンアップします。初回は未選択で、以後はこの Mac での選択状態を記憶します。ゴミ箱設定はフォルダー型キャッシュにのみ適用されます。 - **保護**:許可されたディレクトリのみを処理します。キーチェーン、パスワード管理、VPN、Cookie、履歴などを保護し、実行中アプリのキャッシュはスキップします。各項目を削除直前に再検証します。 - **記録**:操作ログは ~/Library/Logs/XStats/cleanup.log に保存します。 - **メンテナンス**:DNS キャッシュ削除とメモリ解放。ヘルパーがなければ管理者認証を求めます。 diff --git a/README.ko.md b/README.ko.md index fc47f41..f0a2ec6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -138,8 +138,8 @@ XStats를 종료하거나 앱이 비정상 종료되면 팬이 자동 모드로 ## 정리 -- **대상**: 앱·브라우저 캐시, 로그, 충돌 보고서, Xcode·시뮬레이터·npm 캐시, Xcode 아카이브, 미완료 다운로드, 설치 파일, 휴지통. -- **삭제 전 확인**: 유형별 용량과 개별 파일을 표시하고 실행 전에 다시 확인합니다. 기본적으로 캐시와 로그는 바로 삭제하고 다운로드는 휴지통으로 옮깁니다. 모두 휴지통으로 이동하도록 설정할 수도 있습니다. +- **대상**: 앱·브라우저 캐시, 로그, 충돌 보고서, Xcode·시뮬레이터·npm·Yarn·pnpm·Bun·Go·Rust·uv 캐시, Xcode 아카이브, 미완료 다운로드, 설치 파일, 휴지통. +- **삭제 전 확인**: 유형별 용량과 개별 파일을 표시하고 실행 전에 다시 확인합니다. 개발 도구 캐시는 내부 디렉터리를 직접 삭제하지 않고 각 도구의 명령으로 정리합니다. 처음에는 선택되지 않으며 이후 이 Mac의 선택 상태를 기억합니다. 휴지통 설정은 디렉터리 기반 캐시에만 적용됩니다. - **보호**: 허용된 디렉터리만 처리합니다. 키체인, 비밀번호 관리자, VPN, 쿠키, 기록 등을 보호하고 실행 중인 앱의 캐시는 건너뜁니다. 삭제 직전에 각 항목을 다시 검사합니다. - **기록**: 작업 로그는 ~/Library/Logs/XStats/cleanup.log에 저장합니다. - **유지 관리**: DNS 캐시 비우기와 메모리 확보를 지원합니다. 보조 프로그램이 없으면 관리자 인증을 요청합니다. diff --git a/README.md b/README.md index 549a8f2..c65efcb 100644 --- a/README.md +++ b/README.md @@ -156,9 +156,10 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登 ## 清理 - **扫描范围**:应用缓存、日志与崩溃报告、浏览器缓存(Chrome、Edge、Brave、Arc、Firefox、Safari)、 - Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。 + Xcode 编译缓存、模拟器缓存、npm / Yarn / pnpm / Bun / Go / Rust / uv 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。 - **先预览**:按类别显示大小,每条规则可展开查看具体项目,清理前再确认一次。缓存与日志直接删除, - 空间立即释放;下载目录的内容移到废纸篓。也可以一键改为全部先移到废纸篓。 + 空间立即释放;下载目录的内容移到废纸篓。开发工具缓存由对应工具命令清理,不直接删除内部目录; + “缓存也先移到废纸篓”仅作用于目录型缓存。开发工具缓存首次不勾选,之后会记住本机的勾选状态。 - **安全**:只处理白名单目录。钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰。正在运行的应用的缓存 会跳过,浏览器需要先退出,删除前每一项都会再校验一次。所有操作记录在 `~/Library/Logs/XStats/cleanup.log`。 - **系统维护**:刷新 DNS 缓存、释放内存。已安装辅助工具时直接执行,否则请求一次管理员授权。 diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 6961715..14ff0f3 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -32,6 +32,12 @@ XStats 基于 [gentpan/OpenStats](https://github.com/gentpan/OpenStats) 开发 `google.svg` 是 Google 的品牌标志,仅按其品牌规范用于“使用 Google 登录”按钮。Apple 标志使用系统 SF Symbols 的 `apple.logo`。 GitHub、Google 与 Apple 的名称和标志均为各自公司的商标。 +## 开发工具品牌图标 + +`Packages/XStatsKit/Sources/XStatsUI/Resources/Logos/tool-{npm,yarn,pnpm,bun,go,rust,uv}.svg` 来自 +[Simple Icons](https://github.com/simple-icons/simple-icons/tree/b86d5c9a0bdd4f3f5c30898a63654dd32f39fd76), +使用 CC0-1.0。npm、Yarn、pnpm、Bun、Go、Rust、uv 的名称与标志均为各自权利人的商标,XStats 仅用这些图标标识对应工具的缓存。 + ## exelban/stats XStats 的以下部分移植自 [exelban/stats](https://github.com/exelban/stats): diff --git a/docs/PLAN.md b/docs/PLAN.md index 1ad26a8..ca1612c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -240,7 +240,7 @@ struct CleanRule: Identifiable, Sendable { | 开机自启 | `SMAppService.mainApp.register()` | 不需要单独的 LaunchAtLogin helper | | 自动更新 | Sparkle 2 | 通过 SPM 引入 | | 快捷键 | sindresorhus/KeyboardShortcuts(MIT) | 可选 | -| 图标 | SF Symbols(唯一图标集) | 16pt 行内 / 20pt 独立 | +| 图标 | 默认使用 SF Symbols;工具和网站可使用有来源声明的品牌标志 | 16pt 行内 / 20pt 独立 | | 日志 | `os.Logger` + 清理操作 JSONL 文件 | | | 测试 | Swift Testing;清理用临时目录做沙箱测试 | SafetyGuard 必须覆盖符号链接、`..`、保护路径等用例 | | CI | GitHub Actions `macos-latest`:构建、测试、签名、公证、生成 DMG | 用 `xcrun notarytool` |