feat(cleaner): 支持开发工具缓存清理

This commit is contained in:
ysicing committed 2026-09-22 16:39:29 +08:00
1 parent cab90673d5
commit 57004c87dd
31 files changed
+732 -26

No files matched your search

@@ -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)
@@ -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<String>
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<String>,
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 } }
@@ -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",
@@ -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",
]
@@ -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<Element>()
return filter { seen.insert($0).inserted }
}
}
@@ -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 بنفسه
未找到 {},无法安全清理 لم يتم العثور على {}، لذلك لا يمكن تنظيفه بأمان
{} 清理失败(退出码 {}) فشل تنظيف {} (رمز الخروج {})
{} 清理失败(退出码 {}):{} فشل تنظيف {} (رمز الخروج {}): {}
{}:未找到 {},无法安全清理 {}: لم يتم العثور على {}، لذلك لا يمكن تنظيفه بأمان
支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 تُنقل العناصر المدعومة إلى سلة المهملات أولًا؛ وتُنظف ذاكرة الأدوات مباشرة بأوامرها.
支持的缓存可以恢复;工具缓存始终由对应命令直接清理 يمكن استعادة الذاكرة المؤقتة المدعومة؛ وتُنظف ذاكرة الأدوات دائمًا مباشرة بأوامرها
{} {} 英寸 {} بحجم {} بوصة
{} · {} 个 {} · {}
{} · {} 核 {} · {} نوى
1 # Copyright (C) 2026 ysicing
95 cleanip.io 对这次评分的把握:各数据源结论越一致越高
96 macOS 系统目录(系统组件)
97 npm 缓存
98 Go 缓存
99 Rust 缓存
100 uv 缓存
101 Yarn 缓存
102 pnpm 缓存
103 Bun 缓存
104 npm 下载缓存,由 npm 自行清理
105 Yarn 全局与离线镜像缓存,由 Yarn 自行清理
106 pnpm 内容寻址存储,仅清理未被引用的包
107 Bun 下载的包缓存,由 Bun 自行清理
108 Go 编译与完整模块缓存,由 go clean 清理
109 Cargo 注册表与 Git 依赖缓存,需要 cargo-cache
110 uv 下载的 Python 包与构建缓存,由 uv 自行清理
111 未找到 {},无法安全清理
112 {} 清理失败(退出码 {})
113 {} 清理失败(退出码 {}):{}
114 {}:未找到 {},无法安全清理
115 支持的内容先移到废纸篓;工具缓存由对应命令直接清理。
116 支持的缓存可以恢复;工具缓存始终由对应命令直接清理
117 {} {} 英寸
118 {} · {} 个
119 {} · {} 核
@@ -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
1 # Copyright (C) 2026 ysicing
95 cleanip.io 对这次评分的把握:各数据源结论越一致越高
96 macOS 系统目录(系统组件)
97 npm 缓存
98 Go 缓存
99 Rust 缓存
100 uv 缓存
101 Yarn 缓存
102 pnpm 缓存
103 Bun 缓存
104 npm 下载缓存,由 npm 自行清理
105 Yarn 全局与离线镜像缓存,由 Yarn 自行清理
106 pnpm 内容寻址存储,仅清理未被引用的包
107 Bun 下载的包缓存,由 Bun 自行清理
108 Go 编译与完整模块缓存,由 go clean 清理
109 Cargo 注册表与 Git 依赖缓存,需要 cargo-cache
110 uv 下载的 Python 包与构建缓存,由 uv 自行清理
111 未找到 {},无法安全清理
112 {} 清理失败(退出码 {})
113 {} 清理失败(退出码 {}):{}
114 {}:未找到 {},无法安全清理
115 支持的内容先移到废纸篓;工具缓存由对应命令直接清理。
116 支持的缓存可以恢复;工具缓存始终由对应命令直接清理
117 {} {} 英寸
118 {} · {} 个
119 {} · {} 核
@@ -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
1 # Copyright (C) 2026 ysicing
95 cleanip.io 对这次评分的把握:各数据源结论越一致越高
96 macOS 系统目录(系统组件)
97 npm 缓存
98 Go 缓存
99 Rust 缓存
100 uv 缓存
101 Yarn 缓存
102 pnpm 缓存
103 Bun 缓存
104 npm 下载缓存,由 npm 自行清理
105 Yarn 全局与离线镜像缓存,由 Yarn 自行清理
106 pnpm 内容寻址存储,仅清理未被引用的包
107 Bun 下载的包缓存,由 Bun 自行清理
108 Go 编译与完整模块缓存,由 go clean 清理
109 Cargo 注册表与 Git 依赖缓存,需要 cargo-cache
110 uv 下载的 Python 包与构建缓存,由 uv 自行清理
111 未找到 {},无法安全清理
112 {} 清理失败(退出码 {})
113 {} 清理失败(退出码 {}):{}
114 {}:未找到 {},无法安全清理
115 支持的内容先移到废纸篓;工具缓存由对应命令直接清理。
116 支持的缓存可以恢复;工具缓存始终由对应命令直接清理
117 {} {} 英寸
118 {} · {} 个
119 {} · {} 核
@@ -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
1 # Copyright (C) 2026 ysicing
95 cleanip.io 对这次评分的把握:各数据源结论越一致越高
96 macOS 系统目录(系统组件)
97 npm 缓存
98 Go 缓存
99 Rust 缓存
100 uv 缓存
101 Yarn 缓存
102 pnpm 缓存
103 Bun 缓存
104 npm 下载缓存,由 npm 自行清理
105 Yarn 全局与离线镜像缓存,由 Yarn 自行清理
106 pnpm 内容寻址存储,仅清理未被引用的包
107 Bun 下载的包缓存,由 Bun 自行清理
108 Go 编译与完整模块缓存,由 go clean 清理
109 Cargo 注册表与 Git 依赖缓存,需要 cargo-cache
110 uv 下载的 Python 包与构建缓存,由 uv 自行清理
111 未找到 {},无法安全清理
112 {} 清理失败(退出码 {})
113 {} 清理失败(退出码 {}):{}
114 {}:未找到 {},无法安全清理
115 支持的内容先移到废纸篓;工具缓存由对应命令直接清理。
116 支持的缓存可以恢复;工具缓存始终由对应命令直接清理
117 {} {} 英寸
118 {} · {} 个
119 {} · {} 核
@@ -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でクリーンアップします
未找到 {},无法安全清理 {}が見つからないため、安全にクリーンアップできません
{} 清理失败(退出码 {}) {}のクリーンアップに失敗しました(終了コード{})
{} 清理失败(退出码 {}):{} {}のクリーンアップに失敗しました(終了コード{}):{}
{}:未找到 {},无法安全清理 {}:{}が見つからないため、安全にクリーンアップできません
支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 対応する項目は先にゴミ箱へ移動し、ツールキャッシュは各コマンドで直接削除します。
支持的缓存可以恢复;工具缓存始终由对应命令直接清理 対応するキャッシュは復元できますが、ツールキャッシュは常に各コマンドで直接削除します
{} {} 英寸 {} {}インチ
{} · {} 个 {} · {}個
{} · {} 核 {} · {}コア
1 # Copyright (C) 2026 ysicing
95 cleanip.io 对这次评分的把握:各数据源结论越一致越高
96 macOS 系统目录(系统组件)
97 npm 缓存
98 Go 缓存
99 Rust 缓存
100 uv 缓存
101 Yarn 缓存
102 pnpm 缓存
103 Bun 缓存
104 npm 下载缓存,由 npm 自行清理
105 Yarn 全局与离线镜像缓存,由 Yarn 自行清理
106 pnpm 内容寻址存储,仅清理未被引用的包
107 Bun 下载的包缓存,由 Bun 自行清理
108 Go 编译与完整模块缓存,由 go clean 清理
109 Cargo 注册表与 Git 依赖缓存,需要 cargo-cache
110 uv 下载的 Python 包与构建缓存,由 uv 自行清理
111 未找到 {},无法安全清理
112 {} 清理失败(退出码 {})
113 {} 清理失败(退出码 {}):{}
114 {}:未找到 {},无法安全清理
115 支持的内容先移到废纸篓;工具缓存由对应命令直接清理。
116 支持的缓存可以恢复;工具缓存始终由对应命令直接清理
117 {} {} 英寸
118 {} · {} 个
119 {} · {} 核
@@ -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가 직접 정리합니다
未找到 {},无法安全清理 {}을(를) 찾지 못해 안전하게 정리할 수 없습니다
{} 清理失败(退出码 {}) {} 정리에 실패했습니다(종료 코드 {})
{} 清理失败(退出码 {}):{} {} 정리에 실패했습니다(종료 코드 {}): {}
{}:未找到 {},无法安全清理 {}: {}을(를) 찾지 못해 안전하게 정리할 수 없습니다
支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 지원되는 항목은 먼저 휴지통으로 이동하고 도구 캐시는 해당 명령으로 직접 정리합니다.
支持的缓存可以恢复;工具缓存始终由对应命令直接清理 지원되는 캐시는 복원할 수 있으며 도구 캐시는 항상 해당 명령으로 직접 정리합니다
{} {} 英寸 {} {}인치
{} · {} 个 {} · {}개
{} · {} 核 {} · {}코어
1 # Copyright (C) 2026 ysicing
95 cleanip.io 对这次评分的把握:各数据源结论越一致越高
96 macOS 系统目录(系统组件)
97 npm 缓存
98 Go 缓存
99 Rust 缓存
100 uv 缓存
101 Yarn 缓存
102 pnpm 缓存
103 Bun 缓存
104 npm 下载缓存,由 npm 自行清理
105 Yarn 全局与离线镜像缓存,由 Yarn 自行清理
106 pnpm 内容寻址存储,仅清理未被引用的包
107 Bun 下载的包缓存,由 Bun 自行清理
108 Go 编译与完整模块缓存,由 go clean 清理
109 Cargo 注册表与 Git 依赖缓存,需要 cargo-cache
110 uv 下载的 Python 包与构建缓存,由 uv 自行清理
111 未找到 {},无法安全清理
112 {} 清理失败(退出码 {})
113 {} 清理失败(退出码 {}):{}
114 {}:未找到 {},无法安全清理
115 支持的内容先移到废纸篓;工具缓存由对应命令直接清理。
116 支持的缓存可以恢复;工具缓存始终由对应命令直接清理
117 {} {} 英寸
118 {} · {} 个
119 {} · {} 核
@@ -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 自行清理
未找到 {},无法安全清理 未找到 {},無法安全清理
{} 清理失败(退出码 {}) {} 清理失敗(退出碼 {})
{} 清理失败(退出码 {}):{} {} 清理失敗(退出碼 {}):{}
{}:未找到 {},无法安全清理 {}:未找到 {},無法安全清理
支持的内容先移到废纸篓;工具缓存由对应命令直接清理。 支持的內容先移到廢紙簍;工具緩存由對應命令直接清理。
支持的缓存可以恢复;工具缓存始终由对应命令直接清理 支持的緩存可以恢復;工具緩存始終由對應命令直接清理
{} {} 英寸 {} {} 英吋
{} · {} 个 {} · {} 個
{} · {} 核 {} · {} 核
1 # Copyright (c) 2026 GiantAccel, LLC
96 cleanip.io 对这次评分的把握:各数据源结论越一致越高
97 macOS 系统目录(系统组件)
98 npm 缓存
99 Go 缓存
100 Rust 缓存
101 uv 缓存
102 Yarn 缓存
103 pnpm 缓存
104 Bun 缓存
105 npm 下载缓存,由 npm 自行清理
106 Yarn 全局与离线镜像缓存,由 Yarn 自行清理
107 pnpm 内容寻址存储,仅清理未被引用的包
108 Bun 下载的包缓存,由 Bun 自行清理
109 Go 编译与完整模块缓存,由 go clean 清理
110 Cargo 注册表与 Git 依赖缓存,需要 cargo-cache
111 uv 下载的 Python 包与构建缓存,由 uv 自行清理
112 未找到 {},无法安全清理
113 {} 清理失败(退出码 {})
114 {} 清理失败(退出码 {}):{}
115 {}:未找到 {},无法安全清理
116 支持的内容先移到废纸篓;工具缓存由对应命令直接清理。
117 支持的缓存可以恢复;工具缓存始终由对应命令直接清理
118 {} {} 英寸
119 {} · {} 个
120 {} · {} 核
@@ -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
@@ -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() }
@@ -0,0 +1,2 @@
<!-- Simple Icons Bun, CC0-1.0, source commit b86d5c9a0bdd4f3f5c30898a63654dd32f39fd76. -->
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bun</title><path d="M12 22.596c6.628 0 12-4.338 12-9.688 0-3.318-2.057-6.248-5.219-7.986-1.286-.715-2.297-1.357-3.139-1.89C14.058 2.025 13.08 1.404 12 1.404c-1.097 0-2.334.785-3.966 1.821a49.92 49.92 0 0 1-2.816 1.697C2.057 6.66 0 9.59 0 12.908c0 5.35 5.372 9.687 12 9.687v.001ZM10.599 4.715c.334-.759.503-1.58.498-2.409 0-.145.202-.187.23-.029.658 2.783-.902 4.162-2.057 4.624-.124.048-.199-.121-.103-.209a5.763 5.763 0 0 0 1.432-1.977Zm2.058-.102a5.82 5.82 0 0 0-.782-2.306v-.016c-.069-.123.086-.263.185-.172 1.962 2.111 1.307 4.067.556 5.051-.082.103-.23-.003-.189-.126a5.85 5.85 0 0 0 .23-2.431Zm1.776-.561a5.727 5.727 0 0 0-1.612-1.806v-.014c-.112-.085-.024-.274.114-.218 2.595 1.087 2.774 3.18 2.459 4.407a.116.116 0 0 1-.049.071.11.11 0 0 1-.153-.026.122.122 0 0 1-.022-.083 5.891 5.891 0 0 0-.737-2.331Zm-5.087.561c-.617.546-1.282.76-2.063 1-.117 0-.195-.078-.156-.181 1.752-.909 2.376-1.649 2.999-2.778 0 0 .155-.118.188.085 0 .304-.349 1.329-.968 1.874Zm4.945 11.237a2.957 2.957 0 0 1-.937 1.553c-.346.346-.8.565-1.286.62a2.178 2.178 0 0 1-1.327-.62 2.955 2.955 0 0 1-.925-1.553.244.244 0 0 1 .064-.198.234.234 0 0 1 .193-.069h3.965a.226.226 0 0 1 .19.07c.05.053.073.125.063.197Zm-5.458-2.176a1.862 1.862 0 0 1-2.384-.245 1.98 1.98 0 0 1-.233-2.447c.207-.319.503-.566.848-.713a1.84 1.84 0 0 1 1.092-.11c.366.075.703.261.967.531a1.98 1.98 0 0 1 .408 2.114 1.931 1.931 0 0 1-.698.869v.001Zm8.495.005a1.86 1.86 0 0 1-2.381-.253 1.964 1.964 0 0 1-.547-1.366c0-.384.11-.76.32-1.079.207-.319.503-.567.849-.713a1.844 1.844 0 0 1 1.093-.108c.367.076.704.262.968.534a1.98 1.98 0 0 1 .4 2.117 1.932 1.932 0 0 1-.702.868Z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,2 @@
<!-- Simple Icons Go, CC0-1.0, source commit b86d5c9a0bdd4f3f5c30898a63654dd32f39fd76. -->
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Go</title><path d="M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514-.176.046-.187.058-.34-.117-.174-.199-.303-.327-.548-.444-.737-.362-1.45-.257-2.115.175-.795.514-1.204 1.274-1.192 2.22.011.935.654 1.706 1.577 1.835.795.105 1.46-.175 1.987-.77.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35.152-.362.432-.97.596-1.274a.315.315 0 01.292-.187h4.253c-.023.316-.023.631-.07.947a4.983 4.983 0 01-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986-1.145.152-2.209-.07-3.143-.77-.865-.655-1.356-1.52-1.484-2.595-.152-1.274.222-2.419.993-3.424.83-1.086 1.928-1.776 3.272-2.02 1.098-.2 2.15-.07 3.096.571.62.41 1.063.97 1.356 1.648.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.665 3.665 0 01-1.262-2.255c-.21-1.32.152-2.489.947-3.529.853-1.122 1.881-1.706 3.272-1.95 1.192-.21 2.314-.095 3.33.595.923.63 1.496 1.484 1.648 2.605.198 1.578-.257 2.863-1.344 3.962-.771.783-1.718 1.273-2.805 1.495-.315.06-.63.07-.934.106zm2.78-4.72c-.011-.153-.011-.27-.034-.387-.21-1.157-1.274-1.81-2.384-1.554-1.087.245-1.788.935-2.045 2.033-.21.912.234 1.835 1.075 2.21.643.28 1.285.244 1.905-.07.923-.48 1.425-1.228 1.484-2.233z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,2 @@
<!-- Simple Icons npm, CC0-1.0, source commit b86d5c9a0bdd4f3f5c30898a63654dd32f39fd76. -->
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>npm</title><path d="M1.763 0C.786 0 0 .786 0 1.763v20.474C0 23.214.786 24 1.763 24h20.474c.977 0 1.763-.786 1.763-1.763V1.763C24 .786 23.214 0 22.237 0zM5.13 5.323l13.837.019-.009 13.836h-3.464l.01-10.382h-3.456L12.04 19.17H5.113z"/></svg>

