diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png index 4cb0ecd..deb3909 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png index e04611f..943fc84 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png index 1378b97..9f1a945 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png index 191f659..ae2205c 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png index e04611f..943fc84 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png index 490e741..1ae4185 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png index 191f659..ae2205c 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png index 5f6df3e..eb647a8 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png index 490e741..1ae4185 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png index e25626a..e353701 100644 Binary files a/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png and b/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/Helper/main.swift b/Helper/main.swift index f3b70f6..3e37284 100644 --- a/Helper/main.swift +++ b/Helper/main.swift @@ -2,7 +2,8 @@ // OpenStatsHelper // // 以 root 运行的辅助工具,由 SMAppService.daemon 注册。 -// 只做三件事:设置风扇转速、切换“合盖不睡眠”、在客户端断开时恢复默认。 +// 只做固定的几件事:设置风扇转速、切换“合盖不睡眠”、刷新 DNS、释放内存, +// 并在客户端断开时恢复风扇与睡眠设置。 // import Foundation @@ -100,10 +101,12 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto } } - func sleepDisabled(reply: @escaping @Sendable (Bool) -> Void) { - queue.async { [self] in - reply(currentSleepDisabled()) - } + func flushDNSCache(reply: @escaping @Sendable (String?) -> Void) { + queue.async { [self] in reply(run(.flushDNS)) } + } + + func purgeMemory(reply: @escaping @Sendable (String?) -> Void) { + queue.async { [self] in reply(run(.purgeMemory)) } } // MARK: 状态恢复 @@ -154,7 +157,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto // MARK: 睡眠 private func applySleepDisabled(_ disabled: Bool) -> String? { - let result = runPmset(["-a", "disablesleep", disabled ? "1" : "0"]) + let result = runTool("/usr/bin/pmset", ["-a", "disablesleep", disabled ? "1" : "0"]) guard result.status == 0 else { return "pmset 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))" } @@ -163,17 +166,22 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto return nil } - private func currentSleepDisabled() -> Bool { - let output = runPmset(["-g"]).output - return output.split(separator: "\n").contains { line in - let parts = line.split(whereSeparator: \.isWhitespace) - return parts.first == "SleepDisabled" && parts.last == "1" + // MARK: 系统维护 + + private func run(_ command: MaintenanceCommand) -> String? { + for step in command.steps { + let result = runTool(step.path, step.arguments) + // killall 在 mDNSResponder 恰好重启时可能返回非 0,不视为失败 + if result.status != 0, step.path != "/usr/bin/killall" { + return "\((step.path as NSString).lastPathComponent) 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))" + } } + return nil } - private func runPmset(_ arguments: [String]) -> (status: Int32, output: String) { + private func runTool(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) { let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/pmset") + process.executableURL = URL(fileURLWithPath: path) process.arguments = arguments let pipe = Pipe() process.standardOutput = pipe diff --git a/Makefile b/Makefile index f531151..15b8fcc 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ CONFIG ?= Debug DERIVED := build/DerivedData APP := $(DERIVED)/Build/Products/$(CONFIG)/OpenStats.app -.PHONY: generate build run stop test snapshot icon open clean +.PHONY: generate build run stop test snapshot open clean ## 由 project.yml 生成 Xcode 工程 generate: @@ -30,10 +30,6 @@ test: snapshot: build $(APP)/Contents/MacOS/OpenStats --snapshot build/snapshots -## 重新生成应用图标 -icon: - swift scripts/make-icon.swift App/Assets.xcassets/AppIcon.appiconset - open: generate open $(PROJECT) diff --git a/Packages/OpenStatsKit/Package.swift b/Packages/OpenStatsKit/Package.swift index 5d0b277..40b51f3 100644 --- a/Packages/OpenStatsKit/Package.swift +++ b/Packages/OpenStatsKit/Package.swift @@ -9,14 +9,17 @@ let package = Package( .library(name: "SMC", targets: ["SMC"]), .library(name: "Metrics", targets: ["Metrics"]), .library(name: "HelperShared", targets: ["HelperShared"]), + .library(name: "Cleaner", targets: ["Cleaner"]), .library(name: "OpenStatsUI", targets: ["OpenStatsUI"]), ], targets: [ .target(name: "SMC"), .target(name: "Metrics", dependencies: ["SMC"]), .target(name: "HelperShared"), - .target(name: "OpenStatsUI", dependencies: ["Metrics", "SMC", "HelperShared"]), + .target(name: "Cleaner"), + .target(name: "OpenStatsUI", dependencies: ["Metrics", "SMC", "HelperShared", "Cleaner"]), .testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]), + .testTarget(name: "CleanerTests", dependencies: ["Cleaner"]), ], swiftLanguageModes: [.v6] ) diff --git a/Packages/OpenStatsKit/Sources/Cleaner/CleanEngine.swift b/Packages/OpenStatsKit/Sources/Cleaner/CleanEngine.swift new file mode 100644 index 0000000..527f07d --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Cleaner/CleanEngine.swift @@ -0,0 +1,161 @@ +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, + 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? { + 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, 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) + } + } +} diff --git a/Packages/OpenStatsKit/Sources/Cleaner/CleanModels.swift b/Packages/OpenStatsKit/Sources/Cleaner/CleanModels.swift new file mode 100644 index 0000000..1afd6c5 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Cleaner/CleanModels.swift @@ -0,0 +1,119 @@ +import Foundation + +public enum CleanCategory: String, Sendable, CaseIterable, Identifiable { + case system, browser, developer, downloads, trash + + public var id: String { rawValue } + + public var title: String { + switch self { + case .system: "系统" + case .browser: "浏览器" + case .developer: "开发者" + case .downloads: "下载" + case .trash: "废纸篓" + } + } +} + +public enum DeletionPolicy: Sendable { + /// 可再生的缓存与日志:直接删除,空间立即释放 + case delete + /// 用户文件(安装包、未完成下载):移到废纸篓,可恢复 + case trash +} + +public enum SkipReason: Sendable, Equatable, Hashable { + case appRunning(String) + case needsFullDiskAccess + case recentlyModified + case protected + + public var title: String { + switch self { + case .appRunning(let name): "\(name) 正在运行" + case .needsFullDiskAccess: "需要完全磁盘访问权限" + case .recentlyModified: "刚刚被使用" + case .protected: "受保护" + } + } +} + +/// 扫描所需的外部环境,便于在测试中替换 +public struct CleanEnvironment: Sendable { + public var home: String + public var runningBundleIdentifiers: @Sendable () -> Set + public var now: @Sendable () -> Date + + public init(home: String = NSHomeDirectory(), + runningBundleIdentifiers: @escaping @Sendable () -> Set, + now: @escaping @Sendable () -> Date = Date.init) { + self.home = home + self.runningBundleIdentifiers = runningBundleIdentifiers + self.now = now + } +} + +public struct CleanRule: Sendable, Identifiable { + public let id: String + public let category: CleanCategory + public let title: String + public let detail: String + public let symbol: String + public let selectedByDefault: Bool + public let policy: DeletionPolicy + /// 这些应用运行时整条规则跳过(例如浏览器) + public let blockingApps: [(bundleID: String, name: String)] + /// 条目名为反向域名(如 com.foo.bar)时,对应应用运行中则跳过该条目 + public let checksOwnerApp: Bool + /// 最近修改过的条目视为正在使用 + public let minimumAge: TimeInterval + /// 列出候选条目(顶层文件或目录) + 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, + locate: @escaping @Sendable (CleanEnvironment) throws -> [URL]) { + self.id = id + self.category = category + self.title = title + self.detail = detail + self.symbol = symbol + self.selectedByDefault = selectedByDefault + self.policy = policy + self.blockingApps = blockingApps + self.checksOwnerApp = checksOwnerApp + self.minimumAge = minimumAge + self.locate = locate + } +} + +public struct CleanItem: Sendable, Identifiable, Hashable { + public var id: String { url.path } + public let url: URL + public let size: UInt64 +} + +public struct RuleScan: Sendable, Identifiable { + public var id: String { rule.id } + public let rule: CleanRule + /// 按大小降序 + public let items: [CleanItem] + public let skippedCount: Int + /// 整条规则被阻止的原因(浏览器运行中、缺少权限) + public let blocked: SkipReason? + + public var totalSize: UInt64 { items.reduce(0) { $0 + $1.size } } + public var isCleanable: Bool { blocked == nil && !items.isEmpty } +} + +public struct CleanReport: Sendable { + public var freedBytes: UInt64 = 0 + public var removedCount = 0 + public var skippedCount = 0 + public var failures: [String] = [] + /// 移到废纸篓的大小(不会立即释放) + public var trashedBytes: UInt64 = 0 +} diff --git a/Packages/OpenStatsKit/Sources/Cleaner/RuleCatalog.swift b/Packages/OpenStatsKit/Sources/Cleaner/RuleCatalog.swift new file mode 100644 index 0000000..93b90c3 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Cleaner/RuleCatalog.swift @@ -0,0 +1,196 @@ +import Foundation + +/// 清理规则。目录清单参考了 Mole 的清理范围(仅参考范围,代码为独立实现)。 +public enum RuleCatalog { + public static func rules() -> [CleanRule] { + [userCaches, logs] + browsers.map(browserRule) + [xcodeDerivedData, simulatorCaches, npmCache, xcodeArchives, + incompleteDownloads, installers, trash] + } + + // MARK: 系统 + + /// 由其他规则单独处理,或清理后会影响系统功能的缓存目录 + static let excludedCacheNames: Set = [ + "Google", "Firefox", "Microsoft Edge", "BraveSoftware", "Arc", + "com.google.Chrome", "com.microsoft.edgemac", "com.brave.Browser", "company.thebrowser.Browser", + "org.mozilla.firefox", "com.apple.Safari", + ] + + static let userCaches = CleanRule( + id: "system.caches", category: .system, title: "应用缓存", + detail: "~/Library/Caches 中各应用的缓存,删除后会按需重建", symbol: "archivebox", + checksOwnerApp: true + ) { env in + try children(of: env.home + "/Library/Caches").filter { url in + let name = url.lastPathComponent + return !name.hasPrefix("com.apple.") && !excludedCacheNames.contains(name) + } + } + + static let logs = CleanRule( + id: "system.logs", category: .system, title: "应用日志与崩溃报告", + detail: "~/Library/Logs 中的日志和诊断报告", symbol: "doc.text", + checksOwnerApp: true + ) { env in + try children(of: env.home + "/Library/Logs") + } + + // MARK: 浏览器 + + struct Browser: Sendable { + let id: String + let name: String + let bundleID: String + /// ~/Library/Caches 下的整个缓存目录 + let cacheDirectories: [String] + /// ~/Library/Application Support 下的用户数据目录(其中的配置目录含 Code Cache 等) + let userDataDirectory: String? + } + + static let browsers: [Browser] = [ + Browser(id: "chrome", name: "Chrome", bundleID: "com.google.Chrome", + cacheDirectories: ["Google/Chrome"], userDataDirectory: "Google/Chrome"), + Browser(id: "edge", name: "Edge", bundleID: "com.microsoft.edgemac", + cacheDirectories: ["Microsoft Edge"], userDataDirectory: "Microsoft Edge"), + Browser(id: "brave", name: "Brave", bundleID: "com.brave.Browser", + cacheDirectories: ["BraveSoftware/Brave-Browser"], userDataDirectory: "BraveSoftware/Brave-Browser"), + Browser(id: "arc", name: "Arc", bundleID: "company.thebrowser.Browser", + cacheDirectories: ["Arc", "company.thebrowser.Browser"], userDataDirectory: "Arc/User Data"), + Browser(id: "firefox", name: "Firefox", bundleID: "org.mozilla.firefox", + cacheDirectories: [], userDataDirectory: nil), + Browser(id: "safari", name: "Safari", bundleID: "com.apple.Safari", + cacheDirectories: ["com.apple.Safari"], userDataDirectory: nil), + ] + + /// Chromium 配置目录内可安全清理的缓存子目录 + static let chromiumProfileCaches = ["Code Cache", "GPUCache", "Service Worker/CacheStorage"] + + static func browserRule(_ browser: Browser) -> CleanRule { + CleanRule( + id: "browser.\(browser.id)", category: .browser, title: "\(browser.name) 缓存", + detail: "网页缓存与脚本缓存,不涉及 Cookie、历史记录和密码", symbol: "globe", + blockingApps: [(browser.bundleID, browser.name)], minimumAge: 0 + ) { env in + let fileManager = FileManager.default + var urls = browser.cacheDirectories + .map { URL(fileURLWithPath: env.home + "/Library/Caches/" + $0) } + // 浏览器缓存目录本身属于 ~/Library/Caches 的子目录,逐个校验可读性 + urls = try urls.filter { url in + guard fileManager.fileExists(atPath: url.path) else { return false } + _ = try children(of: url.path) + return true + } + + if browser.id == "firefox" { + let profiles = (try? children(of: env.home + "/Library/Caches/Firefox/Profiles")) ?? [] + urls += profiles.map { $0.appendingPathComponent("cache2") } + .filter { fileManager.fileExists(atPath: $0.path) } + } + + if let userData = browser.userDataDirectory { + let base = env.home + "/Library/Application Support/" + userData + let profiles = ((try? children(of: base)) ?? []).filter(isChromiumProfile) + for profile in profiles { + urls += chromiumProfileCaches + .map { profile.appendingPathComponent($0) } + .filter { fileManager.fileExists(atPath: $0.path) } + } + } + return urls + } + } + + static func isChromiumProfile(_ url: URL) -> Bool { + let name = url.lastPathComponent + guard name == "Default" || name.hasPrefix("Profile ") || name == "Guest Profile" else { return false } + return FileManager.default.fileExists(atPath: url.appendingPathComponent("Preferences").path) + } + + // MARK: 开发者 + + static let xcodeDerivedData = CleanRule( + id: "developer.derivedData", category: .developer, title: "Xcode 编译缓存", + detail: "DerivedData,下次编译时自动重建", symbol: "hammer", + blockingApps: [("com.apple.dt.Xcode", "Xcode")] + ) { env in + try childrenIfExists(of: env.home + "/Library/Developer/Xcode/DerivedData") + } + + static let simulatorCaches = CleanRule( + id: "developer.simulatorCaches", category: .developer, title: "模拟器缓存", + detail: "CoreSimulator 的动态库与运行时缓存", symbol: "iphone", + blockingApps: [("com.apple.iphonesimulator", "模拟器")] + ) { env in + try childrenIfExists(of: env.home + "/Library/Developer/CoreSimulator/Caches") + } + + static let npmCache = CleanRule( + id: "developer.npm", category: .developer, title: "npm 缓存", + detail: "~/.npm/_cacache,安装依赖时自动重新下载", symbol: "shippingbox" + ) { env in + let path = env.home + "/.npm/_cacache" + return FileManager.default.fileExists(atPath: path) ? [URL(fileURLWithPath: path)] : [] + } + + static let xcodeArchives = CleanRule( + id: "developer.archives", category: .developer, title: "Xcode 归档", + detail: "打包生成的 .xcarchive,删除后无法重新符号化旧版本崩溃日志", symbol: "archivebox.fill", + selectedByDefault: false, policy: .trash, minimumAge: 0 + ) { env in + try childrenIfExists(of: env.home + "/Library/Developer/Xcode/Archives") + } + + // MARK: 下载 + + static let incompleteDownloads = CleanRule( + id: "downloads.incomplete", category: .downloads, title: "未完成的下载", + detail: "超过 1 天未更新的 .crdownload / .part / .download", symbol: "arrow.down.circle", + policy: .trash, minimumAge: 86_400 + ) { env in + try childrenIfExists(of: env.home + "/Downloads") + .filter { ["crdownload", "part", "download"].contains($0.pathExtension.lowercased()) } + } + + static let installers = CleanRule( + id: "downloads.installers", category: .downloads, title: "安装包", + detail: "下载目录中的 .dmg / .pkg / .xip / .iso,移到废纸篓", symbol: "shippingbox.fill", + selectedByDefault: false, policy: .trash, minimumAge: 0 + ) { env in + try childrenIfExists(of: env.home + "/Downloads") + .filter { ["dmg", "pkg", "xip", "iso"].contains($0.pathExtension.lowercased()) } + } + + // MARK: 废纸篓 + + static let trash = CleanRule( + id: "trash", category: .trash, title: "清空废纸篓", + detail: "永久删除废纸篓中的内容,无法恢复", symbol: "trash", + selectedByDefault: false, minimumAge: 0 + ) { env in + try childrenIfExists(of: env.home + "/.Trash") + } + + // MARK: 工具 + + /// 列出目录的直接子项(不含隐藏文件);无权限时抛出 CleanerAccessError + static func children(of path: String) throws -> [URL] { + do { + return try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: path), + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + } catch let error as CocoaError where error.code == .fileReadNoPermission { + throw CleanerAccessError.permissionDenied + } catch let error as NSError where error.domain == NSPOSIXErrorDomain && error.code == Int(EPERM) { + throw CleanerAccessError.permissionDenied + } + } + + static func childrenIfExists(of path: String) throws -> [URL] { + guard FileManager.default.fileExists(atPath: path) else { return [] } + return try children(of: path) + } +} + +public enum CleanerAccessError: Error, Sendable { + case permissionDenied +} diff --git a/Packages/OpenStatsKit/Sources/Cleaner/SafetyGuard.swift b/Packages/OpenStatsKit/Sources/Cleaner/SafetyGuard.swift new file mode 100644 index 0000000..aae698b --- /dev/null +++ b/Packages/OpenStatsKit/Sources/Cleaner/SafetyGuard.swift @@ -0,0 +1,95 @@ +import Foundation + +public enum SafetyError: Error, Sendable, Equatable, CustomStringConvertible { + case notAbsolute + case traversal + case controlCharacter + case outsideAllowedRoots + case isRoot + case protectedName(String) + case applicationSupportNotCache + + public var description: String { + switch self { + case .notAbsolute: "不是绝对路径" + case .traversal: "路径包含 .." + case .controlCharacter: "路径包含控制字符" + case .outsideAllowedRoots: "不在允许清理的目录内" + case .isRoot: "不能删除清理目录本身" + case .protectedName(let name): "受保护的项目:\(name)" + case .applicationSupportNotCache: "Application Support 中只允许清理缓存目录" + } + } +} + +/// 所有删除操作的唯一入口校验。默认拒绝,只放行白名单目录内的条目。 +public struct SafetyGuard: Sendable { + public let home: String + + /// 相对家目录的允许根目录;条目必须位于其内部(不能是根目录本身) + static let allowedRoots = [ + "Library/Caches", + "Library/Logs", + "Library/Application Support", + "Library/Developer/Xcode/DerivedData", + "Library/Developer/Xcode/Archives", + "Library/Developer/CoreSimulator/Caches", + ".npm", + "Downloads", + ".Trash", + ] + + /// Application Support 内只允许删除这些名字的缓存目录(浏览器配置目录下) + static let applicationSupportCacheNames: Set = ["Code Cache", "GPUCache", "CacheStorage", "Cache", "cache2", "ShaderCache", "GrShaderCache"] + + /// 路径任一段包含以下关键词即拒绝(小写匹配) + static let protectedKeywords = [ + "keychain", "1password", "agilebits", "bitwarden", "lastpass", "dashlane", "enpass", "passwords", + "tailscale", "wireguard", "clash", "surge", "shadowsocks", "v2ray", "nordvpn", "expressvpn", "networkextension", + "mobile documents", "cloudkit", "cookies", "login data", "bookmarks", "history", + "openstats", + ] + + public init(home: String = NSHomeDirectory()) { + self.home = (home as NSString).standardizingPath + } + + public func validate(_ url: URL) throws { + let raw = url.path + guard raw.hasPrefix("/") else { throw SafetyError.notAbsolute } + guard !url.pathComponents.contains("..") else { throw SafetyError.traversal } + guard !raw.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) else { + throw SafetyError.controlCharacter + } + try check(path: (raw as NSString).standardizingPath) + // 解析符号链接(含父目录)后再检查一次:只会更严格,不会因此放行 + let resolved = url.resolvingSymlinksInPath().path + if resolved != raw { + try check(path: resolved) + } + } + + private func check(path: String) throws { + let homePrefix = home + "/" + guard path.hasPrefix(homePrefix) else { throw SafetyError.outsideAllowedRoots } + let relative = String(path.dropFirst(homePrefix.count)) + + guard let root = Self.allowedRoots.first(where: { relative == $0 || relative.hasPrefix($0 + "/") }) else { + throw SafetyError.outsideAllowedRoots + } + guard relative != root else { throw SafetyError.isRoot } + + let inside = relative.dropFirst(root.count + 1) + for component in inside.split(separator: "/") { + let lower = component.lowercased() + if let keyword = Self.protectedKeywords.first(where: { lower.contains($0) }) { + throw SafetyError.protectedName(keyword) + } + } + + if root == "Library/Application Support" { + let leaf = (path as NSString).lastPathComponent + guard Self.applicationSupportCacheNames.contains(leaf) else { throw SafetyError.applicationSupportNotCache } + } + } +} diff --git a/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift index 41c4d0a..4d19de1 100644 --- a/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift +++ b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift @@ -5,7 +5,7 @@ public enum HelperConstants { public static let launchdPlistName = "com.openstats.helper.plist" public static let appBundleIdentifier = "com.openstats.app" /// 与 App 版本同步;App 发现辅助工具版本不一致时提示重新安装 - public static let protocolVersion = 1 + public static let protocolVersion = 2 /// 用 Developer ID 签名后填入 Team ID,辅助工具会据此严格校验调用方。 /// 为空时只校验 bundle identifier,仅适用于本地开发。 @@ -26,5 +26,22 @@ public enum HelperConstants { func setFanAutomatic(fan: Int, reply: @escaping @Sendable (String?) -> Void) func resetAllFans(reply: @escaping @Sendable (String?) -> Void) func setSleepDisabled(_ disabled: Bool, reply: @escaping @Sendable (String?) -> Void) - func sleepDisabled(reply: @escaping @Sendable (Bool) -> Void) + func flushDNSCache(reply: @escaping @Sendable (String?) -> Void) + func purgeMemory(reply: @escaping @Sendable (String?) -> Void) +} + +/// 需要管理员权限的系统维护命令。辅助工具与未安装辅助工具时的授权回退共用同一份固定命令 +public enum MaintenanceCommand: String, Sendable, CaseIterable { + case flushDNS, purgeMemory + + public var steps: [(path: String, arguments: [String])] { + switch self { + case .flushDNS: [("/usr/bin/dscacheutil", ["-flushcache"]), ("/usr/bin/killall", ["-HUP", "mDNSResponder"])] + case .purgeMemory: [("/usr/sbin/purge", [])] + } + } + + public var shellCommand: String { + steps.map { ([$0.path] + $0.arguments).joined(separator: " ") }.joined(separator: "; ") + } } diff --git a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift index a855d91..ddaf9cc 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/Formatting.swift @@ -13,8 +13,6 @@ public struct History: Sendable { if elements.count == capacity { elements.removeFirst() } elements.append(element) } - - public var last: Element? { elements.last } } public enum Format { @@ -42,24 +40,6 @@ public enum Format { return "\(amount.formatted(.number.precision(.fractionLength(digits)))) \(units[index])" } - public static func rate(_ bytesPerSecond: Double) -> String { - bytes(bytesPerSecond) + "/s" - } - - /// 菜单栏用的紧凑写法:692K、1.2M - public static func compactRate(_ bytesPerSecond: Double) -> String { - let value = max(0, bytesPerSecond) - switch value { - case ..<1024: return "\(Int(value))B" - case ..<(1024 * 1024): return "\(Int(value / 1024))K" - case ..<(1024 * 1024 * 1024): - let mb = value / 1024 / 1024 - return mb >= 10 ? "\(Int(mb))M" : "\(mb.formatted(.number.precision(.fractionLength(1))))M" - default: - return "\((value / 1024 / 1024 / 1024).formatted(.number.precision(.fractionLength(1))))G" - } - } - /// 菜单栏网速:保留完整单位,数字最多 3 位,宽度稳定 /// 例:512 B/s、12 KB/s、154 KB/s、1.2 MB/s、12 MB/s、1.1 GB/s public static func menuBarRate(_ bytesPerSecond: Double) -> String { diff --git a/Packages/OpenStatsKit/Sources/Metrics/SensorSampler.swift b/Packages/OpenStatsKit/Sources/Metrics/SensorSampler.swift index a1d35fe..89b09a2 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/SensorSampler.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/SensorSampler.swift @@ -12,8 +12,6 @@ public final class SensorSampler { fans = smc.map(FanControl.init) } - public var isAvailable: Bool { smc != nil } - public func sample(groups: Set = Set(TemperatureGroup.allCases), includeFans: Bool = true) -> SensorReadings? { guard let smc else { return nil } var summaries: [TemperatureSummary] = [] diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index 21b24a8..cb7d2dd 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -112,11 +112,7 @@ public final class AppController: NSObject, NSApplicationDelegate { guard let self else { return } self.model.isPanelVisible = visible self.statusItem.button?.highlight(visible) - if visible { - self.model.helper.refreshStatus() - // 首次采样到进程、电池等数据后内容会变高,稍后重新计算一次 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.panel.refreshHeight() } - } + if visible { self.model.helper.refreshStatus() } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift index f07ad29..fd11056 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift @@ -13,6 +13,7 @@ final class StatusPanel: NSPanel { private var localMonitor: Any? private var anchor: NSRect = .zero private weak var anchorScreen: NSScreen? + private var lastDismissal = Date.distantPast /// 面板隐藏时不保留 SwiftUI 视图,避免后台随数据刷新重绘 private let makeContent: () -> NSView @@ -41,7 +42,12 @@ final class StatusPanel: NSPanel { override var canBecomeKey: Bool { true } func toggle(below anchor: NSRect, on screen: NSScreen?) { - isVisible ? dismiss() : present(below: anchor, on: screen) + if isVisible { + dismiss() + } else if Date().timeIntervalSince(lastDismissal) > 0.3 { + // 面板打开时点击菜单栏图标:按下时面板已因失焦关闭,松开时不应再次打开 + present(below: anchor, on: screen) + } } func present(below anchor: NSRect, on screen: NSScreen?) { @@ -61,20 +67,19 @@ final class StatusPanel: NSPanel { onVisibilityChange?(true) } - /// 切换标签页或数据首次到达后重新计算高度,保持顶部对齐 + /// 切换标签页后重新计算高度,保持顶部对齐。 + /// 直接设置而不做窗口动画:动画期间 SwiftUI 会逐帧重排,看起来像抖动 func refreshHeight() { guard isVisible else { return } let frame = targetFrame() guard abs(frame.height - self.frame.height) > 1 else { return } - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - animator().setFrame(frame, display: true) - } + setFrame(frame, display: true) + invalidateShadow() } func dismiss() { guard isVisible, !isPinned else { return } + lastDismissal = Date() removeMonitors() NSAnimationContext.runAnimationGroup({ context in context.duration = 0.1 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift index 530d5c2..61f1383 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Components.swift @@ -53,56 +53,13 @@ extension CardHeader where Trailing == Text { } } -extension CardHeader where Trailing == EmptyView { - init(icon: String, title: String) { - self.icon = icon - self.title = title - self.trailing = EmptyView() - } -} - struct HairlineDivider: View { var body: some View { Rectangle().fill(DS.Palette.border).frame(height: DS.Size.stroke) } } -// MARK: - 数值 - -struct BigValue: View { - let value: String - var unit: String? - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) { - Text(value) - .dsFont(.xxl, weight: .semibold) - .monospacedDigit() - .foregroundStyle(DS.Palette.textPrimary) - if let unit { - Text(unit).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary) - } - } - .lineLimit(1) - } -} - -struct InlineStat: View { - let label: String - let value: String - var alignment: HorizontalAlignment = .leading - - var body: some View { - VStack(alignment: alignment, spacing: 0) { - Text(label).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) - Text(value) - .dsFont(.sm, weight: .semibold) - .monospacedDigit() - .foregroundStyle(DS.Palette.textPrimary) - .lineLimit(1) - } - } -} +// MARK: - 图例 struct LegendItem: View { let color: Color @@ -139,32 +96,6 @@ struct ProgressTrack: View { } } -struct StackedBar: View { - struct Segment: Identifiable { - let id: String - let fraction: Double - let color: Color - } - - let segments: [Segment] - - var body: some View { - GeometryReader { proxy in - HStack(spacing: 0) { - ForEach(segments) { segment in - Rectangle() - .fill(segment.color) - .frame(width: proxy.size.width * min(1, max(0, segment.fraction))) - } - Spacer(minLength: 0) - } - .background(DS.Palette.track) - .clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm)) - } - .frame(height: DS.Size.barHeight) - } -} - // MARK: - 徽标 enum Tone { @@ -343,6 +274,36 @@ struct DSToggle: View { } } +// MARK: - 复选框 + +struct DSCheckbox: View { + let isOn: Bool + let action: () -> Void + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + Button(action: action) { + RoundedRectangle(cornerRadius: DS.Radius.sm) + .fill(isOn ? DS.Palette.primary : Color.clear) + .overlay { + if isOn { + Image(systemName: "checkmark") + .font(.system(size: DS.TextSize.xs.rawValue - DS.Space.s1 / 2, weight: .bold)) + .foregroundStyle(DS.Palette.onPrimary) + } else { + RoundedRectangle(cornerRadius: DS.Radius.sm) + .strokeBorder(DS.Palette.neutral300, lineWidth: DS.Size.chartLine) + } + } + .frame(width: DS.Size.iconInline, height: DS.Size.iconInline) + .opacity(isEnabled ? 1 : 0.4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(isOn ? .isSelected : []) + } +} + // MARK: - 单选行 struct RadioRow: View { @@ -498,7 +459,10 @@ struct PageScroll: View { if isSnapshot { stack } else { - ScrollView { stack }.scrollIndicators(.never) + // 内容放得下时不回弹,避免点击时整页轻微抖动 + ScrollView { stack } + .scrollIndicators(.never) + .scrollBounceBehavior(.basedOnSize) } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index 3ffa84f..d9b6fdb 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -32,7 +32,6 @@ extension DS { static let border = Color.dynamic(light: 0x000000, lightAlpha: 0.07, dark: 0xFFFFFF, darkAlpha: 0.09) static let track = Color.dynamic(light: 0x000000, lightAlpha: 0.06, dark: 0xFFFFFF, darkAlpha: 0.10) static let neutral300 = Color.dynamic(light: 0xD1D5DB, dark: 0x3F434A) - static let neutral400 = Color.dynamic(light: 0x9CA3AF, dark: 0x6B7280) } } @@ -111,7 +110,7 @@ extension DS { static let panelWidth: CGFloat = 640 static let panelMinHeight: CGFloat = 320 static let panelGap: CGFloat = 4 - static let tabsWidth: CGFloat = 280 + static let tabsWidth: CGFloat = 340 /// 三列卡片的单列宽度:(面板宽 - 两侧内边距 - 两个列间距) / 3 static let tileWidth: CGFloat = (panelWidth - Space.s3 * 4) / 3 static let settingsWidth: CGFloat = 720 @@ -129,7 +128,6 @@ extension DS { } static let level1 = Level(color: .black.opacity(0.08), radius: 3, y: 1) - static let level2 = Level(color: .black.opacity(0.10), radius: 12, y: 4) } enum Motion { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/CleanerPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/CleanerPage.swift new file mode 100644 index 0000000..a8bd406 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/CleanerPage.swift @@ -0,0 +1,357 @@ +import Cleaner +import HelperShared +import Metrics +import SwiftUI + +struct CleanerPage: View { + @Environment(AppModel.self) private var model + + var body: some View { + PageScroll { + EqualHeightRow { + SummaryCard().frame(maxWidth: .infinity) + MaintenanceCard().frame(width: DS.Size.tileWidth) + } + if model.cleaner.needsFullDiskAccess { + InfoBanner(icon: "lock.shield", text: "部分项目需要“完全磁盘访问权限”才能扫描(Safari 缓存、废纸篓)。", tone: .warning) { + Button("去授权") { model.cleaner.openFullDiskAccessSettings() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + } + } + HStack(alignment: .top, spacing: DS.Space.s3) { + VStack(spacing: DS.Space.s3) { + CategoryCard(category: .system) + CategoryCard(category: .browser) + } + .frame(maxWidth: .infinity) + VStack(spacing: DS.Space.s3) { + CategoryCard(category: .developer) + CategoryCard(category: .downloads) + CategoryCard(category: .trash) + } + .frame(maxWidth: .infinity) + } + .fixedSize(horizontal: false, vertical: true) + CleanerFooter() + } + .onAppear { model.cleaner.scan(ifNeeded: true) } + } +} + +// MARK: - 汇总 + +private struct SummaryCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + let cleaner = model.cleaner + + Card(padding: DS.Space.s3, spacing: DS.Space.s3) { + HStack(spacing: DS.Space.s1) { + Image(systemName: "sparkles").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold)) + Text("磁盘清理").dsFont(.xs, weight: .semibold) + Spacer() + if let last = cleaner.lastScan { + TimelineView(.periodic(from: .now, by: 60)) { context in + Chip(text: "扫描于 " + Self.relative(last, now: context.date)) + } + } + } + .foregroundStyle(DS.Palette.textSecondary) + + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) { + Text(verbatim: headline) + .dsFont(.xxl, weight: .semibold) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + Text(subline) + .dsFont(.sm) + .foregroundStyle(DS.Palette.textSecondary) + .lineLimit(1) + } + + if let disk = model.store.disk { + VStack(alignment: .leading, spacing: DS.Space.s1) { + ProgressTrack(fraction: disk.usedFraction) + Text(verbatim: "\(disk.volumeName) 可用 \(Format.bytes(disk.available, base: .decimal)) · 共 \(Format.bytes(disk.total, base: .decimal))") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + } + + if cleaner.isConfirming { + InfoBanner(icon: "exclamationmark.triangle.fill", text: confirmationText, tone: .warning) { + HStack(spacing: DS.Space.s2) { + Button("取消") { cleaner.cancelClean() } + .buttonStyle(DSButtonStyle(kind: .secondary)) + Button("确认清理") { cleaner.confirmClean() } + .buttonStyle(DSButtonStyle(kind: .primary)) + } + } + } else { + HStack(spacing: DS.Space.s2) { + Button { cleaner.scan() } label: { + Label(cleaner.phase == .scanning ? "扫描中…" : "重新扫描", systemImage: "arrow.clockwise") + } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .disabled(cleaner.isBusy) + + Button { cleaner.requestClean() } label: { + Label(cleaner.phase == .cleaning ? "清理中…" : "清理所选 \(Format.bytes(cleaner.selectedBytes, base: .decimal))", + systemImage: "trash") + } + .buttonStyle(DSButtonStyle(kind: .primary)) + .disabled(cleaner.isBusy || cleaner.selectedBytes == 0) + Spacer() + } + } + } + } + + private var headline: String { + let cleaner = model.cleaner + if cleaner.phase == .finished { return Format.bytes(cleaner.report?.freedBytes ?? 0, base: .decimal) } + return cleaner.scans.isEmpty ? "扫描中" : Format.bytes(cleaner.totalBytes, base: .decimal) + } + + private var subline: String { + let cleaner = model.cleaner + switch cleaner.phase { + case .idle, .scanning: return "正在计算可清理空间" + case .cleaning: return "正在清理…" + case .ready: return "可清理 · 已选 \(cleaner.selectedScans.count) 项" + case .finished: + guard let report = cleaner.report else { return "" } + var parts = ["已释放"] + if report.trashedBytes > 0 { parts.append("另有 \(Format.bytes(report.trashedBytes, base: .decimal)) 移到废纸篓") } + if report.skippedCount > 0 { parts.append("跳过 \(report.skippedCount) 项") } + if !report.failures.isEmpty { parts.append("\(report.failures.count) 项失败") } + return parts.joined(separator: " · ") + } + } + + private var confirmationText: String { + let cleaner = model.cleaner + let count = cleaner.selectedScans.reduce(0) { $0 + $1.items.count } + let trashNote = model.settings.cleanPrefersTrash ? "所有内容先移到废纸篓。" : "缓存与日志直接删除,下载内容移到废纸篓。" + return "将清理 \(count) 个项目,共 \(Format.bytes(cleaner.selectedBytes, base: .decimal))。\(trashNote)" + } + + private static func relative(_ date: Date, now: Date) -> String { + let minutes = Int(now.timeIntervalSince(date) / 60) + return minutes < 1 ? "刚刚" : "\(Format.duration(minutes: minutes))前" + } +} + +// MARK: - 系统维护 + +private struct MaintenanceCard: View { + @Environment(AppModel.self) private var model + + var body: some View { + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack(spacing: DS.Space.s1) { + Image(systemName: "wrench.and.screwdriver").font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold)) + Text("系统维护").dsFont(.xs, weight: .semibold) + } + .foregroundStyle(DS.Palette.textSecondary) + + MaintenanceButton(command: .flushDNS, icon: "network", title: "刷新 DNS 缓存") + MaintenanceButton(command: .purgeMemory, icon: "memorychip", title: "释放内存") + + Text(model.helper.isReady ? "由辅助工具执行" : "需要管理员授权") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + } +} + +private struct MaintenanceButton: View { + @Environment(AppModel.self) private var model + let command: MaintenanceCommand + let icon: String + let title: String + @State private var hovering = false + + var body: some View { + let maintenance = model.maintenance + let isRunning = maintenance.running == command + let outcome = maintenance.outcomes[command] + + Button { + Task { await maintenance.run(command) } + } label: { + VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { + HStack(spacing: DS.Space.s2) { + Image(systemName: icon) + .font(.system(size: DS.TextSize.sm.rawValue, weight: .medium)) + .frame(width: DS.Size.iconStandalone) + Text(title).dsFont(.sm, weight: .medium) + Spacer(minLength: 0) + if isRunning { + Text("执行中").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + } + .foregroundStyle(DS.Palette.textPrimary) + if let outcome { + Text(outcome.text) + .dsFont(.xs) + .foregroundStyle(outcome.isError ? DS.Palette.error : DS.Palette.success) + .lineLimit(1) + .padding(.leading, DS.Size.iconStandalone + DS.Space.s2) + } + } + .padding(.horizontal, DS.Space.s2) + .padding(.vertical, DS.Space.s2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(hovering ? DS.Palette.surfaceHover : DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.md)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(maintenance.running != nil) + .onHover { hovering = $0 } + } +} + +// MARK: - 分类 + +private struct CategoryCard: View { + @Environment(AppModel.self) private var model + let category: CleanCategory + + var body: some View { + let scans = model.cleaner.scans(in: category) + let total = scans.filter(\.isCleanable).reduce(0) { $0 + $1.totalSize } + + Card(padding: DS.Space.s3, spacing: DS.Space.s2) { + HStack { + Text(category.title).dsFont(.xs, weight: .semibold).foregroundStyle(DS.Palette.textSecondary) + Spacer() + if total > 0 { + Text(verbatim: Format.bytes(total, base: .decimal)) + .dsFont(.xs, weight: .medium) + .monospacedDigit() + .foregroundStyle(DS.Palette.textSecondary) + } + } + if scans.isEmpty { + ForEach(0.. 0 { HairlineDivider() } + RuleRow(scan: scan) + } + } + } + + /// 扫描完成前占位,保持面板高度稳定 + private var placeholderRows: Int { + RuleCatalog.rules().filter { $0.category == category }.count + } +} + +private struct RuleRow: View { + @Environment(AppModel.self) private var model + let scan: RuleScan + @State private var expanded = false + + var body: some View { + let cleaner = model.cleaner + let selected = cleaner.selection.contains(scan.id) && scan.isCleanable + + VStack(alignment: .leading, spacing: DS.Space.s2) { + 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) + VStack(alignment: .leading, spacing: 0) { + Text(scan.rule.title) + .dsFont(.sm, weight: .medium) + .foregroundStyle(scan.blocked == nil ? DS.Palette.textPrimary : DS.Palette.textTertiary) + Text(statusText) + .dsFont(.xs) + .foregroundStyle(scan.blocked == nil ? DS.Palette.textTertiary : DS.Palette.warning) + .lineLimit(1) + } + Spacer(minLength: DS.Space.s1) + if scan.isCleanable { + Text(verbatim: Format.bytes(scan.totalSize, base: .decimal)) + .dsFont(.sm, weight: .medium) + .monospacedDigit() + .foregroundStyle(DS.Palette.textPrimary) + Button { expanded.toggle() } label: { + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold)) + .foregroundStyle(DS.Palette.textTertiary) + .frame(width: DS.Size.iconStandalone, height: DS.Size.iconStandalone) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("查看包含的项目") + } + } + + if expanded { + VStack(alignment: .leading, spacing: DS.Space.s1) { + ForEach(scan.items.prefix(Self.previewLimit)) { item in + Button { cleaner.reveal(item) } label: { + HStack { + Text(item.url.lastPathComponent).dsFont(.xs).lineLimit(1).truncationMode(.middle) + Spacer(minLength: DS.Space.s2) + Text(verbatim: Format.bytes(item.size, base: .decimal)).dsFont(.xs).monospacedDigit() + } + .foregroundStyle(DS.Palette.textSecondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("在访达中显示") + } + if scan.items.count > Self.previewLimit { + Text(verbatim: "另有 \(scan.items.count - Self.previewLimit) 项") + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + } + } + .padding(.leading, DS.Size.iconInline + DS.Size.iconStandalone + DS.Space.s2 * 2) + } + } + } + + private static let previewLimit = 6 + + private var statusText: String { + if let blocked = scan.blocked { return blocked.title } + if scan.items.isEmpty { return "无需清理" } + var text = "\(scan.items.count) 项" + if scan.skippedCount > 0 { text += " · \(scan.skippedCount) 项使用中" } + if scan.rule.policy == .trash { text += " · 移到废纸篓" } + return text + } +} + +// MARK: - 底部 + +private struct CleanerFooter: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var settings = model.settings + + HStack(spacing: DS.Space.s3) { + DSToggle(isOn: $settings.cleanPrefersTrash, label: "缓存也先移到废纸篓") + VStack(alignment: .leading, spacing: 0) { + Text("缓存也先移到废纸篓").dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary) + Text("可以恢复,但清空废纸篓前不会释放空间").dsFont(.xs).foregroundStyle(DS.Palette.textTertiary) + } + Spacer() + Button("查看清理日志") { model.cleaner.revealLog() } + .buttonStyle(DSButtonStyle(kind: .ghost)) + } + .padding(.horizontal, DS.Space.s1) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift index 2e87fbf..df338ef 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift @@ -34,7 +34,7 @@ struct OverviewPage: View { } /// 一行等高卡片 -private struct EqualHeightRow: View { +struct EqualHeightRow: View { @ViewBuilder var content: Content var body: some View { @@ -398,9 +398,10 @@ private struct BatteryCard: View { private struct TopProcessesCard: View { @Environment(AppModel.self) private var model @Environment(\.isSnapshot) private var isSnapshot + private static let rowCount = 5 var body: some View { - let processes = Array(model.store.processes.prefix(5)) + let processes = Array(model.store.processes.prefix(Self.rowCount)) Card(padding: DS.Space.s3, spacing: DS.Space.s2) { HStack(spacing: DS.Space.s1) { @@ -413,8 +414,10 @@ private struct TopProcessesCard: View { } .foregroundStyle(DS.Palette.textSecondary) - if processes.isEmpty { + // 数据到达前用等高占位行,避免面板高度变化 + ForEach(0.. Path { - Path { path in - path.addLines(Self.points.map { CGPoint(x: rect.minX + $0.x * rect.width, y: rect.minY + $0.y * rect.height) }) - } - } -} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index 1f1dc7f..0ce137f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -32,6 +32,9 @@ enum SnapshotRenderer { await model.hub.start { snapshot in model.handle(snapshot) } try? await Task.sleep(for: .seconds(12)) await model.hub.stop() + // 清理页展示真实扫描结果(只读,不删除任何文件) + model.cleaner.scan() + while model.cleaner.isBusy { try? await Task.sleep(for: .milliseconds(200)) } for (appearanceName, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] { guard let appearance = NSAppearance(named: appearanceName) else { continue } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index 59a4a43..dc20fc5 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -13,6 +13,8 @@ public final class AppModel { public let helper: HelperClient public let fans: FanController public let keepAwake: KeepAwakeController + public let cleaner: CleanerController + public let maintenance: MaintenanceController @ObservationIgnored public let hub = MetricsHub() public var isPanelVisible = false @@ -30,6 +32,8 @@ public final class AppModel { self.helper = helper fans = FanController(helper: helper, store: store, settings: settings) keepAwake = KeepAwakeController(helper: helper, settings: settings) + cleaner = CleanerController(settings: settings) + maintenance = MaintenanceController(helper: helper) } /// 根据当前可见内容决定采集范围 @@ -43,7 +47,7 @@ public final class AppModel { demand.memory = true demand.network = true demand.gpu = (panel && tab == .overview) || menu.contains(.gpu) - demand.disk = panel && tab == .overview + demand.disk = panel && (tab == .overview || tab == .cleaner) demand.battery = (panel && (tab == .overview || tab == .keepAwake)) || keepAwake.lidClosedActive demand.processes = panel && (tab == .processes || tab == .overview) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index 3bdef70..171f170 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -86,7 +86,7 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { } public enum PanelTab: String, CaseIterable, Identifiable, Sendable { - case overview, processes, thermal, keepAwake + case overview, processes, thermal, keepAwake, cleaner public var id: String { rawValue } @@ -96,6 +96,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .processes: "进程" case .thermal: "散热" case .keepAwake: "防休眠" + case .cleaner: "清理" } } } @@ -139,6 +140,10 @@ public final class AppSettings { public var appearance: AppearanceMode { didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) } } + /// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间) + public var cleanPrefersTrash: Bool { + didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) } + } public static let refreshOptions = [1, 2, 3, 5] public static let batteryFloorOptions = [10, 20, 30, 40] @@ -163,6 +168,7 @@ public final class AppSettings { ? defaults.integer(forKey: Keys.fanSafetyTemperature) : 95 panelTab = defaults.string(forKey: Keys.panelTab).flatMap(PanelTab.init(rawValue:)) ?? .overview appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system + cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash) } /// 按固定顺序返回已启用的菜单栏项目 @@ -194,5 +200,6 @@ public final class AppSettings { static let fanSafetyTemperature = "fanSafetyTemperature" static let panelTab = "panelTab" static let appearance = "appearance" + static let cleanPrefersTrash = "cleanPrefersTrash" } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanerController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanerController.swift new file mode 100644 index 0000000..1807326 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/CleanerController.swift @@ -0,0 +1,120 @@ +import AppKit +import Cleaner +import Foundation +import Observation + +@MainActor +@Observable +public final class CleanerController { + public enum Phase: Equatable { + case idle, scanning, ready, cleaning, finished + } + + public private(set) var phase: Phase = .idle + public private(set) var scans: [RuleScan] = [] + public private(set) var lastScan: Date? + public private(set) var report: CleanReport? + public var selection: Set + public var isConfirming = false + + @ObservationIgnored private let settings: AppSettings + @ObservationIgnored private let rules = RuleCatalog.rules() + @ObservationIgnored private var task: Task? + + init(settings: AppSettings) { + self.settings = settings + selection = Set(rules.filter(\.selectedByDefault).map(\.id)) + } + + var isBusy: Bool { phase == .scanning || phase == .cleaning } + + func scan(ifNeeded: Bool = false) { + guard !isBusy, !(ifNeeded && lastScan != nil) else { return } + phase = .scanning + isConfirming = false + let environment = Self.environment() + let rules = rules + task = Task { + let results = await CleanEngine.scan(rules, environment: environment) + scans = results + lastScan = Date() + phase = .ready + } + } + + func requestClean() { + guard phase == .ready || phase == .finished, selectedBytes > 0 else { return } + isConfirming = true + } + + func cancelClean() { + isConfirming = false + } + + func confirmClean() { + guard isConfirming else { return } + isConfirming = false + phase = .cleaning + let environment = Self.environment() + let scans = scans + let selection = selection + let preferTrash = settings.cleanPrefersTrash + task = Task { + let result = await CleanEngine.clean(scans, selected: selection, preferTrash: preferTrash, environment: environment) + report = result + phase = .finished + // 清理后重新计算剩余可清理空间 + let rescanned = await CleanEngine.scan(rules, environment: Self.environment()) + self.scans = rescanned + lastScan = Date() + } + } + + func toggle(_ scan: RuleScan) { + guard scan.isCleanable else { return } + if selection.contains(scan.id) { selection.remove(scan.id) } else { selection.insert(scan.id) } + isConfirming = false + } + + var totalBytes: UInt64 { + scans.filter(\.isCleanable).reduce(0) { $0 + $1.totalSize } + } + + var selectedScans: [RuleScan] { + scans.filter { $0.isCleanable && selection.contains($0.id) } + } + + var selectedBytes: UInt64 { + selectedScans.reduce(0) { $0 + $1.totalSize } + } + + var needsFullDiskAccess: Bool { + scans.contains { $0.blocked == .needsFullDiskAccess } + } + + func scans(in category: CleanCategory) -> [RuleScan] { + scans.filter { $0.rule.category == category } + } + + func openFullDiskAccessSettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles") else { return } + NSWorkspace.shared.open(url) + } + + func revealLog() { + let log = CleanLog().url + if FileManager.default.fileExists(atPath: log.path) { + NSWorkspace.shared.activateFileViewerSelecting([log]) + } + } + + func reveal(_ item: CleanItem) { + NSWorkspace.shared.activateFileViewerSelecting([item.url]) + } + + /// 运行中的应用在主线程读取一次后传入后台扫描 + private static func environment() -> CleanEnvironment { + let running = Set(NSWorkspace.shared.runningApplications.compactMap(\.bundleIdentifier)) + return CleanEnvironment(runningBundleIdentifiers: { running }) + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift index 3818611..529d400 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/FanController.swift @@ -41,8 +41,6 @@ public final class FanController { self.settings = settings } - var hasFans: Bool { !(store.sensors?.fans.isEmpty ?? true) } - func select(_ newMode: Mode) async { guard newMode != mode || newMode == .custom else { return } let previous = mode diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift index d119baa..14fb65d 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/HelperClient.swift @@ -104,6 +104,13 @@ public final class HelperClient { await call { proxy, reply in proxy.setSleepDisabled(disabled, reply: reply) } } + func run(_ command: MaintenanceCommand) async -> String? { + switch command { + case .flushDNS: await call { proxy, reply in proxy.flushDNSCache(reply: reply) } + case .purgeMemory: await call { proxy, reply in proxy.purgeMemory(reply: reply) } + } + } + /// 退出应用时使用:同步恢复风扇与睡眠设置,每步最多等待 1 秒 func restoreDefaultsSynchronously() { guard isReady else { return } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift new file mode 100644 index 0000000..c9c1d93 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MaintenanceController.swift @@ -0,0 +1,73 @@ +import Foundation +import HelperShared +import Metrics +import Observation + +/// 刷新 DNS、释放内存:已安装辅助工具时直接执行,否则弹出系统管理员授权 +@MainActor +@Observable +public final class MaintenanceController { + public struct Outcome: Equatable { + let text: String + let isError: Bool + } + + public private(set) var running: MaintenanceCommand? + public private(set) var outcomes: [MaintenanceCommand: Outcome] = [:] + + @ObservationIgnored private let helper: HelperClient + + init(helper: HelperClient) { + self.helper = helper + } + + func run(_ command: MaintenanceCommand) async { + guard running == nil else { return } + running = command + defer { running = nil } + + let before = MemorySampler().sample() + helper.refreshStatus() + let error = helper.isReady ? await helper.run(command) : await Self.runWithAdministratorPrompt(command) + + if let error { + outcomes[command] = Outcome(text: error, isError: error != Self.cancelled) + return + } + switch command { + case .flushDNS: + outcomes[command] = Outcome(text: "DNS 缓存已刷新", isError: false) + case .purgeMemory: + let after = MemorySampler().sample() + let freed = (before?.cached ?? 0) > (after?.cached ?? 0) ? before!.cached - after!.cached : 0 + outcomes[command] = Outcome(text: freed > 0 ? "已释放 \(Format.bytes(freed)) 缓存内存" : "内存已整理", isError: false) + } + } + + nonisolated private static let cancelled = "已取消" + + /// 未安装辅助工具时的回退:通过 AppleScript 请求一次性管理员授权,执行固定命令 + private static func runWithAdministratorPrompt(_ command: MaintenanceCommand) async -> String? { + let prompt = command == .flushDNS ? "OpenStats 需要管理员权限来刷新 DNS 缓存。" : "OpenStats 需要管理员权限来释放内存。" + let script = "do shell script \"\(command.shellCommand)\" with administrator privileges with prompt \"\(prompt)\"" + return await Task.detached { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + let errorPipe = Pipe() + process.standardError = errorPipe + process.standardOutput = Pipe() + do { + try process.run() + } catch { + return error.localizedDescription + } + let data = errorPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus != 0 else { return nil } + let message = String(decoding: data, as: UTF8.self) + // -128:用户在授权对话框中点了取消 + return message.contains("-128") ? cancelled : message.trimmingCharacters(in: .whitespacesAndNewlines) + }.value + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift index 57f5a9e..30a6858 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/MetricsStore.swift @@ -13,8 +13,6 @@ public final class MetricsStore { public private(set) var system = SystemInfoReader.read() public private(set) var cpu: CPULoad? - public private(set) var cpuUser = History(capacity: historyCapacity) - public private(set) var cpuSystem = History(capacity: historyCapacity) public private(set) var cpuTotal = History(capacity: historyCapacity) public private(set) var memory: MemoryUsage? @@ -27,7 +25,8 @@ public final class MetricsStore { public private(set) var gpu: GPUUsage? public private(set) var gpuHistory = History(capacity: historyCapacity) public private(set) var disk: DiskUsage? - public private(set) var battery: BatteryStatus? + /// 启动时读取一次,确保面板首次打开时布局(是否有电池卡片)就已确定 + public private(set) var battery: BatteryStatus? = BatterySampler.sample() public private(set) var processes: [ProcessUsage] = [] public private(set) var sensors: SensorReadings? public private(set) var lastUpdate: Date? @@ -38,8 +37,6 @@ public final class MetricsStore { lastUpdate = snapshot.date if let cpu = snapshot.cpu { self.cpu = cpu - cpuUser.append(cpu.user) - cpuSystem.append(cpu.system) cpuTotal.append(cpu.total) } if let memory = snapshot.memory { diff --git a/Packages/OpenStatsKit/Tests/CleanerTests/CleanerTests.swift b/Packages/OpenStatsKit/Tests/CleanerTests/CleanerTests.swift new file mode 100644 index 0000000..d894f11 --- /dev/null +++ b/Packages/OpenStatsKit/Tests/CleanerTests/CleanerTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing +@testable import Cleaner + +/// 在临时目录中构造一个假的家目录 +private struct FakeHome { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("openstats-cleaner-\(UUID().uuidString)") + .resolvingSymlinksInPath() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + var path: String { root.path } + + @discardableResult + func file(_ relative: String, bytes: Int = 4096, age: TimeInterval = 3600) throws -> URL { + let url = root.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(repeating: 0x41, count: bytes).write(to: url) + let date = Date().addingTimeInterval(-age) + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.deletingLastPathComponent().path) + return url + } + + func environment(running: Set = []) -> CleanEnvironment { + CleanEnvironment(home: path, runningBundleIdentifiers: { running }) + } + + func remove() { try? FileManager.default.removeItem(at: root) } +} + +@Suite("安全守卫") +struct SafetyGuardTests { + let guardian = SafetyGuard(home: "/Users/tester") + + @Test func allowsItemsInsideRoots() throws { + 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")) + } + + @Test func rejectsRootsAndOutsidePaths() { + #expect(throws: SafetyError.isRoot) { try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Caches")) } + #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")) } + } + + @Test func rejectsTraversalAndControlCharacters() { + #expect(throws: SafetyError.traversal) { + try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Caches/../../Documents")) + } + #expect(throws: SafetyError.controlCharacter) { + try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Caches/a\u{0007}b")) + } + } + + @Test func rejectsProtectedNames() { + #expect(throws: SafetyError.protectedName("1password")) { + try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Caches/com.1password.1password")) + } + #expect(throws: SafetyError.protectedName("cookies")) { + try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Caches/Cookies")) + } + } + + @Test func applicationSupportOnlyAllowsCacheFolders() { + #expect(throws: SafetyError.applicationSupportNotCache) { + try guardian.validate(URL(fileURLWithPath: "/Users/tester/Library/Application Support/Google/Chrome/Default")) + } + } + + @Test func rejectsSymlinkEscapingRoots() throws { + let home = try FakeHome() + defer { home.remove() } + let outside = try home.file("Documents/secret.txt") + let link = home.root.appendingPathComponent("Library/Caches/link") + try FileManager.default.createDirectory(at: link.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: outside) + #expect(throws: SafetyError.outsideAllowedRoots) { try SafetyGuard(home: home.path).validate(link) } + } +} + +@Suite("扫描与清理", .serialized) +struct CleanEngineTests { + @Test func scanSkipsRunningAppsAndAppleCaches() async throws { + let home = try FakeHome() + defer { home.remove() } + try home.file("Library/Caches/com.example.idle/data.bin", bytes: 8192) + try home.file("Library/Caches/com.example.busy/data.bin") + try home.file("Library/Caches/com.apple.Music/data.bin") + try home.file("Library/Caches/com.1password.1password/data.bin") + + let scans = await CleanEngine.scan([RuleCatalog.userCaches], environment: home.environment(running: ["com.example.busy"])) + let scan = try #require(scans.first) + #expect(scan.items.map(\.url.lastPathComponent) == ["com.example.idle"]) + #expect(scan.totalSize >= 8192) + #expect(scan.skippedCount == 2) // 运行中的应用 + 受保护的密码管理器;com.apple.* 在列出时即排除 + } + + @Test func recentlyModifiedItemsAreSkipped() async throws { + let home = try FakeHome() + defer { home.remove() } + try home.file("Library/Caches/com.example.fresh/data.bin", age: 5) + + let scans = await CleanEngine.scan([RuleCatalog.userCaches], environment: home.environment()) + #expect(scans.first?.items.isEmpty == true) + #expect(scans.first?.skippedCount == 1) + } + + @Test func browserRuleIsBlockedWhileRunning() async throws { + let home = try FakeHome() + defer { home.remove() } + try home.file("Library/Caches/Google/Chrome/Default/Cache/data") + let chrome = try #require(RuleCatalog.browsers.first { $0.id == "chrome" }).rule + + let blocked = await CleanEngine.scan([chrome], environment: home.environment(running: ["com.google.Chrome"])) + #expect(blocked.first?.blocked == .appRunning("Chrome")) + + let idle = await CleanEngine.scan([chrome], environment: home.environment()) + #expect(idle.first?.blocked == nil) + #expect(idle.first?.items.count == 1) + } + + @Test func cleanDeletesOnlySelectedRules() async throws { + let home = try FakeHome() + defer { home.remove() } + let cache = try home.file("Library/Caches/com.example.app/data.bin") + let log = try home.file("Library/Logs/com.example.app/app.log") + + let environment = home.environment() + let scans = await CleanEngine.scan([RuleCatalog.userCaches, RuleCatalog.logs], environment: environment) + let report = await CleanEngine.clean(scans, selected: [RuleCatalog.userCaches.id], preferTrash: false, + environment: environment, log: nil) + + #expect(report.removedCount == 1) + #expect(report.freedBytes > 0) + #expect(!FileManager.default.fileExists(atPath: cache.deletingLastPathComponent().path)) + #expect(FileManager.default.fileExists(atPath: log.path)) + } + + @Test func cleanRechecksRunningAppsBeforeDeleting() async throws { + let home = try FakeHome() + defer { home.remove() } + let cache = try home.file("Library/Caches/com.example.app/data.bin") + + let scans = await CleanEngine.scan([RuleCatalog.userCaches], environment: home.environment()) + // 扫描后应用启动了 + let report = await CleanEngine.clean(scans, selected: [RuleCatalog.userCaches.id], preferTrash: false, + environment: home.environment(running: ["com.example.app"]), log: nil) + #expect(report.removedCount == 0) + #expect(report.skippedCount == 1) + #expect(FileManager.default.fileExists(atPath: cache.path)) + } +} + +extension RuleCatalog.Browser { + var rule: CleanRule { RuleCatalog.browserRule(self) } +} diff --git a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift index d020b68..b738fe3 100644 --- a/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift +++ b/Packages/OpenStatsKit/Tests/MetricsTests/MetricsTests.swift @@ -52,13 +52,6 @@ struct FormatTests { #expect(Format.bytes(UInt64(412_000_000_000), base: .decimal) == "412 GB") } - @Test func compactRate() { - #expect(Format.compactRate(0) == "0B") - #expect(Format.compactRate(692_486) == "676K") - #expect(Format.compactRate(3.5 * 1024 * 1024) == "3.5M") - #expect(Format.compactRate(42 * 1024 * 1024) == "42M") - } - @Test func menuBarRate() { #expect(Format.menuBarRate(0) == "0 B/s") #expect(Format.menuBarRate(512) == "512 B/s") diff --git a/README.md b/README.md index 981dd54..b945b60 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,15 @@ 安全机制:自定义模式下 CPU 达到设定温度(默认 95°C)自动交还系统控制;应用退出、崩溃或断开连接时风扇自动恢复。 +### 清理与系统维护 + +- **一键扫描**:应用缓存、日志与崩溃报告、浏览器缓存(Chrome / Edge / Brave / Arc / Firefox / Safari)、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包、废纸篓。 +- **先预览再清理**:按类别显示大小,可展开查看具体项目并在访达中定位;清理前二次确认。 +- **安全边界**:只清理白名单目录;钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过;执行前逐项重新校验;所有操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 +- **系统维护**:一键刷新 DNS 缓存、释放内存(已安装辅助工具时直接执行,否则请求一次管理员授权)。 + +

