新增清理与系统维护,修复面板抖动,更换应用图标

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

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 761 KiB

+21 -13
View File
@@ -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
+1 -5
View File
@@ -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)
+4 -1
View File
@@ -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]
)
@@ -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<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)
}
}
}
@@ -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<String>
public var now: @Sendable () -> Date
public init(home: String = NSHomeDirectory(),
runningBundleIdentifiers: @escaping @Sendable () -> Set<String>,
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
}
@@ -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<String> = [
"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
}
@@ -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<String> = ["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 }
}
}
}
@@ -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: "; ")
}
}
@@ -13,8 +13,6 @@ public struct History<Element: Sendable>: 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 {
@@ -12,8 +12,6 @@ public final class SensorSampler {
fans = smc.map(FanControl.init)
}
public var isAvailable: Bool { smc != nil }
public func sample(groups: Set<TemperatureGroup> = Set(TemperatureGroup.allCases), includeFans: Bool = true) -> SensorReadings? {
guard let smc else { return nil }
var summaries: [TemperatureSummary] = []
@@ -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() }
}
}
@@ -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
@@ -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<Content: View>: View {
if isSnapshot {
stack
} else {
ScrollView { stack }.scrollIndicators(.never)
// 内容放得下时不回弹,避免点击时整页轻微抖动
ScrollView { stack }
.scrollIndicators(.never)
.scrollBounceBehavior(.basedOnSize)
}
}
}
@@ -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 {
@@ -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..<placeholderRows, id: \.self) { _ in
PlaceholderLine().frame(height: DS.Size.controlHeight)
}
}
ForEach(Array(scans.enumerated()), id: \.element.id) { index, scan in
if index > 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)
}
}
@@ -34,7 +34,7 @@ struct OverviewPage: View {
}
/// 一行等高卡片
private struct EqualHeightRow<Content: View>: View {
struct EqualHeightRow<Content: View>: 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..<max(0, Self.rowCount - processes.count), id: \.self) { _ in
PlaceholderLine()
.frame(height: DS.TextSize.sm.rawValue * 1.25)
}
ForEach(processes) { process in
HStack(spacing: DS.Space.s2) {
@@ -1,3 +1,4 @@
import AppKit
import Metrics
import SwiftUI
@@ -20,6 +21,7 @@ public struct PanelRootView: View {
case .processes: ProcessesPage()
case .thermal: ThermalPage()
case .keepAwake: KeepAwakePage()
case .cleaner: CleanerPage()
}
}
.frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
@@ -83,30 +85,10 @@ struct AppGlyph: View {
var size: CGFloat = DS.Size.controlHeight
var body: some View {
RoundedRectangle(cornerRadius: DS.Radius.md)
.fill(DS.Palette.primary)
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.interpolation(.high)
.frame(width: size, height: size)
.overlay {
PulseMark()
.stroke(DS.Palette.onPrimary,
style: StrokeStyle(lineWidth: size / DS.Space.s12 * DS.Space.s1, lineCap: .round, lineJoin: .round))
.padding(size * 0.22)
}
.accessibilityHidden(true)
}
}
/// 应用标志折线,与 scripts/make-icon.swift 中的点位一致
struct PulseMark: Shape {
static let points: [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 path(in rect: CGRect) -> Path {
Path { path in
path.addLines(Self.points.map { CGPoint(x: rect.minX + $0.x * rect.width, y: rect.minY + $0.y * rect.height) })
}
}
}
@@ -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 }
@@ -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)
@@ -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"
}
}
@@ -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<String>
public var isConfirming = false
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let rules = RuleCatalog.rules()
@ObservationIgnored private var task: Task<Void, Never>?
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 })
}
}
@@ -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
@@ -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 }
@@ -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
}
}
@@ -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<Double>(capacity: historyCapacity)
public private(set) var cpuSystem = History<Double>(capacity: historyCapacity)
public private(set) var cpuTotal = History<Double>(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<Double>(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 {
@@ -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<String> = []) -> 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) }
}
@@ -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")
+18 -5
View File
@@ -70,6 +70,15 @@
安全机制:自定义模式下 CPU 达到设定温度(默认 95°C)自动交还系统控制;应用退出、崩溃或断开连接时风扇自动恢复。
### 清理与系统维护
- **一键扫描**:应用缓存、日志与崩溃报告、浏览器缓存(Chrome / Edge / Brave / Arc / Firefox / Safari)、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包、废纸篓。
- **先预览再清理**:按类别显示大小,可展开查看具体项目并在访达中定位;清理前二次确认。
- **安全边界**:只清理白名单目录;钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过;执行前逐项重新校验;所有操作写入 `~/Library/Logs/OpenStats/cleanup.log`。
- **系统维护**:一键刷新 DNS 缓存、释放内存(已安装辅助工具时直接执行,否则请求一次管理员授权)。
<p><img src="docs/images/cleaner-light.png" width="600" alt="清理"></p>
### 设置
<p><img src="docs/images/settings-menubar-light.png" width="600" alt="设置"></p>
@@ -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 自动更新
- [ ] 功耗、磁盘读写等更多指标
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 208 KiB

-64
View File
@@ -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)")