After

Width:  |  Height:  |  Size: 410 B

@@ -0,0 +1,2 @@
<!-- Simple Icons pnpm, CC0-1.0, source commit b86d5c9a0bdd4f3f5c30898a63654dd32f39fd76. -->
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>pnpm</title><path d="M0 0v7.5h7.5V0zm8.25 0v7.5h7.498V0zm8.25 0v7.5H24V0zM2 2h3.5v3.5H2zm8.25 0h3.498v3.5H10.25zm8.25 0H22v3.5h-3.5zM8.25 8.25v7.5h7.498v-7.5zm8.25 0v7.5H24v-7.5zm2 2H22v3.5h-3.5zM0 16.5V24h7.5v-7.5zm8.25 0V24h7.498v-7.5zm8.25 0V24H24v-7.5z"/></svg>

After

Width:  |  Height:  |  Size: 437 B

@@ -0,0 +1,2 @@
<!-- Simple Icons Rust, CC0-1.0, source commit b86d5c9a0bdd4f3f5c30898a63654dd32f39fd76. -->
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Rust</title><path d="M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z"/></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,2 @@
<!-- Simple Icons uv, CC0-1.0, source commit b86d5c9a0bdd4f3f5c30898a63654dd32f39fd76. -->
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>uv</title><path d="m0 .1058.0504 11.9496.0403 9.5597c.0055 1.3199 1.08 2.3854 2.4 2.3798l9.5596-.0403 5.9749-.0252.6075-.0026c1.316-.0056 2.3799-1.0963 2.3799-2.4123h1.0946v2.3894L24 23.9042 23.8992.005 12.9056.0513l.0463 9.5245v5.9637h-1.9583L11.04 9.584 10.9936.0594Z"/></svg>