清理

+ ### 设置

设置

@@ -92,9 +101,9 @@ ## 辅助工具与安全 -风扇调速和“合盖后继续运行”需要系统权限,由 `SMAppService.daemon` 注册的辅助工具完成,首次使用时在“系统设置 › 通用 › 登录项”中批准一次。 +风扇调速、“合盖后继续运行”、刷新 DNS 与释放内存需要系统权限,由 `SMAppService.daemon` 注册的辅助工具完成,首次使用时在“系统设置 › 通用 › 登录项”中批准一次。 -- 只暴露固定的几个操作:设置风扇目标转速、恢复自动、切换 `pmset disablesleep`,**不提供任意命令执行**。 +- 只暴露固定的几个操作:设置风扇目标转速、恢复自动、切换 `pmset disablesleep`、刷新 DNS、释放内存,**不提供任意命令执行**。 - 通过 XPC 通信,并校验调用方代码签名。 - 客户端断开时自动恢复风扇与睡眠设置;异常退出后下次开机也会恢复。 @@ -128,11 +137,11 @@ Helper/ root 辅助工具及其 launchd 配置 Packages/OpenStatsKit/ Sources/SMC SMC 读写、风扇控制、温度传感器归类 Sources/Metrics 指标采集与分级调度(MetricsHub) - Sources/HelperShared XPC 协议 + Sources/HelperShared XPC 协议与系统维护命令 + Sources/Cleaner 清理规则、安全守卫、扫描与执行引擎 Sources/OpenStatsUI 设计 token、面板、设置窗口、菜单栏绘制 - Tests/MetricsTests 单元测试 + Tests/ 单元测试(采集、SMC 编解码、清理安全边界) project.yml XcodeGen 工程描述 -scripts/make-icon.swift 生成应用图标 docs/PLAN.md 技术方案与调研记录 ``` @@ -142,6 +151,10 @@ docs/PLAN.md 技术方案与调研记录 - [x] 概览仪表盘、进程、散热、防休眠 - [x] 液态玻璃面板与浅色 / 深色主题 - [x] 风扇调速与合盖运行(辅助工具) +- [x] 缓存清理、刷新 DNS、释放内存 +- [ ] 应用卸载(含残留文件与程序坞图标) +- [ ] 磁盘健康(SSD 寿命、写入量、温度) +- [ ] 点击菜单栏各指标弹出独立详情 - [ ] Developer ID 签名、公证与 DMG 发布 - [ ] Sparkle 自动更新 - [ ] 功耗、磁盘读写等更多指标 diff --git a/docs/images/cleaner-light.png b/docs/images/cleaner-light.png new file mode 100644 index 0000000..f8cf460 Binary files /dev/null and b/docs/images/cleaner-light.png differ diff --git a/docs/images/keepawake-light.png b/docs/images/keepawake-light.png index 82d2474..c022289 100644 Binary files a/docs/images/keepawake-light.png and b/docs/images/keepawake-light.png differ diff --git a/docs/images/menubar-dark.png b/docs/images/menubar-dark.png index 180f27a..3298f93 100644 Binary files a/docs/images/menubar-dark.png and b/docs/images/menubar-dark.png differ diff --git a/docs/images/overview-dark.png b/docs/images/overview-dark.png index 16753b5..fd73c30 100644 Binary files a/docs/images/overview-dark.png and b/docs/images/overview-dark.png differ diff --git a/docs/images/overview-light.png b/docs/images/overview-light.png index 1c80f6a..bf54c47 100644 Binary files a/docs/images/overview-light.png and b/docs/images/overview-light.png differ diff --git a/docs/images/settings-menubar-light.png b/docs/images/settings-menubar-light.png index b6a7c88..65796e8 100644 Binary files a/docs/images/settings-menubar-light.png and b/docs/images/settings-menubar-light.png differ diff --git a/docs/images/thermal-dark.png b/docs/images/thermal-dark.png index cf43011..ca45584 100644 Binary files a/docs/images/thermal-dark.png and b/docs/images/thermal-dark.png differ diff --git a/scripts/make-icon.swift b/scripts/make-icon.swift deleted file mode 100644 index 45c72ac..0000000 --- a/scripts/make-icon.swift +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env swift -// -// 生成 AppIcon.appiconset:纯色圆角底 + 白色脉搏折线(不使用 SF Symbols,避免许可问题) -// 用法:swift scripts/make-icon.swift App/Assets.xcassets/AppIcon.appiconset -// - -import AppKit - -let output = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "App/Assets.xcassets/AppIcon.appiconset") -try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) - -let primary = NSColor(srgbRed: 0x25 / 255.0, green: 0x63 / 255.0, blue: 0xEB / 255.0, alpha: 1) - -/// 与界面中 PulseMark 的折线点一致(单位坐标,原点在左上) -let pulse: [CGPoint] = [ - CGPoint(x: 0.00, y: 0.56), CGPoint(x: 0.26, y: 0.56), CGPoint(x: 0.36, y: 0.30), - CGPoint(x: 0.50, y: 0.80), CGPoint(x: 0.62, y: 0.40), CGPoint(x: 0.70, y: 0.56), - CGPoint(x: 1.00, y: 0.56), -] - -func render(pixels: Int) -> Data { - let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: pixels, pixelsHigh: pixels, - bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, - colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! - let size = CGFloat(pixels) - NSGraphicsContext.saveGraphicsState() - NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) - - // macOS 图标栅格:1024 画布内 824 的圆角矩形 - let inset = size * 100 / 1024 - let body = NSRect(x: inset, y: inset, width: size - inset * 2, height: size - inset * 2) - primary.setFill() - NSBezierPath(roundedRect: body, xRadius: size * 185 / 1024, yRadius: size * 185 / 1024).fill() - - let markInset = body.width * 0.18 - let mark = body.insetBy(dx: markInset, dy: markInset) - let path = NSBezierPath() - for (index, point) in pulse.enumerated() { - let p = NSPoint(x: mark.minX + point.x * mark.width, y: mark.maxY - point.y * mark.height) - index == 0 ? path.move(to: p) : path.line(to: p) - } - path.lineWidth = size * 64 / 1024 - path.lineCapStyle = .round - path.lineJoinStyle = .round - NSColor.white.setStroke() - path.stroke() - - NSGraphicsContext.restoreGraphicsState() - return rep.representation(using: .png, properties: [:])! -} - -var images: [[String: String]] = [] -for points in [16, 32, 128, 256, 512] { - for scale in [1, 2] { - let name = "icon_\(points)x\(points)\(scale == 2 ? "@2x" : "").png" - try render(pixels: points * scale).write(to: output.appendingPathComponent(name)) - images.append(["idiom": "mac", "size": "\(points)x\(points)", "scale": "\(scale)x", "filename": name]) - } -} - -let contents: [String: Any] = ["images": images, "info": ["author": "xcode", "version": 1]] -let json = try JSONSerialization.data(withJSONObject: contents, options: [.prettyPrinted, .sortedKeys]) -try json.write(to: output.appendingPathComponent("Contents.json")) -print("已生成 \(images.count) 张图标到 \(output.path)")