- 清理:应用缓存、日志、浏览器缓存、Xcode/模拟器/npm 缓存、下载残留、安装包、废纸篓; 白名单目录 + 安全守卫,运行中的应用自动跳过,执行前逐项复核,操作日志 - 系统维护:刷新 DNS 缓存、释放内存(辅助工具或管理员授权) - 面板:去掉打开后的二次调整高度与窗口高度动画,内容放得下时不回弹, 修复点击菜单栏图标关闭时被重新打开 - 更换应用图标,面板与关于页使用应用图标作为 Logo - 清理未使用的组件、格式化方法、设计 token 与辅助工具接口
162 lines
7.4 KiB
Swift
162 lines
7.4 KiB
Swift
import Foundation
|
||
|
||
public enum CleanEngine {
|
||
// MARK: 扫描
|
||
|
||
/// 并发扫描所有规则,只读,不修改任何文件
|
||
public static func scan(_ rules: [CleanRule], environment: CleanEnvironment) async -> [RuleScan] {
|
||
let safety = SafetyGuard(home: environment.home)
|
||
return await withTaskGroup(of: (Int, RuleScan).self) { group in
|
||
for (index, rule) in rules.enumerated() {
|
||
group.addTask { (index, scanRule(rule, environment: environment, safety: safety)) }
|
||
}
|
||
var results = [(Int, RuleScan)]()
|
||
for await result in group { results.append(result) }
|
||
return results.sorted { $0.0 < $1.0 }.map(\.1)
|
||
}
|
||
}
|
||
|
||
static func scanRule(_ rule: CleanRule, environment: CleanEnvironment, safety: SafetyGuard) -> RuleScan {
|
||
let running = environment.runningBundleIdentifiers()
|
||
if let app = rule.blockingApps.first(where: { running.contains($0.bundleID) }) {
|
||
return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .appRunning(app.name))
|
||
}
|
||
|
||
let candidates: [URL]
|
||
do {
|
||
candidates = try rule.locate(environment)
|
||
} catch {
|
||
return RuleScan(rule: rule, items: [], skippedCount: 0, blocked: .needsFullDiskAccess)
|
||
}
|
||
|
||
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 {
|
||
skipped += 1
|
||
continue
|
||
}
|
||
let size = allocatedSize(of: url)
|
||
if size > 0 { items.append(CleanItem(url: url, size: size)) }
|
||
}
|
||
items.sort { $0.size > $1.size }
|
||
return RuleScan(rule: rule, items: items, skippedCount: skipped, blocked: nil)
|
||
}
|
||
|
||
static func skipReason(for url: URL, rule: CleanRule, running: Set<String>,
|
||
environment: CleanEnvironment, safety: SafetyGuard) -> SkipReason? {
|
||
guard (try? safety.validate(url)) != nil else { return .protected }
|
||
|
||
if rule.checksOwnerApp, let owner = owningApp(of: url, running: running) {
|
||
return .appRunning(owner)
|
||
}
|
||
if rule.minimumAge > 0,
|
||
let modified = try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate,
|
||
environment.now().timeIntervalSince(modified) < rule.minimumAge {
|
||
return .recentlyModified
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// 条目名为反向域名时,匹配正在运行的应用(名称相同,或是其子标识)
|
||
static func owningApp(of url: URL, running: Set<String>) -> String? {
|
||
let name = url.lastPathComponent
|
||
guard name.split(separator: ".").count >= 3 else { return nil }
|
||
return running.first { name == $0 || name.hasPrefix($0 + ".") }
|
||
}
|
||
|
||
/// 实际占用的磁盘空间;不跟随符号链接
|
||
static func allocatedSize(of url: URL) -> UInt64 {
|
||
let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey]
|
||
guard let values = try? url.resourceValues(forKeys: Set(keys)), values.isSymbolicLink != true else { return 0 }
|
||
if values.isRegularFile == true {
|
||
return UInt64(values.totalFileAllocatedSize ?? values.fileAllocatedSize ?? 0)
|
||
}
|
||
guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: keys, options: [],
|
||
errorHandler: { _, _ in true }) else { return 0 }
|
||
var total: UInt64 = 0
|
||
while let file = enumerator.nextObject() as? URL {
|
||
guard let fileValues = try? file.resourceValues(forKeys: Set(keys)),
|
||
fileValues.isRegularFile == true else { continue }
|
||
total += UInt64(fileValues.totalFileAllocatedSize ?? fileValues.fileAllocatedSize ?? 0)
|
||
}
|
||
return total
|
||
}
|
||
|
||
// MARK: 清理
|
||
|
||
/// 清理选中规则的扫描结果。执行前逐项重新校验:扫描到执行之间应用可能已经启动
|
||
public static func clean(_ scans: [RuleScan], selected: Set<String>, preferTrash: Bool,
|
||
environment: CleanEnvironment, log: CleanLog? = CleanLog()) async -> CleanReport {
|
||
let safety = SafetyGuard(home: environment.home)
|
||
var report = CleanReport()
|
||
|
||
for scan in scans where selected.contains(scan.id) && scan.isCleanable {
|
||
let running = environment.runningBundleIdentifiers()
|
||
if scan.rule.blockingApps.contains(where: { running.contains($0.bundleID) }) {
|
||
report.skippedCount += scan.items.count
|
||
continue
|
||
}
|
||
// 废纸篓规则本身必须永久删除;其他可再生内容按用户偏好决定
|
||
let useTrash = scan.rule.id != RuleCatalog.trash.id && (scan.rule.policy == .trash || preferTrash)
|
||
|
||
for item in scan.items {
|
||
if Task.isCancelled { return report }
|
||
if let reason = skipReason(for: item.url, rule: scan.rule, running: running,
|
||
environment: environment, safety: safety) {
|
||
report.skippedCount += 1
|
||
log?.record(item: item, rule: scan.rule, action: "skip", detail: reason.title)
|
||
continue
|
||
}
|
||
do {
|
||
if useTrash {
|
||
try FileManager.default.trashItem(at: item.url, resultingItemURL: nil)
|
||
report.trashedBytes += item.size
|
||
} else {
|
||
try FileManager.default.removeItem(at: item.url)
|
||
report.freedBytes += item.size
|
||
}
|
||
report.removedCount += 1
|
||
log?.record(item: item, rule: scan.rule, action: useTrash ? "trash" : "delete", detail: nil)
|
||
} catch {
|
||
report.failures.append("\(item.url.lastPathComponent):\(error.localizedDescription)")
|
||
log?.record(item: item, rule: scan.rule, action: "fail", detail: error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
return report
|
||
}
|
||
}
|
||
|
||
/// 清理操作日志:~/Library/Logs/OpenStats/cleanup.log,每行一条 JSON
|
||
public struct CleanLog: Sendable {
|
||
public let url: URL
|
||
|
||
public init(url: URL = URL(fileURLWithPath: NSHomeDirectory() + "/Library/Logs/OpenStats/cleanup.log")) {
|
||
self.url = url
|
||
}
|
||
|
||
func record(item: CleanItem, rule: CleanRule, action: String, detail: String?) {
|
||
var entry: [String: Any] = [
|
||
"time": ISO8601DateFormatter().string(from: Date()),
|
||
"rule": rule.id,
|
||
"action": action,
|
||
"path": item.url.path,
|
||
"bytes": item.size,
|
||
]
|
||
if let detail { entry["detail"] = detail }
|
||
guard var data = try? JSONSerialization.data(withJSONObject: entry, options: [.sortedKeys]) else { return }
|
||
data.append(0x0A)
|
||
|
||
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||
if let handle = try? FileHandle(forWritingTo: url) {
|
||
defer { try? handle.close() }
|
||
_ = try? handle.seekToEnd()
|
||
try? handle.write(contentsOf: data)
|
||
} else {
|
||
try? data.write(to: url)
|
||
}
|
||
}
|
||
}
|