After

Width:  |  Height:  |  Size: 448 B

@@ -0,0 +1,2 @@
<!-- Simple Icons Yarn, CC0-1.0, source commit b86d5c9a0bdd4f3f5c30898a63654dd32f39fd76. -->
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Yarn</title><path d="M12 0C5.375 0 0 5.375 0 12s5.375 12 12 12 12-5.375 12-12S18.625 0 12 0zm.768 4.105c.183 0 .363.053.525.157.125.083.287.185.755 1.154.31-.088.468-.042.551-.019.204.056.366.19.463.375.477.917.542 2.553.334 3.605-.241 1.232-.755 2.029-1.131 2.576.324.329.778.899 1.117 1.825.278.774.31 1.478.273 2.015a5.51 5.51 0 0 0 .602-.329c.593-.366 1.487-.917 2.553-.931.714-.009 1.269.445 1.353 1.103a1.23 1.23 0 0 1-.945 1.362c-.649.158-.95.278-1.821.843-1.232.797-2.539 1.242-3.012 1.39a1.686 1.686 0 0 1-.704.343c-.737.181-3.266.315-3.466.315h-.046c-.783 0-1.214-.241-1.45-.491-.658.329-1.51.19-2.122-.134a1.078 1.078 0 0 1-.58-1.153 1.243 1.243 0 0 1-.153-.195c-.162-.25-.528-.936-.454-1.946.056-.723.556-1.367.88-1.71a5.522 5.522 0 0 1 .408-2.256c.306-.727.885-1.348 1.32-1.737-.32-.537-.644-1.367-.329-2.21.227-.602.412-.936.82-1.08h-.005c.199-.074.389-.153.486-.259a3.418 3.418 0 0 1 2.298-1.103c.037-.093.079-.185.125-.283.31-.658.639-1.029 1.024-1.168a.94.94 0 0 1 .328-.06zm.006.7c-.507.016-1.001 1.519-1.001 1.519s-1.27-.204-2.266.871c-.199.218-.468.334-.746.44-.079.028-.176.023-.417.672-.371.991.625 2.094.625 2.094s-1.186.839-1.626 1.881c-.486 1.144-.338 2.261-.338 2.261s-.843.732-.899 1.487c-.051.663.139 1.2.343 1.515.227.343.51.176.51.176s-.561.653-.037.931c.477.25 1.283.394 1.71-.037.31-.31.371-1.001.486-1.283.028-.065.12.111.209.199.097.093.264.195.264.195s-.755.324-.445 1.066c.102.246.468.403 1.066.398.222-.005 2.664-.139 3.313-.296.375-.088.505-.283.505-.283s1.566-.431 2.998-1.357c.917-.598 1.293-.76 2.034-.936.612-.148.57-1.098-.241-1.084-.839.009-1.575.44-2.196.825-1.163.718-1.742.672-1.742.672l-.018-.032c-.079-.13.371-1.293-.134-2.678-.547-1.515-1.413-1.881-1.344-1.997.297-.5 1.038-1.297 1.334-2.78.176-.899.13-2.377-.269-3.151-.074-.144-.732.241-.732.241s-.616-1.371-.788-1.483a.271.271 0 0 0-.157-.046z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -499,6 +499,16 @@ public final class AppSettings {
public var cleanPrefersTrash: Bool {
didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) }
}
/// 清理页的规则选择;nil 表示用户从未调整过,使用规则默认值。空集合表示用户明确全部取消。
public var cleanSelectedRuleIDs: Set<String>? {
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"
@@ -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<String>
public var selection: Set<String> {
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 }
@@ -26,8 +26,17 @@ private struct FakeHome {
return url
}
func environment(running: Set<String> = []) -> 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<String> = [], 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 {
@@ -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<String> = [
"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
+5 -3
View File
@@ -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
+2 -2
View File
@@ -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 キャッシュ削除とメモリ解放。ヘルパーがなければ管理者認証を求めます。
+2 -2
View File
@@ -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 캐시 비우기와 메모리 확보를 지원합니다. 보조 프로그램이 없으면 관리자 인증을 요청합니다.
+3 -2
View File
@@ -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 缓存、释放内存。已安装辅助工具时直接执行,否则请求一次管理员授权。
+6
View File
@@ -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):
+1 -1
View File
@@ -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` |