Files
OpenStats/Packages/OpenStatsKit/Sources/Cleaner/CleanEngine.swift
T
gentpan 1205483b5e 新增清理与系统维护,修复面板抖动,更换应用图标
- 清理:应用缓存、日志、浏览器缓存、Xcode/模拟器/npm 缓存、下载残留、安装包、废纸篓;
  白名单目录 + 安全守卫,运行中的应用自动跳过,执行前逐项复核,操作日志
- 系统维护:刷新 DNS 缓存、释放内存(辅助工具或管理员授权)
- 面板:去掉打开后的二次调整高度与窗口高度动画,内容放得下时不回弹,
  修复点击菜单栏图标关闭时被重新打开
- 更换应用图标,面板与关于页使用应用图标作为 Logo
- 清理未使用的组件、格式化方法、设计 token 与辅助工具接口
2026-09-13 03:03:08 +05:00

162 lines
7.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}