Files
OpenStats/docs/PLAN.md
T
gentpan 4741d40e1b OpenStats:macOS 菜单栏系统监控初始版本
- 菜单栏:CPU/GPU/内存多样式、网速两行、温度、风扇
- 面板:概览仪表盘、进程、散热、防休眠,液态玻璃与浅色/深色主题
- 采集层:CPU/内存/网络/GPU/磁盘/电池/进程/SMC 温度与风扇,按界面分级采样
- root 辅助工具:风扇调速、合盖不睡眠,断开时自动恢复
- XcodeGen 工程、单元测试、截图模式
2026-09-13 02:44:05 +05:00

24 KiB
Raw Blame History

OpenStats 方案: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。


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 MIT,Swift,约 4.2 万 Star,2026-09 仍在更新 ✅ 可移植代码,需在 LICENSE/致谢页保留 Copyright © Serhiy Mytrovtsiy。注意作者声明“开源但不接受贡献”
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. 总体架构

┌──────────────────────────── OpenStats.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/OpenStats/operations.jsonl                         │
│                                                                                       │
└──────────────────────────────────────┬────────────────────────────────────────────────┘
                                       │ XPC(NSXPCConnection,V2 才需要)
                          ┌────────────▼─────────────┐
                          │ PrivilegedHelper(root)   │  SMAppService.daemon 注册
                          │ 只接受白名单内的固定操作     │  校验调用方签名(Team ID)
                          └──────────────────────────┘

工程目录

openstats/
├─ OpenStats.xcodeproj
├─ App/                         # App target:AppDelegate、StatusItem、窗口管理
├─ Packages/OpenStatsKit/       # 本地 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 规则数据化

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(本机实测输出:CPU total 25.7% … cores=18、MEM used 43775MB / 65536MB … pressure=1、NET rx 692486 B/s,进程常驻内存约 6.6 MB)。

7.1 应用入口与菜单栏

import AppKit
import SwiftUI

@main
struct OpenStatsApp: 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 采样(节选)

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 调度器(分级采样)

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 清理执行出口

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