新增清理与系统维护,修复面板抖动,更换应用图标
- 清理:应用缓存、日志、浏览器缓存、Xcode/模拟器/npm 缓存、下载残留、安装包、废纸篓; 白名单目录 + 安全守卫,运行中的应用自动跳过,执行前逐项复核,操作日志 - 系统维护:刷新 DNS 缓存、释放内存(辅助工具或管理员授权) - 面板:去掉打开后的二次调整高度与窗口高度动画,内容放得下时不回弹, 修复点击菜单栏图标关闭时被重新打开 - 更换应用图标,面板与关于页使用应用图标作为 Logo - 清理未使用的组件、格式化方法、设计 token 与辅助工具接口
No files matched your search
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 767 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 767 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 761 KiB |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 自动更新
|
||||
- [ ] 功耗、磁盘读写等更多指标
|
||||
|
||||
|
After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 314 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 208 KiB |
@@ -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)")
|
||||