fix(updates): 避免检查更新触发钥匙串授权
This commit is contained in:
1 parent
4683d80a3e
commit
6f7c898cc6
7 files changed
+57
-455
No files matched your search
+3
-3
@@ -213,9 +213,9 @@ China-region locales prefer `https://x-stats.china.12306.work/api/v1/update/chec
|
||||
`https://xstats-apps.12306.work/api/v1/update/check`. Failures fall back serially to the other endpoint,
|
||||
and the first success stops further requests so one check is not reported twice. Requests use the stable
|
||||
`XStats/<app-version> (macOS <system-version>)` User-Agent format for regional routing and diagnostics.
|
||||
The original 32-byte
|
||||
random value is generated with `SecRandomCopyBytes` and remains in the device-only Keychain; no hardware
|
||||
serial number is used. The response has the same release manifest fields previously read from the static appcast (version,
|
||||
The original 32-byte random value is generated with `SecRandomCopyBytes` and remains in the app's local
|
||||
`UserDefaults`; it is not a credential, is excluded from WebDAV settings sync, and never invokes Keychain
|
||||
authorization. No hardware serial number is used. The response has the same release manifest fields previously read from the static appcast (version,
|
||||
date, notes taken from `CHANGELOG.md` by `Scripts/appcast.py`, zip URL, sha256 and size). An
|
||||
update is installed only after: sha256 matches, the zip holds exactly one `.app`, its bundle ID and
|
||||
version match, `SecStaticCodeCheckValidity` passes with a requirement pinned to the running app's
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
记录 XStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。
|
||||
官网、构建与发布脚本等不影响应用本身的改动不在此记录。
|
||||
|
||||
## 未发布
|
||||
|
||||
### 2026-09-22
|
||||
|
||||
#### 修复
|
||||
|
||||
- 随机安装标识改存本机偏好设置,检查更新不再因读取钥匙串而弹出授权窗口;网络请求仍只发送 SHA-256。
|
||||
|
||||
## 0.7.1 · 2026-09-22
|
||||
|
||||
#### 新增
|
||||
|
||||
@@ -11,35 +11,37 @@ protocol InstallationIDStore {
|
||||
}
|
||||
|
||||
enum InstallationIdentityError: Error, LocalizedError {
|
||||
case keychain(OSStatus)
|
||||
case random(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .keychain(let status):
|
||||
(SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error \(status)"
|
||||
case .random(let status):
|
||||
"Random generator error \(status)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 每次安装首次使用时生成随机值并留存在本机钥匙串;网络侧只使用它的 SHA-256。
|
||||
/// 首次使用时生成随机值并留存在本机偏好设置;它不是凭据,不能因读取而触发钥匙串授权。
|
||||
public struct InstallationIdentity {
|
||||
private let store: any InstallationIDStore
|
||||
private let randomBytes: () throws -> Data
|
||||
|
||||
public init() {
|
||||
self.store = KeychainInstallationIDStore()
|
||||
self.store = UserDefaultsInstallationIDStore(defaults: .standard)
|
||||
self.randomBytes = Self.generateRandomBytes
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults, randomBytes: @escaping () throws -> Data) {
|
||||
self.store = UserDefaultsInstallationIDStore(defaults: defaults)
|
||||
self.randomBytes = randomBytes
|
||||
}
|
||||
|
||||
init(store: any InstallationIDStore, randomBytes: @escaping () throws -> Data) {
|
||||
self.store = store
|
||||
self.randomBytes = randomBytes
|
||||
}
|
||||
|
||||
/// 返回可上报的稳定哈希;随机原值永不离开钥匙串。
|
||||
/// 返回可上报的稳定哈希;随机原值只保存在本机且不随设置同步。
|
||||
public func hashedID() throws -> String {
|
||||
let value: Data
|
||||
if let saved = try store.read() {
|
||||
@@ -61,42 +63,11 @@ public struct InstallationIdentity {
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainInstallationIDStore: InstallationIDStore {
|
||||
private static let service = "work.12306.xstats.installation"
|
||||
private static let account = "installation-id"
|
||||
struct UserDefaultsInstallationIDStore: InstallationIDStore {
|
||||
static let key = "installationIdentitySeed"
|
||||
let defaults: UserDefaults
|
||||
|
||||
init() {}
|
||||
func read() throws -> Data? { defaults.data(forKey: Self.key) }
|
||||
|
||||
func read() throws -> Data? {
|
||||
var query = baseQuery
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw InstallationIdentityError.keychain(status == errSecSuccess ? errSecDecode : status)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func write(_ value: Data) throws {
|
||||
let query = baseQuery
|
||||
var status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: value] as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var item = query
|
||||
item[kSecValueData as String] = value
|
||||
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
status = SecItemAdd(item as CFDictionary, nil)
|
||||
}
|
||||
guard status == errSecSuccess else { throw InstallationIdentityError.keychain(status) }
|
||||
}
|
||||
|
||||
private var baseQuery: [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Self.service,
|
||||
kSecAttrAccount as String: Self.account,
|
||||
]
|
||||
}
|
||||
func write(_ value: Data) throws { defaults.set(value, forKey: Self.key) }
|
||||
}
|
||||
@@ -22,6 +22,24 @@ import Testing
|
||||
#expect(generations == 1)
|
||||
#expect(store.value == Data(0..<32))
|
||||
}
|
||||
|
||||
@Test func userDefaultsPersistsAcrossIdentityInstancesWithoutKeychain() throws {
|
||||
let suite = "InstallationIdentityTests.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
var generations = 0
|
||||
let randomBytes = {
|
||||
generations += 1
|
||||
return Data(0..<32)
|
||||
}
|
||||
|
||||
let first = try InstallationIdentity(defaults: defaults, randomBytes: randomBytes).hashedID()
|
||||
let second = try InstallationIdentity(defaults: defaults, randomBytes: randomBytes).hashedID()
|
||||
|
||||
#expect(first == second)
|
||||
#expect(generations == 1)
|
||||
#expect(defaults.data(forKey: UserDefaultsInstallationIDStore.key) == Data(0..<32))
|
||||
}
|
||||
}
|
||||
|
||||
private final class MemoryInstallationIDStore: InstallationIDStore, @unchecked Sendable {
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ CPU load, GPU, memory pressure, network speed, disk, battery, temperatures and f
|
||||
lets you act on it: spin the fans up, keep the Mac awake with the lid closed, clear caches, fully
|
||||
uninstall apps and disable startup items. Network details also check how clean your public IP is —
|
||||
whether it is flagged as a VPN, proxy, data center or for abuse.
|
||||
All monitoring metrics are read on your own Mac and are never uploaded. No XStats account is required: manually back up and restore preferences through your own WebDAV server. Update checks send the current version and the SHA-256 of a random installation ID for anonymous installation and version-distribution statistics; the original random value remains in the local Keychain. Other optional network features include a public-IP and cleanliness lookup when you open network details, a ping probe to a target you choose, and a daily check for a new version.
|
||||
All monitoring metrics are read on your own Mac and are never uploaded. No XStats account is required: manually back up and restore preferences through your own WebDAV server. Update checks send the current version and the SHA-256 of a random installation ID for anonymous installation and version-distribution statistics; the original random value remains in local preferences, is not a credential, and is excluded from WebDAV sync. Other optional network features include a public-IP and cleanliness lookup when you open network details, a ping probe to a target you choose, and a daily check for a new version.
|
||||
|
||||
[Download](https://github.com/ysicing/xstats/releases) ·
|
||||
[Changelog](CHANGELOG.md) ·
|
||||
@@ -49,7 +49,7 @@ Arabic uses a right-to-left layout.
|
||||
<!-- changelog:start -->
|
||||
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
|
||||
|
||||
Latest release **0.7.1** (2026-09-22) · [full changelog](CHANGELOG.md) (kept in Chinese)
|
||||
Latest release **0.7.1** (2026-09-22) · **1** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
|
||||
|
||||
<!-- changelog:end -->
|
||||
|
||||
@@ -209,7 +209,7 @@ check in Settings → About:
|
||||
`x-stats.china.12306.work`; all others prefer `xstats-apps.12306.work`. The app falls back serially to the other endpoint
|
||||
only after a failure and stops after the first success. The server stores only that
|
||||
hash, the current version, first and last check times, and the check count; it does not store a hardware serial number
|
||||
or persist request IPs (an IP is used only for an in-memory one-minute rate limit). The original random value stays in the local Keychain. Turning off automatic update checks stops these
|
||||
or persist request IPs (an IP is used only for an in-memory one-minute rate limit). The original random value stays in local preferences and does not access Keychain. Turning off automatic update checks stops these
|
||||
automatic requests. When a new version is out it asks; nothing installs without your click.
|
||||
|
||||
WebDAV sync sends only preferences to your configured server. It excludes monitoring data, history and WebDAV credentials.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
XStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各核心 CPU 负载、GPU、内存压力、
|
||||
网速、磁盘、电池、温度和风扇,并且可以直接处理:给风扇提速、合盖后继续运行、清理缓存、彻底卸载应用、停用启动项;
|
||||
网络详情还能检测公网 IP 的纯净度,看出出口是否被标记为 VPN、代理、机房或有滥用记录。
|
||||
所有监控指标都在你自己的 Mac 上读取,不会上传。不需要 XStats 账号,可通过自己的 WebDAV 服务器手动备份和恢复偏好设置。检查更新时会发送当前版本和随机安装标识的 SHA-256,用于匿名安装与版本分布统计;原始随机值只保存在本机钥匙串。其他可关闭的联网功能包括:打开网络详情时查询公网 IP 与纯净度、定时 ping 你选择的探测目标,以及每天检查一次新版本。
|
||||
所有监控指标都在你自己的 Mac 上读取,不会上传。不需要 XStats 账号,可通过自己的 WebDAV 服务器手动备份和恢复偏好设置。检查更新时会发送当前版本和随机安装标识的 SHA-256,用于匿名安装与版本分布统计;原始随机值只保存在本机偏好设置中,不作为凭据,也不参与 WebDAV 同步。其他可关闭的联网功能包括:打开网络详情时查询公网 IP 与纯净度、定时 ping 你选择的探测目标,以及每天检查一次新版本。
|
||||
|
||||
[下载](https://github.com/ysicing/xstats/releases) ·
|
||||
[更新日志](CHANGELOG.md) ·
|
||||
@@ -46,9 +46,18 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登
|
||||
<!-- changelog:start -->
|
||||
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
|
||||
|
||||
最新版本 **0.7.1**(2026-09-22) · [完整更新日志](CHANGELOG.md)
|
||||
最新版本 **0.7.1**(2026-09-22) · 开发中 **1** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
|
||||
|
||||
<details open>
|
||||
<summary><b>2026-09-22</b> · 未发布 · 修复 1</summary>
|
||||
|
||||
**修复**
|
||||
|
||||
- 随机安装标识改存本机偏好设置,检查更新不再因读取钥匙串而弹出授权窗口;网络请求仍只发送 SHA-256。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>2026-09-22</b> · 0.7.1 · 新增 1 · 调整 2</summary>
|
||||
|
||||
**新增**
|
||||
@@ -200,7 +209,7 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登
|
||||
|
||||
- **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地、ASN、网络类型与纯净度评分由这台 Mac 直接向 `cleanip.io` 查询,只发送公网地址、不经过我们的服务器;地址没变、不满 7 天就沿用上次结果,点刷新才重查。
|
||||
- **连接探测**:打开网络详情时每秒(后台每 10 秒)向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。
|
||||
- **检查更新与安装统计**:启动时与之后每天发送当前版本和随机安装标识的 SHA-256,并读取版本清单。中国地区优先使用 `x-stats.china.12306.work`,其他地区优先使用 `xstats-apps.12306.work`,首选失败才串行回退到另一个,首次成功后停止。服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机钥匙串中。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。
|
||||
- **检查更新与安装统计**:启动时与之后每天发送当前版本和随机安装标识的 SHA-256,并读取版本清单。中国地区优先使用 `x-stats.china.12306.work`,其他地区优先使用 `xstats-apps.12306.work`,首选失败才串行回退到另一个,首次成功后停止。服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机偏好设置中,不访问钥匙串。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。
|
||||
|
||||
WebDAV 只向你配置的服务器传输偏好设置,不上传监控数据、历史记录或 WebDAV 凭据。
|
||||
|
||||
|
||||
-404
@@ -1,404 +0,0 @@
|
||||
# XStats 方案:macOS 菜单栏监控 + 系统清理
|
||||
|
||||
> 调研日期:2026-09-13 | 开发机:macOS 27.0 / Xcode 27 / Swift 6.4 / Apple Silicon
|
||||
|
||||
## ⚠️ 范围调整(2026-09-13,已实现)
|
||||
|
||||
以下原始方案中的**清理功能已取消**,第 5 节及相关里程碑仅作存档。当前实现的范围:
|
||||
|
||||
| 模块 | 状态 |
|
||||
|---|---|
|
||||
| 菜单栏:CPU / GPU / 内存(数字、柱状图、圆环、饼图、柱状+数字五种样式)、网速两行、温度、风扇 | ✅ |
|
||||
| 面板:概览仪表盘(健康评分、三列指标卡、核心负载、电池、高占用进程、快捷开关)、进程、散热、防休眠 | ✅ |
|
||||
| 液态玻璃面板(macOS 26+),浅色 / 深色 / 跟随系统 | ✅ |
|
||||
| 风扇调速(自动 / 降温 / 强冷 / 自定义),CPU 过热自动交还系统 | ✅ 需安装辅助工具 |
|
||||
| 防休眠:屏幕常亮、仅系统不休眠、合盖后继续运行(电量保护、定时) | ✅ 合盖模式需辅助工具 |
|
||||
| 温度传感器:启动时枚举 SMC 键按前缀归类,不硬编码芯片型号 | ✅ |
|
||||
| Developer ID 签名、公证、Sparkle 自动更新 | ⏳ 下一阶段 |
|
||||
|
||||
工程结构与运行方式见根目录 [README.md](../README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 语言 / UI | Swift 6 + AppKit(`NSStatusItem`)+ SwiftUI(面板、设置页) | 菜单栏图标自绘需要 AppKit,面板用 SwiftUI 开发效率高 |
|
||||
| 最低系统 | macOS 14 Sonoma | 可用 `@Observable`、`SMAppService`、`Task.sleep(for:tolerance:)` |
|
||||
| 监控层 | 参考并部分移植 **exelban/stats**(MIT) | 许可证允许,保留版权声明即可 |
|
||||
| 清理层 | 参考 **tw93/Mole** 的**规则与安全模型**,用 Swift **自己重写** | Mole 是 **GPL-3.0**,不能直接复制代码进非 GPL 项目(详见 §1) |
|
||||
| 沙盒 | **不沙盒**,不上 Mac App Store | 清理要访问 `~/Library` 各处,温度传感器要走 IOKit/SMC,沙盒内做不到 |
|
||||
| 分发 | Developer ID 签名 + 公证(notarize)+ Sparkle 2 自动更新 + Homebrew Cask | Stats 与 Mole 都是这条路 |
|
||||
| 工程组织 | Xcode App 工程 + 本地 Swift Package(多 target) | 采集、清理、UI 分离,便于单测 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 参考项目核查(有几处需要纠正)
|
||||
|
||||
| 项目 | 核查结果 | 能怎么用 |
|
||||
|---|---|---|
|
||||
| [exelban/stats](https://github.com/exelban/stats) | MIT,Swift,约 4.2 万 Star,2026-09 仍在更新 | ✅ 可移植代码,需在 `LICENSE`/致谢页保留 `Copyright © Serhiy Mytrovtsiy`。注意作者声明“开源但不接受贡献” |
|
||||
| [tw93/Mole](https://github.com/tw93/Mole) | **GPL-3.0**(不是 MIT),Shell + Go,约 6.7 万 Star;另有 `TRADEMARK.md` 商标声明;它的 GUI 版“Mole for Mac”是闭源商业产品 | ⚠️ 不能把它的脚本/Go 代码搬进 MIT 或闭源项目;可以参考思路、清理目录清单、安全设计(这些是事实和设计,不是代码表达)。不能用 “Mole” 名称 |
|
||||
| mhaeuser/Battery-Toolkit | BSD-3-Clause,Swift | ✅ 参考特权辅助进程(Privileged Helper)+ XPC 的规范写法 |
|
||||
| Sam-Spencer/bar-charts | **GitHub 上不存在**(API 返回 404) | ❌ 忽略。图表直接用系统自带 Swift Charts 即可 |
|
||||
| 补充:vladkens/macmon | MIT,Rust | ✅ Apple Silicon 下无需 sudo 读功耗 / GPU 的 `IOReport` 用法参考 |
|
||||
|
||||
**关于 Mole 的两条路**
|
||||
|
||||
- **A(推荐):项目用 MIT,清理模块自己用 Swift 实现。** 只把 Mole 当“需求说明书”:看它清哪些目录、有哪些保护规则、踩过哪些坑(`SECURITY_AUDIT.md` 很有价值),然后自己写。
|
||||
- B:项目整体采用 GPL-3.0,就可以直接移植 Mole 的逻辑,甚至打包调用 `mo` CLI。代价是整个 App 必须开源为 GPL,以后无法闭源或商业授权;而且调用 Shell 脚本在 GUI 里做进度、取消、错误处理都很别扭。
|
||||
|
||||
---
|
||||
|
||||
## 2. 功能范围
|
||||
|
||||
### MVP(第 1 阶段)
|
||||
- 菜单栏:CPU 百分比 + 迷你折线(可选显示内存、网速)
|
||||
- 弹出面板:CPU 总量 / 用户态 / 内核态、每核心(区分 P 核 / E 核)、Top 5 进程;内存(App / 联动 / 压缩 / 内存压力);实时网速
|
||||
- 清理(仅用户态,无需管理员密码):扫描 → 分类预览 → 勾选 → **移到废纸篓** → 记录日志
|
||||
- 设置:刷新频率、菜单栏显示项、开机自启(`SMAppService.mainApp`)
|
||||
|
||||
### V1(第 2 阶段)
|
||||
- GPU 使用率、温度、功耗、风扇转速、电池健康度、磁盘读写与剩余空间
|
||||
- 清理扩展:开发者缓存(Xcode / npm / pnpm / Homebrew…)、项目构建产物(`node_modules`、`target`)、安装包(`.dmg/.pkg`)、大文件查找
|
||||
- 应用卸载(连带清理偏好设置、缓存、LaunchAgent)
|
||||
- 白名单、历史记录、通知(CPU 持续过高、磁盘将满)
|
||||
|
||||
### V2(第 3 阶段)
|
||||
- 需要 root 的操作:刷新 DNS、系统日志清理、Time Machine 本地快照 → 通过 `SMAppService.daemon` 安装特权辅助进程 + XPC
|
||||
- 磁盘占用可视化(类似 `mo analyze`,树图)
|
||||
- 桌面小组件(WidgetKit)
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
```
|
||||
┌──────────────────────────── XStats.app(主进程,非沙盒)────────────────────────────┐
|
||||
│ │
|
||||
│ UI 层(@MainActor) │
|
||||
│ ├─ StatusItemController NSStatusItem,自绘 NSImage(数字 + 迷你图),不用 SwiftUI 重建 │
|
||||
│ ├─ PopoverPanel NSPanel/NSPopover 承载 SwiftUI:监控页 / 清理页 / 设置入口 │
|
||||
│ └─ SettingsWindow SwiftUI Settings │
|
||||
│ ▲ @Observable 状态(只存“最新值 + 环形缓冲历史”) │
|
||||
│ │ │
|
||||
│ 调度层(actor MetricsHub) │
|
||||
│ ├─ 分级:menuBar 档(2s,只跑 CPU 总量/网速) popover 档(1s,全量 + 进程列表) │
|
||||
│ ├─ 暂停:锁屏 / 屏幕休眠 / 系统睡眠时停止;低电量模式下降频 │
|
||||
│ └─ Samplers:CPU / Memory / Network / GPU / Sensors / Battery / Disk / Process │
|
||||
│ │
|
||||
│ 清理层(actor CleanEngine) │
|
||||
│ ├─ RuleCatalog 清理规则(数据化,Swift 静态表或 JSON) │
|
||||
│ ├─ Scanner 并发扫描计算大小(只读,可取消) │
|
||||
│ ├─ SafetyGuard 路径校验 / 保护名单 / 符号链接 / 运行中应用检测 / 白名单 │
|
||||
│ ├─ Executor trashItem(默认)或 removeItem(用户显式选择) │
|
||||
│ └─ OperationLog ~/Library/Logs/XStats/operations.jsonl │
|
||||
│ │
|
||||
└──────────────────────────────────────┬────────────────────────────────────────────────┘
|
||||
│ XPC(NSXPCConnection,V2 才需要)
|
||||
┌────────────▼─────────────┐
|
||||
│ PrivilegedHelper(root) │ SMAppService.daemon 注册
|
||||
│ 只接受白名单内的固定操作 │ 校验调用方签名(Team ID)
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### 工程目录
|
||||
|
||||
```
|
||||
xstats/
|
||||
├─ XStats.xcodeproj
|
||||
├─ App/ # App target:AppDelegate、StatusItem、窗口管理
|
||||
├─ Packages/XStatsKit/ # 本地 Swift Package
|
||||
│ ├─ Sources/
|
||||
│ │ ├─ Metrics/ # 各 Sampler + MetricsHub(纯逻辑,可单测)
|
||||
│ │ ├─ SMCBridge/ # C/ObjC 桥接:SMC、IOHIDEventSystemClient、IOReport
|
||||
│ │ ├─ Cleaner/ # RuleCatalog、Scanner、SafetyGuard、Executor
|
||||
│ │ ├─ DesignSystem/ # 颜色 / 字号 / 间距 token
|
||||
│ │ └─ Features/ # SwiftUI 页面:MonitorView、CleanView、SettingsView
|
||||
│ └─ Tests/
|
||||
├─ Helper/ # V2:特权辅助进程
|
||||
├─ ThirdPartyNotices.md # Stats 的 MIT 版权声明
|
||||
└─ docs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 监控层:每个指标从哪里读
|
||||
|
||||
| 指标 | API | 要点 / 坑 |
|
||||
|---|---|---|
|
||||
| CPU 总量 & 每核 | `host_processor_info(PROCESSOR_CPU_LOAD_INFO)`,两次采样取差值 | 返回的数组是内核分配的,**每次必须 `vm_deallocate`**,否则持续泄漏;计数器用 `&-` 防回绕 |
|
||||
| P 核 / E 核划分 | `sysctl hw.perflevel0.logicalcpu`(P)/ `hw.perflevel1.logicalcpu`(E) | 核心编号顺序:Apple Silicon 上 E 核通常在前,按 perflevel 数量切分 |
|
||||
| 内存 | `host_statistics64(HOST_VM_INFO64)` | 与活动监视器同口径:App = `internal - purgeable`,已用 = App + wired + compressed;页大小用 `host_page_size()`(Apple Silicon 为 16K) |
|
||||
| 内存压力 | `sysctl kern.memorystatus_vm_pressure_level`(1/2/4) | 需要事件时可叠加 `DispatchSource.makeMemoryPressureSource` |
|
||||
| 网速 | `sysctl(NET_RT_IFLIST2)` → `if_msghdr2.ifm_data`(`if_data64`) | ⚠️ 纠正原文:`getifaddrs` 里的 `if_data.ifi_ibytes` 是 **32 位**,流量超过 4 GiB 会回绕,应改用 64 位计数器 |
|
||||
| 进程列表 | `proc_listallpids` + `proc_pid_rusage(RUSAGE_INFO_V4)` / `proc_pidinfo(PROC_PIDTASKINFO)` | 只在面板展开时跑;CPU% 同样靠两次 `ri_user_time + ri_system_time` 差值 |
|
||||
| GPU 使用率 | `IOServiceMatching("IOAccelerator")` → `PerformanceStatistics["Device Utilization %"]` | Stats 的 `Modules/GPU/reader.swift` 可直接参考 |
|
||||
| 温度(Apple Silicon) | `IOHIDEventSystemClient` 私有 API(`kIOHIDEventTypeTemperature`)+ SMC 键 | 私有 API,需 ObjC/C 桥接头;参考 Stats `Modules/Sensors/reader.m` |
|
||||
| 功耗 / GPU 频率 | `IOReport`(`libIOReport.dylib`,“Energy Model”、“GPU Stats” 通道) | 无需 sudo;参考 macmon |
|
||||
| 风扇 | SMC 键 `FNum`、`F0Ac` | 读取无需 root;**写入(调速)需要 root helper**,建议不做或放到最后 |
|
||||
| 电池 | `IOPSCopyPowerSourcesInfo` + `IOServiceMatching("AppleSmartBattery")` | 循环次数、设计容量、当前最大容量 |
|
||||
| 磁盘空间 | `URLResourceValues.volumeAvailableCapacityForImportantUsage` | 比 `statfs` 更接近 Finder 显示(含可清除空间) |
|
||||
| 磁盘读写 | `IOBlockStorageDriver` 的 `Statistics` 字典 | 两次采样取差值 |
|
||||
|
||||
### 功耗预算(硬指标)
|
||||
|
||||
| 场景 | 目标 |
|
||||
|---|---|
|
||||
| 面板关闭,只显示 CPU | 平均 CPU < 0.3%,常驻内存 < 40 MB |
|
||||
| 面板展开,全量 | 平均 CPU < 2% |
|
||||
| 锁屏 / 屏幕休眠 | 采样完全停止 |
|
||||
|
||||
实现手段:
|
||||
1. **分级采样**:面板关闭时只跑 CPU 总量和网速;进程列表、温度、GPU 仅面板展开时启用(温度传感器是 Stats 里最耗电的模块)。
|
||||
2. **定时器合并**:`Task.sleep(for:tolerance:)` 给 0.5s 容差,让系统合并唤醒。
|
||||
3. **菜单栏自绘**:每次刷新只重画一张小 `NSImage` 赋给 `statusItem.button.image`,不在菜单栏里挂 SwiftUI 视图(后者每秒重建开销大)。
|
||||
4. **历史数据用固定大小环形缓冲**(例如 60 个点),不做持久化数据库。
|
||||
5. 监听 `NSWorkspace.screensDidSleepNotification` / `willSleepNotification` / `com.apple.screenIsLocked` 暂停;`ProcessInfo.isLowPowerModeEnabled` 时降到 5s。
|
||||
6. 用 Instruments 的 **Energy Log / Time Profiler** 与活动监视器“能耗影响”列做回归测试。
|
||||
|
||||
---
|
||||
|
||||
## 5. 清理层设计(参考 Mole,Swift 重写)
|
||||
|
||||
### 5.1 流程
|
||||
|
||||
```
|
||||
扫描(只读、并发、可取消)→ 分类展示大小 → 用户勾选 → 二次确认 → SafetyGuard 逐项再校验
|
||||
→ 移到废纸篓(默认)/ 永久删除(需用户显式开启)→ 写操作日志 → 显示释放空间
|
||||
```
|
||||
|
||||
所有删除只走 **一个出口函数**(对应 Mole 的 `safe_remove`),便于审计和测试。
|
||||
|
||||
### 5.2 规则数据化
|
||||
|
||||
```swift
|
||||
struct CleanRule: Identifiable, Sendable {
|
||||
enum Risk: Sendable { case safe, review } // safe 默认勾选,review 默认不勾选
|
||||
enum Requirement: Sendable { case none, fullDiskAccess, admin }
|
||||
|
||||
let id: String // "user.caches"
|
||||
let category: Category // .system / .browser / .developer / .apps / .downloads
|
||||
let title: LocalizedStringResource
|
||||
let paths: [PathPattern] // ~/Library/Caches/*、~/Downloads/*.crdownload
|
||||
let minAge: Duration? // 如 7 天内修改过的跳过
|
||||
let skipIfAppRunning: [String] // bundle id,运行中则跳过
|
||||
let risk: Risk
|
||||
let requirement: Requirement
|
||||
let postAction: PostAction? // 例如调用 `xcrun simctl delete unavailable`
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 首批规则(参考 Mole 实际清理的目录整理)
|
||||
|
||||
| 分类 | 目录 / 操作 | 风险 | 备注 |
|
||||
|---|---|---|---|
|
||||
| 系统 | `~/Library/Caches/*` | safe | **所属应用正在运行则跳过**(Mole 踩过坑:删除正在使用的 SQLite 缓存会让进程写满磁盘) |
|
||||
| 系统 | `~/Library/Logs/*`、`~/Library/Application Support/CrashReporter` | safe | |
|
||||
| 系统 | `~/.Trash` | review | 调用 Finder 清空更稳妥 |
|
||||
| 浏览器 | Chrome / Edge / Brave / Arc / Firefox 的 **Cache、Code Cache、GPUCache** 子目录 | safe | ❌ 绝不碰 Cookies、History、Login Data;浏览器运行中跳过 |
|
||||
| 浏览器 | Chromium 系旧版本目录(`…/Versions/<旧版本>`) | safe | |
|
||||
| 下载 | `~/Downloads/*.crdownload`、`*.part`、`*.download` | safe | 用 `lsof` 或文件修改时间确认未在下载 |
|
||||
| 下载 | `~/Downloads/*.dmg / *.pkg / *.xip` | review | 列出让用户选 |
|
||||
| 邮件 | `~/Library/Containers/com.apple.mail/Data/Library/Mail Downloads` | safe | 需要完全磁盘访问权限 |
|
||||
| 开发 | `~/Library/Developer/Xcode/DerivedData` | safe | |
|
||||
| 开发 | `~/Library/Developer/Xcode/Archives`、iOS DeviceSupport | review | |
|
||||
| 开发 | 模拟器:`xcrun simctl delete unavailable` | safe | 调用命令而不是直接删目录 |
|
||||
| 开发 | npm `~/.npm/_cacache`、pnpm `~/Library/pnpm/store`、yarn、pip `~/Library/Caches/pip`、Go `go clean -modcache`、Homebrew `brew cleanup` | safe | 优先调用各工具自带清理命令 |
|
||||
| 开发 | Maven `~/.m2/repository`、Gradle `~/.gradle/caches`、NuGet、conda pkgs | review | 删除后需重新下载 |
|
||||
| 项目产物 | 用户指定目录下的 `node_modules`、`target`、`build`、`dist`、`.next` | review | 7 天内修改过的标记为“最近使用”;避免父子目录重复删除 |
|
||||
| 云盘 | Dropbox / Google Drive / OneDrive 缓存 | review | 客户端运行中跳过 |
|
||||
| 备份 | `~/Library/Application Support/MobileSync/Backup` | review | **只提示,不默认勾选** |
|
||||
|
||||
### 5.4 SafetyGuard 规则(照搬 Mole 的安全边界思路)
|
||||
|
||||
1. 拒绝:空路径、相对路径、含 `..` 路径段、含控制字符的路径。
|
||||
2. 保护前缀永不删除:`/`、`/System`、`/bin`、`/sbin`、`/usr`、`/etc`、`/var`、`/private`、`/Library/Extensions`;仅放行 `/private/tmp`、`/private/var/folders` 等少数子路径。
|
||||
3. **符号链接**:解析后的真实路径也要过一遍拒绝规则,而且解析结果只能“更严格”不能“放行”;父目录是符号链接也要检查。
|
||||
4. 保护类别:钥匙串、密码管理器、VPN/代理工具(Clash、Tailscale、WireGuard…)、AI 工具数据(Claude、ChatGPT、Cursor、Ollama)、浏览器 Cookie/历史、iCloud `Mobile Documents`、`~/Library/Messages`、Apple 自带 Group Containers、用户自己的 `~/Library/LaunchAgents/*.plist`。
|
||||
5. 执行前再次校验(扫描和删除之间可能过了几分钟,应用可能已启动)。
|
||||
6. 判断不了就**跳过**,不扩大删除范围。
|
||||
|
||||
### 5.5 权限
|
||||
|
||||
- **完全磁盘访问(FDA)**:尝试读取受 TCC 保护的文件(如 `~/Library/Safari/Bookmarks.plist`)判断是否已授权;未授权时引导打开 `x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`。没有 FDA 时对应规则显示为“需要授权”,不报错。
|
||||
- **管理员权限**(V2):`SMAppService.daemon(plistName:)` 注册 helper,XPC 协议只暴露固定的枚举操作(如 `.flushDNS`、`.cleanSystemLogs`),**绝不暴露“删除任意路径”接口**;helper 端校验调用方代码签名。
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术选型清单
|
||||
|
||||
| 用途 | 选择 | 备注 |
|
||||
|---|---|---|
|
||||
| 菜单栏 | `NSStatusItem` + 自绘 `NSImage` | 不用 `MenuBarExtra`:label 不能自由绘图、点击行为难控制 |
|
||||
| 弹出面板 | `NSPanel`(非激活、点击外部关闭)+ `NSHostingView` | 比 `NSPopover` 动画和定位更可控;想省事可先用 `NSPopover` |
|
||||
| 状态管理 | Observation(`@Observable`) | |
|
||||
| 并发 | Swift 6 严格并发,采样和清理放 `actor` | UI 更新 `@MainActor` |
|
||||
| 图表 | Swift Charts | 面板内的历史曲线;菜单栏迷你图用 Core Graphics 自绘 |
|
||||
| 设置存储 | `UserDefaults` + `@AppStorage` | |
|
||||
| 开机自启 | `SMAppService.mainApp.register()` | 不需要单独的 LaunchAtLogin helper |
|
||||
| 自动更新 | Sparkle 2 | 通过 SPM 引入 |
|
||||
| 快捷键 | sindresorhus/KeyboardShortcuts(MIT) | 可选 |
|
||||
| 图标 | 默认使用 SF Symbols;工具和网站可使用有来源声明的品牌标志 | 16pt 行内 / 20pt 独立 |
|
||||
| 日志 | `os.Logger` + 清理操作 JSONL 文件 | |
|
||||
| 测试 | Swift Testing;清理用临时目录做沙箱测试 | SafetyGuard 必须覆盖符号链接、`..`、保护路径等用例 |
|
||||
| CI | GitHub Actions `macos-latest`:构建、测试、签名、公证、生成 DMG | 用 `xcrun notarytool` |
|
||||
|
||||
### 界面设计 token(与你的全局设计规范一致)
|
||||
|
||||
- 颜色:`primary` / `secondary` / `neutral` 灰阶 / `success` / `warning` / `error`,定义在 Asset Catalog 并提供深色变体;CPU 负载颜色:< 60% 中性色,60–85% `warning`,> 85% `error`
|
||||
- 字号:12 / 14 / 16 / 20 / 24 / 32;间距:4 / 8 / 12 / 16 / 24 / 32
|
||||
- 圆角:统一 token(如 `radius.sm = 6`、`radius.md = 10`、`radius.lg = 14`)区分卡片 / 面板
|
||||
- 卡片只用边框或阴影之一;不用渐变、不用毛玻璃(系统面板自带材质除外)
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键代码骨架
|
||||
|
||||
完整可运行版本见 [prototype/Samplers.swift](prototype/Samplers.swift)(本机实测输出:`CPU total 25.7% … cores=18`、`MEM used 43775MB / 65536MB … pressure=1`、`NET rx 692486 B/s`,进程常驻内存约 6.6 MB)。
|
||||
|
||||
### 7.1 应用入口与菜单栏
|
||||
|
||||
```swift
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct XStatsApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
||||
var body: some Scene { Settings { SettingsView() } }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private let store = MetricsStore() // @Observable,UI 读取
|
||||
private let hub = MetricsHub()
|
||||
private var statusItem: NSStatusItem!
|
||||
private var panel: StatusPanel!
|
||||
|
||||
func applicationDidFinishLaunching(_ note: Notification) {
|
||||
NSApp.setActivationPolicy(.accessory) // 不显示 Dock 图标(也可在 Info.plist 设 LSUIElement)
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
statusItem.button?.target = self
|
||||
statusItem.button?.action = #selector(togglePanel)
|
||||
|
||||
panel = StatusPanel(rootView: MonitorView().environment(store))
|
||||
panel.onVisibilityChange = { [hub] visible in
|
||||
Task { await hub.setTier(visible ? .popover : .menuBar) }
|
||||
}
|
||||
|
||||
Task {
|
||||
await hub.start { [weak self] cpu, mem, net in
|
||||
guard let self else { return }
|
||||
store.apply(cpu: cpu, memory: mem, network: net)
|
||||
statusItem.button?.image = MenuBarRenderer.image(cpu: store.cpuHistory, network: net)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func togglePanel() {
|
||||
guard let button = statusItem.button else { return }
|
||||
panel.toggle(relativeTo: button)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 CPU 采样(节选)
|
||||
|
||||
```swift
|
||||
struct CPUSampler: Sampler {
|
||||
private var prevTicks: [[UInt32]] = []
|
||||
|
||||
mutating func sample() -> CPULoad? {
|
||||
var cpuCount: natural_t = 0
|
||||
var info: processor_info_array_t?
|
||||
var infoCount: mach_msg_type_number_t = 0
|
||||
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
|
||||
&cpuCount, &info, &infoCount) == KERN_SUCCESS,
|
||||
let info else { return nil }
|
||||
defer { // 内核分配的内存必须归还
|
||||
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
|
||||
vm_size_t(Int(infoCount) * MemoryLayout<integer_t>.stride))
|
||||
}
|
||||
// …读取每核 user/system/nice/idle,与上一次做差值…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 调度器(分级采样)
|
||||
|
||||
```swift
|
||||
actor MetricsHub {
|
||||
private var cpu = CPUSampler()
|
||||
private var mem = MemorySampler()
|
||||
private var net = NetworkSampler()
|
||||
private var tier: SampleTier = .menuBar
|
||||
private var loop: Task<Void, Never>?
|
||||
|
||||
func setTier(_ t: SampleTier) { tier = t }
|
||||
|
||||
func start(onUpdate: @escaping @MainActor @Sendable (CPULoad?, MemoryUsage?, NetRate?) -> Void) {
|
||||
loop?.cancel()
|
||||
loop = Task {
|
||||
while !Task.isCancelled {
|
||||
let c = cpu.sample(), n = net.sample()
|
||||
let m = tier == .popover ? mem.sample() : nil // 面板关闭时不读细节
|
||||
await onUpdate(c, m, n)
|
||||
try? await Task.sleep(for: tier == .popover ? .seconds(1) : .seconds(2),
|
||||
tolerance: .milliseconds(500))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 清理执行出口
|
||||
|
||||
```swift
|
||||
actor CleanEngine {
|
||||
func execute(_ items: [CleanItem], mode: DeleteMode) async -> CleanReport {
|
||||
var report = CleanReport()
|
||||
for item in items {
|
||||
do {
|
||||
try SafetyGuard.validate(item.url) // 执行前再校验一次
|
||||
if try await RunningAppProbe.isInUse(item) { report.skipped(item, .inUse); continue }
|
||||
switch mode {
|
||||
case .trash: try FileManager.default.trashItem(at: item.url, resultingItemURL: nil)
|
||||
case .delete: try FileManager.default.removeItem(at: item.url)
|
||||
}
|
||||
report.cleaned(item)
|
||||
} catch {
|
||||
report.failed(item, error)
|
||||
}
|
||||
OperationLog.append(item, result: report.last)
|
||||
}
|
||||
return report
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 里程碑
|
||||
|
||||
| 周 | 交付 |
|
||||
|---|---|
|
||||
| 第 1 周 | 工程搭建(App + 本地 Package)、`NSStatusItem` 自绘 CPU、`MetricsHub` 分级调度、锁屏暂停 |
|
||||
| 第 2 周 | 弹出面板:CPU 每核(P/E)、内存、网速、Top 进程;Swift Charts 历史曲线;设置页、开机自启 |
|
||||
| 第 3 周 | 清理引擎:`SafetyGuard` + 单测、规则表(系统 / 浏览器 / 下载 / Xcode)、扫描与预览 UI、废纸篓执行、操作日志 |
|
||||
| 第 4 周 | FDA 权限引导、白名单、Energy Log 功耗回归、签名 + 公证 + Sparkle + DMG 打包,发布 0.1 |
|
||||
| 第 5–6 周 | V1:GPU / 温度 / 功耗(IOHID + IOReport 桥接)、电池、磁盘、开发者缓存与项目产物清理、应用卸载 |
|
||||
| 之后 | V2:特权 helper(DNS、系统日志)、磁盘占用可视化、小组件 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险
|
||||
|
||||
| 风险 | 应对 |
|
||||
|---|---|
|
||||
| 误删用户数据 | 默认只移到废纸篓;review 类默认不勾选;单一删除出口 + 单测;操作日志可追溯 |
|
||||
| 私有 API(IOHID 温度、IOReport)随系统更新失效 | 封装在 `SMCBridge` 内,读取失败时隐藏对应模块而不是崩溃;每个 macOS beta 回归 |
|
||||
| 自身耗电过高 | §4 功耗预算作为发布门槛,CI 外手动跑 Energy Log |
|
||||
| 许可证 | Stats 代码保留 MIT 声明并写入 `ThirdPartyNotices.md`;Mole 只参考不复制 |
|
||||
| 公证被拒 | 开启 Hardened Runtime;私有框架用 `dlopen` 动态加载;不申请不必要的 entitlement |
|
||||
Reference in new issue
Block a user