diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..36df2c4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build-and-test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v7 + + # CommandLineTools alone cannot expand SwiftUI's @Observable / @Entry + # macros — the plugins ship only with full Xcode. Use the runner's + # default Xcode rather than pinning a version that may not be installed + # on a future image. + - name: Select Xcode + run: | + ls -d /Applications/Xcode*.app + sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + swift --version + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Test + run: make test + + - name: Build app + run: make build CONFIG=Release + + - name: Verify bundle + run: | + APP=build/DerivedData/Build/Products/Release/OpenStats.app + test -x "$APP/Contents/MacOS/OpenStats" + test -x "$APP/Contents/MacOS/OpenStatsHelper" + test -f "$APP/Contents/Library/LaunchDaemons/com.openstats.helper.plist" + test -f "$APP/Contents/Resources/AppIcon.icns" + codesign --verify --verbose "$APP" + + # Smoke test: the panel is a menu-bar popover with no window to drive in + # CI, so render it off-screen instead. A view that traps on layout fails + # here rather than the first time a user clicks the menu-bar item. + - name: Render UI snapshots + run: | + build/DerivedData/Build/Products/Release/OpenStats.app/Contents/MacOS/OpenStats --snapshot "$PWD/snapshots" + test "$(ls snapshots/*.png | wc -l)" -ge 20 + + - uses: actions/upload-artifact@v7 + with: + name: ui-snapshots + path: snapshots/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..affe277 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,105 @@ +# OpenStats + +macOS 14+ menu-bar system monitor. Swift 6 language mode, AppKit status item and panels +hosting SwiftUI, no third-party dependencies. The Xcode project is generated from +`project.yml` by XcodeGen; everything testable lives in the local Swift package. + +- `App/` — `main.swift` and the asset catalog. +- `Helper/` — the privileged helper (`OpenStatsHelper`) and its launchd plist, embedded in + the app bundle for `SMAppService.daemon`. +- `Packages/OpenStatsKit/Sources/SMC` — the AppleSMC user client, fan control, temperature + key discovery. +- `Packages/OpenStatsKit/Sources/Metrics` — one sampler per metric and `MetricsHub`. +- `Packages/OpenStatsKit/Sources/Cleaner` — cleanup rules, `SafetyGuard`, `CleanEngine`. +- `Packages/OpenStatsKit/Sources/HelperShared` — the XPC protocol and maintenance commands + shared by the app and the helper. +- `Packages/OpenStatsKit/Sources/OpenStatsUI` — design tokens, panel pages, settings, + menu-bar renderer, app controller, snapshot renderer. +- `Packages/OpenStatsKit/Tests` — metrics, SMC decoding and cleanup safety. + +## Build + +**A full Xcode is required.** CommandLineTools does not ship the SwiftUI macro plugins. + +```bash +brew install xcodegen +make build # Debug +make install # Release into /Applications +make test # swift test in the package +``` + +Builds are ad-hoc signed (`CODE_SIGN_IDENTITY: "-"` in `project.yml`). For a release, +switch to a Developer ID identity, keep hardened runtime on, notarize, and set +`HelperConstants.teamIdentifier` so the helper's code-signing requirement includes the team. + +## Sampling + +`MetricsHub` is an actor that runs one loop. `AppModel.demand` describes what is on screen — +which menu-bar items are enabled, whether the panel is open and on which tab — and the hub +samples only that: CPU always; memory and network cheaply; GPU, disk, processes, sensors and +fans only when a visible surface needs them. Disk is read at most every 30 s, battery every +10 s. The loop pauses on screen sleep, system sleep and session switch. + +Things that are easy to get wrong and are handled on purpose: + +- `host_processor_info` returns kernel-allocated memory; it is `vm_deallocate`d every sample. +- `getifaddrs`' `if_data` counters are 32-bit and wrap at 4 GiB; network uses + `NET_RT_IFLIST2` and `if_data64`. +- Memory follows Activity Monitor: app memory is `internal − purgeable` pages; the page size + comes from `host_page_size` (16 KB on Apple Silicon). +- Process CPU time and wall time are both in mach absolute units, so their ratio needs no + timebase conversion. +- Core types come from `hw.perflevelN`; logical CPUs are numbered from the lowest + performance level up. + +## SMC + +Temperatures are not hard-coded per chip. On first use the sampler enumerates every SMC key +once (≈3,800 keys in about 10 ms on an M5 Max), groups `Tp*`/`Te*` as CPU, `Tg*` as GPU, +`Tm*` as memory, `TB*` as battery and `Ts0P`/`Ts1P` as palm rest, keeps keys whose first +reading is plausible, and samples at most 12 per group. + +Fans are read without privileges. Writing needs root and goes through the helper. On M5 +the mode key (`F0md`) accepts a direct write; M1–M4 first need `Ftst=1` and a pause while +`thermalmonitord` lets go. The firmware records only manual or automatic, not who set it, so +a fan in manual mode that OpenStats did not set is shown as controlled by another program. + +## Helper + +Registered with `SMAppService.daemon`; `RunAtLoad` so that an unclean exit is repaired at +boot. The XPC interface is a fixed list of operations — no arbitrary commands — and each +connection gets `setCodeSigningRequirement`. State that must be undone (manual fans, disabled +sleep) is persisted to `/Library/Application Support/OpenStats/helper-state.plist` and +reverted when the last client disconnects or at the next start. The helper exits after 30 s +without clients. + +## Cleanup + +Every rule lists candidate items; every item passes `SafetyGuard` twice — at scan and again +right before deletion, because apps start in between. + +- Deny by default: an item must sit strictly inside an allow-listed root under the home + folder, never be the root itself, contain no `..` or control characters, and still pass + after symlinks are resolved (resolution can only reject, never allow). +- Under `Application Support`, only folders named like caches (`Code Cache`, `GPUCache`, + `CacheStorage`, …) are allowed. +- Any path component matching a protected keyword (keychains, password managers, VPNs, + cookies, history, …) is rejected. +- Reverse-DNS cache folders whose app is running are skipped; browser rules are blocked while + the browser runs; items modified in the last two minutes count as in use. + +Regenerable caches are deleted outright; user files go to the Trash. Each action is appended +to `~/Library/Logs/OpenStats/cleanup.log` as a JSON line. + +## Panel + +A borderless, non-activating `NSPanel` under the status item, hosting SwiftUI inside +`NSGlassEffectView` on macOS 26+ (`NSVisualEffectView` before). The SwiftUI tree is created on +open and destroyed on close, so a hidden panel costs nothing. Height comes from measuring a +flat, scroll-free copy of the same view; placeholders keep that height stable until data +arrives, and the window is resized without animation because animating it makes SwiftUI +re-lay out every frame. Charts are drawn with `Canvas`, not Swift Charts. + +`--snapshot ` renders every panel tab, settings section and the menu bar in light and +dark, through real `NSHostingView`s in off-screen windows — `ImageRenderer` washes out pages +that contain bitmaps. diff --git a/Assets/icon.png b/Assets/icon.png new file mode 100644 index 0000000..1ae4185 Binary files /dev/null and b/Assets/icon.png differ diff --git a/Assets/readme/activity.svg b/Assets/readme/activity.svg new file mode 100644 index 0000000..a92ae24 --- /dev/null +++ b/Assets/readme/activity.svg @@ -0,0 +1,199 @@ + +4 commits in the last 26 weeks + +Apr +May +Jun +Jul +Aug +Sep +Mon +Wed +Fri + +0 commits on 2026-03-22 +0 commits on 2026-03-23 +0 commits on 2026-03-24 +0 commits on 2026-03-25 +0 commits on 2026-03-26 +0 commits on 2026-03-27 +0 commits on 2026-03-28 +0 commits on 2026-03-29 +0 commits on 2026-03-30 +0 commits on 2026-03-31 +0 commits on 2026-04-01 +0 commits on 2026-04-02 +0 commits on 2026-04-03 +0 commits on 2026-04-04 +0 commits on 2026-04-05 +0 commits on 2026-04-06 +0 commits on 2026-04-07 +0 commits on 2026-04-08 +0 commits on 2026-04-09 +0 commits on 2026-04-10 +0 commits on 2026-04-11 +0 commits on 2026-04-12 +0 commits on 2026-04-13 +0 commits on 2026-04-14 +0 commits on 2026-04-15 +0 commits on 2026-04-16 +0 commits on 2026-04-17 +0 commits on 2026-04-18 +0 commits on 2026-04-19 +0 commits on 2026-04-20 +0 commits on 2026-04-21 +0 commits on 2026-04-22 +0 commits on 2026-04-23 +0 commits on 2026-04-24 +0 commits on 2026-04-25 +0 commits on 2026-04-26 +0 commits on 2026-04-27 +0 commits on 2026-04-28 +0 commits on 2026-04-29 +0 commits on 2026-04-30 +0 commits on 2026-05-01 +0 commits on 2026-05-02 +0 commits on 2026-05-03 +0 commits on 2026-05-04 +0 commits on 2026-05-05 +0 commits on 2026-05-06 +0 commits on 2026-05-07 +0 commits on 2026-05-08 +0 commits on 2026-05-09 +0 commits on 2026-05-10 +0 commits on 2026-05-11 +0 commits on 2026-05-12 +0 commits on 2026-05-13 +0 commits on 2026-05-14 +0 commits on 2026-05-15 +0 commits on 2026-05-16 +0 commits on 2026-05-17 +0 commits on 2026-05-18 +0 commits on 2026-05-19 +0 commits on 2026-05-20 +0 commits on 2026-05-21 +0 commits on 2026-05-22 +0 commits on 2026-05-23 +0 commits on 2026-05-24 +0 commits on 2026-05-25 +0 commits on 2026-05-26 +0 commits on 2026-05-27 +0 commits on 2026-05-28 +0 commits on 2026-05-29 +0 commits on 2026-05-30 +0 commits on 2026-05-31 +0 commits on 2026-06-01 +0 commits on 2026-06-02 +0 commits on 2026-06-03 +0 commits on 2026-06-04 +0 commits on 2026-06-05 +0 commits on 2026-06-06 +0 commits on 2026-06-07 +0 commits on 2026-06-08 +0 commits on 2026-06-09 +0 commits on 2026-06-10 +0 commits on 2026-06-11 +0 commits on 2026-06-12 +0 commits on 2026-06-13 +0 commits on 2026-06-14 +0 commits on 2026-06-15 +0 commits on 2026-06-16 +0 commits on 2026-06-17 +0 commits on 2026-06-18 +0 commits on 2026-06-19 +0 commits on 2026-06-20 +0 commits on 2026-06-21 +0 commits on 2026-06-22 +0 commits on 2026-06-23 +0 commits on 2026-06-24 +0 commits on 2026-06-25 +0 commits on 2026-06-26 +0 commits on 2026-06-27 +0 commits on 2026-06-28 +0 commits on 2026-06-29 +0 commits on 2026-06-30 +0 commits on 2026-07-01 +0 commits on 2026-07-02 +0 commits on 2026-07-03 +0 commits on 2026-07-04 +0 commits on 2026-07-05 +0 commits on 2026-07-06 +0 commits on 2026-07-07 +0 commits on 2026-07-08 +0 commits on 2026-07-09 +0 commits on 2026-07-10 +0 commits on 2026-07-11 +0 commits on 2026-07-12 +0 commits on 2026-07-13 +0 commits on 2026-07-14 +0 commits on 2026-07-15 +0 commits on 2026-07-16 +0 commits on 2026-07-17 +0 commits on 2026-07-18 +0 commits on 2026-07-19 +0 commits on 2026-07-20 +0 commits on 2026-07-21 +0 commits on 2026-07-22 +0 commits on 2026-07-23 +0 commits on 2026-07-24 +0 commits on 2026-07-25 +0 commits on 2026-07-26 +0 commits on 2026-07-27 +0 commits on 2026-07-28 +0 commits on 2026-07-29 +0 commits on 2026-07-30 +0 commits on 2026-07-31 +0 commits on 2026-08-01 +0 commits on 2026-08-02 +0 commits on 2026-08-03 +0 commits on 2026-08-04 +0 commits on 2026-08-05 +0 commits on 2026-08-06 +0 commits on 2026-08-07 +0 commits on 2026-08-08 +0 commits on 2026-08-09 +0 commits on 2026-08-10 +0 commits on 2026-08-11 +0 commits on 2026-08-12 +0 commits on 2026-08-13 +0 commits on 2026-08-14 +0 commits on 2026-08-15 +0 commits on 2026-08-16 +0 commits on 2026-08-17 +0 commits on 2026-08-18 +0 commits on 2026-08-19 +0 commits on 2026-08-20 +0 commits on 2026-08-21 +0 commits on 2026-08-22 +0 commits on 2026-08-23 +0 commits on 2026-08-24 +0 commits on 2026-08-25 +0 commits on 2026-08-26 +0 commits on 2026-08-27 +0 commits on 2026-08-28 +0 commits on 2026-08-29 +0 commits on 2026-08-30 +0 commits on 2026-08-31 +0 commits on 2026-09-01 +0 commits on 2026-09-02 +0 commits on 2026-09-03 +0 commits on 2026-09-04 +0 commits on 2026-09-05 +0 commits on 2026-09-06 +0 commits on 2026-09-07 +0 commits on 2026-09-08 +0 commits on 2026-09-09 +0 commits on 2026-09-10 +0 commits on 2026-09-11 +0 commits on 2026-09-12 +4 commits on 2026-09-13 + +4 commits in the last 26 weeks · through 2026-09-13 +Less + + + + + +More + diff --git a/Assets/readme/activity.zh.svg b/Assets/readme/activity.zh.svg new file mode 100644 index 0000000..3d4ef2d --- /dev/null +++ b/Assets/readme/activity.zh.svg @@ -0,0 +1,199 @@ + +近 26 周共 4 次提交 + +4月 +5月 +6月 +7月 +8月 +9月 +一 +三 +五 + +2026-03-22:0 次提交 +2026-03-23:0 次提交 +2026-03-24:0 次提交 +2026-03-25:0 次提交 +2026-03-26:0 次提交 +2026-03-27:0 次提交 +2026-03-28:0 次提交 +2026-03-29:0 次提交 +2026-03-30:0 次提交 +2026-03-31:0 次提交 +2026-04-01:0 次提交 +2026-04-02:0 次提交 +2026-04-03:0 次提交 +2026-04-04:0 次提交 +2026-04-05:0 次提交 +2026-04-06:0 次提交 +2026-04-07:0 次提交 +2026-04-08:0 次提交 +2026-04-09:0 次提交 +2026-04-10:0 次提交 +2026-04-11:0 次提交 +2026-04-12:0 次提交 +2026-04-13:0 次提交 +2026-04-14:0 次提交 +2026-04-15:0 次提交 +2026-04-16:0 次提交 +2026-04-17:0 次提交 +2026-04-18:0 次提交 +2026-04-19:0 次提交 +2026-04-20:0 次提交 +2026-04-21:0 次提交 +2026-04-22:0 次提交 +2026-04-23:0 次提交 +2026-04-24:0 次提交 +2026-04-25:0 次提交 +2026-04-26:0 次提交 +2026-04-27:0 次提交 +2026-04-28:0 次提交 +2026-04-29:0 次提交 +2026-04-30:0 次提交 +2026-05-01:0 次提交 +2026-05-02:0 次提交 +2026-05-03:0 次提交 +2026-05-04:0 次提交 +2026-05-05:0 次提交 +2026-05-06:0 次提交 +2026-05-07:0 次提交 +2026-05-08:0 次提交 +2026-05-09:0 次提交 +2026-05-10:0 次提交 +2026-05-11:0 次提交 +2026-05-12:0 次提交 +2026-05-13:0 次提交 +2026-05-14:0 次提交 +2026-05-15:0 次提交 +2026-05-16:0 次提交 +2026-05-17:0 次提交 +2026-05-18:0 次提交 +2026-05-19:0 次提交 +2026-05-20:0 次提交 +2026-05-21:0 次提交 +2026-05-22:0 次提交 +2026-05-23:0 次提交 +2026-05-24:0 次提交 +2026-05-25:0 次提交 +2026-05-26:0 次提交 +2026-05-27:0 次提交 +2026-05-28:0 次提交 +2026-05-29:0 次提交 +2026-05-30:0 次提交 +2026-05-31:0 次提交 +2026-06-01:0 次提交 +2026-06-02:0 次提交 +2026-06-03:0 次提交 +2026-06-04:0 次提交 +2026-06-05:0 次提交 +2026-06-06:0 次提交 +2026-06-07:0 次提交 +2026-06-08:0 次提交 +2026-06-09:0 次提交 +2026-06-10:0 次提交 +2026-06-11:0 次提交 +2026-06-12:0 次提交 +2026-06-13:0 次提交 +2026-06-14:0 次提交 +2026-06-15:0 次提交 +2026-06-16:0 次提交 +2026-06-17:0 次提交 +2026-06-18:0 次提交 +2026-06-19:0 次提交 +2026-06-20:0 次提交 +2026-06-21:0 次提交 +2026-06-22:0 次提交 +2026-06-23:0 次提交 +2026-06-24:0 次提交 +2026-06-25:0 次提交 +2026-06-26:0 次提交 +2026-06-27:0 次提交 +2026-06-28:0 次提交 +2026-06-29:0 次提交 +2026-06-30:0 次提交 +2026-07-01:0 次提交 +2026-07-02:0 次提交 +2026-07-03:0 次提交 +2026-07-04:0 次提交 +2026-07-05:0 次提交 +2026-07-06:0 次提交 +2026-07-07:0 次提交 +2026-07-08:0 次提交 +2026-07-09:0 次提交 +2026-07-10:0 次提交 +2026-07-11:0 次提交 +2026-07-12:0 次提交 +2026-07-13:0 次提交 +2026-07-14:0 次提交 +2026-07-15:0 次提交 +2026-07-16:0 次提交 +2026-07-17:0 次提交 +2026-07-18:0 次提交 +2026-07-19:0 次提交 +2026-07-20:0 次提交 +2026-07-21:0 次提交 +2026-07-22:0 次提交 +2026-07-23:0 次提交 +2026-07-24:0 次提交 +2026-07-25:0 次提交 +2026-07-26:0 次提交 +2026-07-27:0 次提交 +2026-07-28:0 次提交 +2026-07-29:0 次提交 +2026-07-30:0 次提交 +2026-07-31:0 次提交 +2026-08-01:0 次提交 +2026-08-02:0 次提交 +2026-08-03:0 次提交 +2026-08-04:0 次提交 +2026-08-05:0 次提交 +2026-08-06:0 次提交 +2026-08-07:0 次提交 +2026-08-08:0 次提交 +2026-08-09:0 次提交 +2026-08-10:0 次提交 +2026-08-11:0 次提交 +2026-08-12:0 次提交 +2026-08-13:0 次提交 +2026-08-14:0 次提交 +2026-08-15:0 次提交 +2026-08-16:0 次提交 +2026-08-17:0 次提交 +2026-08-18:0 次提交 +2026-08-19:0 次提交 +2026-08-20:0 次提交 +2026-08-21:0 次提交 +2026-08-22:0 次提交 +2026-08-23:0 次提交 +2026-08-24:0 次提交 +2026-08-25:0 次提交 +2026-08-26:0 次提交 +2026-08-27:0 次提交 +2026-08-28:0 次提交 +2026-08-29:0 次提交 +2026-08-30:0 次提交 +2026-08-31:0 次提交 +2026-09-01:0 次提交 +2026-09-02:0 次提交 +2026-09-03:0 次提交 +2026-09-04:0 次提交 +2026-09-05:0 次提交 +2026-09-06:0 次提交 +2026-09-07:0 次提交 +2026-09-08:0 次提交 +2026-09-09:0 次提交 +2026-09-10:0 次提交 +2026-09-11:0 次提交 +2026-09-12:0 次提交 +2026-09-13:4 次提交 + +近 26 周共 4 次提交 · 截至 2026-09-13 +少 + + + + + +多 + diff --git a/docs/images/cleaner-light.png b/Assets/readme/cleaner-light.png similarity index 100% rename from docs/images/cleaner-light.png rename to Assets/readme/cleaner-light.png diff --git a/docs/images/keepawake-light.png b/Assets/readme/keepawake-light.png similarity index 100% rename from docs/images/keepawake-light.png rename to Assets/readme/keepawake-light.png diff --git a/docs/images/menubar-dark.png b/Assets/readme/menubar-dark.png similarity index 100% rename from docs/images/menubar-dark.png rename to Assets/readme/menubar-dark.png diff --git a/docs/images/overview-dark.png b/Assets/readme/overview-dark.png similarity index 100% rename from docs/images/overview-dark.png rename to Assets/readme/overview-dark.png diff --git a/docs/images/overview-light.png b/Assets/readme/overview-light.png similarity index 100% rename from docs/images/overview-light.png rename to Assets/readme/overview-light.png diff --git a/docs/images/settings-menubar-light.png b/Assets/readme/settings-menubar-light.png similarity index 100% rename from docs/images/settings-menubar-light.png rename to Assets/readme/settings-menubar-light.png diff --git a/docs/images/thermal-dark.png b/Assets/readme/thermal-dark.png similarity index 100% rename from docs/images/thermal-dark.png rename to Assets/readme/thermal-dark.png diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b834aef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# 更新日志 + +记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。 +官网、构建与发布脚本等不影响应用本身的改动不在此记录。 + +## 未发布 + +### 2026-09-13 + +#### 新增 + +- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。CPU、GPU、内存可以分别选择数字、柱状图、圆环、饼图、柱状图加数字五种样式;网速两行显示,绿点上传、蓝点下载,带完整单位。 +- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 +- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 +- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 +- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。 +- 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。 +- 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。 +- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作,并校验调用方签名;异常退出后下次开机自动恢复风扇与睡眠设置。 +- 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 +- 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。 +- 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。 +- 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。 +- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 +- 外观可选跟随系统、浅色、深色;macOS 26 起面板使用液态玻璃。 +- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。 + +#### 样式 + +- 菜单栏字号对齐常见菜单栏监控工具:标签 7pt 细体、数值 12pt 常规、网速 9pt 细体,指标之间留出更宽的间距。 +- 面板加宽为三列布局,高度按内容自动计算,常见屏幕上不需要滚动。 +- 更换应用图标,面板左上角和关于页使用同一图标。 +- 首次启动默认在菜单栏显示 CPU、内存与网速。 + +#### 修复 + +- 深色菜单栏上 CPU、RAM 小标签颜色过深,几乎看不清。 +- 面板打开后高度会再调整一次、内容轻微回弹,看起来在抖动;面板打开时点击菜单栏图标关闭,会立刻又被打开。 +- 风扇被其他程序设为手动模式时,误显示为由 OpenStats 控制。 +- 风扇转速、PID 等数字出现千分位分隔符,例如 1,350 RPM。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c060f0a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GiantAccel, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index bfe2fe5..33c156e 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,260 @@ -

- OpenStats 图标 -

+
-

OpenStats

+OpenStats -

- 轻量、原生的 macOS 菜单栏系统监控
- CPU · GPU · 内存 · 网络 · 磁盘 · 电池 · 温度 · 风扇调速 · 防休眠
- getopenstats.com -

+# OpenStats -

- macOS 14+ - Swift 6 - Apple Silicon - 开发中 -

+**Your Mac at a glance — CPU, GPU, memory, network and temperatures in the menu bar, with fan control, keep-awake and one-click cleanup.** -

- 概览(深色) - 概览(浅色) -

+[![Release](https://img.shields.io/github/v/release/gentpan/OpenStats?color=6ee02b&label=release)](https://github.com/gentpan/OpenStats/releases/latest) +[![Downloads](https://img.shields.io/github/downloads/gentpan/OpenStats/total?color=6ee02b&label=downloads)](https://github.com/gentpan/OpenStats/releases) +[![Stars](https://img.shields.io/github/stars/gentpan/OpenStats?style=flat&color=f5c518&label=stars)](https://github.com/gentpan/OpenStats/stargazers) +[![Last commit](https://img.shields.io/github/last-commit/gentpan/OpenStats?color=black&label=last%20commit)](https://github.com/gentpan/OpenStats/commits/main) +[![Commit activity](https://img.shields.io/github/commit-activity/m/gentpan/OpenStats?color=black&label=commits)](https://github.com/gentpan/OpenStats/graphs/commit-activity) +[![CI](https://github.com/gentpan/OpenStats/actions/workflows/ci.yml/badge.svg)](https://github.com/gentpan/OpenStats/actions/workflows/ci.yml) +[![macOS](https://img.shields.io/badge/macOS-14%2B-black)](https://github.com/gentpan/OpenStats/releases/latest) +[![License](https://img.shields.io/badge/license-MIT-black)](LICENSE) + +OpenStats is a macOS menu-bar app that shows what your Mac is doing right now — per-core +CPU load, GPU, memory pressure, network speed, disk, battery, temperatures and fans — and +lets you act on it: spin the fans up, keep the Mac awake with the lid closed, clear caches. +Everything is read on your own Mac. No account, no telemetry, no network access at all. + +[Download](https://github.com/gentpan/OpenStats/releases/latest) · +[Website](https://getopenstats.com) · +[Changelog](CHANGELOG.md) · +[Architecture](ARCHITECTURE.md) + +**English** · [简体中文](README.zh-CN.md) + +
--- -## 为什么做 OpenStats +## Install -市面上的菜单栏监控要么功能单一,要么常驻耗电。OpenStats 的目标很简单: +OpenStats has not had a release yet. The first one will be a `.dmg` on +[Releases](https://github.com/gentpan/OpenStats/releases/latest), signed with a Developer ID +certificate and notarized by Apple, followed by a Homebrew cask. Until then, build it from +source — see [Build & run](#build--run). -- **一眼看全**:点开菜单栏就是一整屏仪表盘,不用翻页、不用下拉。 -- **足够省电**:面板收起时只采集菜单栏用到的指标,M5 Max 实测 **约 0.3% CPU、16 MB 内存**。 -- **原生体验**:Swift 6 + AppKit + SwiftUI,macOS 26 起使用系统液态玻璃,跟随浅色 / 深色外观。 +Requires macOS 14 (Sonoma) or later; the panel uses Liquid Glass on macOS 26 and later. +Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -## 功能 +## Recent updates -### 菜单栏 + + -

菜单栏

+Not released yet · **23** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) -| 指标 | 显示方式 | -|---|---| -| CPU / GPU / 内存 | 数字、柱状图、圆环、饼图、柱状图 + 数字,逐项可选 | -| 网速 | 两行显示:绿点上传、蓝点下载,完整单位(KB/s、MB/s、GB/s) | -| CPU 温度 / 风扇转速 | 迷你数值 | -| 防休眠 | 开启时显示咖啡杯图标 | +
+2026-09-13 · Unreleased · 15 added · 4 style · 4 fixed -高负载时可选数值变红提醒;数值等宽右对齐,刷新时菜单栏不抖动。 +**Added** -### 面板 +- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。CPU、GPU、内存可以分别选择数字、柱状图、圆环、饼图、柱状图加数字五种样式;网速两行显示,绿点上传、蓝点下载,带完整单位。 +- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 +- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 +- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 +- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。 +- 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。 +- 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。 +- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作,并校验调用方签名;异常退出后下次开机自动恢复风扇与睡眠设置。 +- 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 +- 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。 +- 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。 +- 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。 +- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 +- 外观可选跟随系统、浅色、深色;macOS 26 起面板使用液态玻璃。 +- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。 -- **概览仪表盘**:健康评分、芯片 / 内存 / 系统 / 运行时长徽章,CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇、各核心负载(超级核 / 性能核 / 能效核分组)、电池环形图、高占用进程、快捷开关。 -- **进程**:按 CPU 或内存排序,辅助进程归并显示为所属应用。 -- **散热**:CPU / GPU / 内存 / 电池 / 掌托温度;风扇实时转速与调速。 -- **防休眠**:屏幕常亮、仅系统不休眠、**合盖后继续运行**,支持定时与低电量自动关闭。 +**Style** + +- 菜单栏字号对齐常见菜单栏监控工具:标签 7pt 细体、数值 12pt 常规、网速 9pt 细体,指标之间留出更宽的间距。 +- 面板加宽为三列布局,高度按内容自动计算,常见屏幕上不需要滚动。 +- 更换应用图标,面板左上角和关于页使用同一图标。 +- 首次启动默认在菜单栏显示 CPU、内存与网速。 + +**Fixed** + +- 深色菜单栏上 CPU、RAM 小标签颜色过深,几乎看不清。 +- 面板打开后高度会再调整一次、内容轻微回弹,看起来在抖动;面板打开时点击菜单栏图标关闭,会立刻又被打开。 +- 风扇被其他程序设为手动模式时,误显示为由 OpenStats 控制。 +- 风扇转速、PID 等数字出现千分位分隔符,例如 1,350 RPM。 + +
+ + + +## Activity

- 散热 - 防休眠 + Commits per day over the last 26 weeks

-### 风扇调速 +

+ + + + Star history + + +

-| 模式 | 行为 | +## At a glance + +

+ Overview, dark + Overview, light +

+ +## Where the numbers show + +**Menu bar** +- CPU, GPU and memory each in one of five styles — number, bars, ring, pie, or bars with a + number — plus network speed, CPU temperature and the fastest fan. +- Network speed takes two rows: a green dot for upload, a blue dot for download, always with + its unit (`KB/s`, `MB/s`, `GB/s`). +- Labels at 7 pt and figures at 12 pt, matching the menu-bar monitors people already use. + Figures are fixed-width, so the bar does not jitter as they change. + +

Menu bar

+ +**Panel** — click the menu-bar item. +- **Overview.** A health score with badges for the chip, memory, macOS version, uptime and + model; then CPU history as bars, GPU as a line, memory, disk, network and fans in three + columns; per-core load grouped by core type (super, performance, efficiency); battery as a + ring; the busiest processes and quick switches. +- **Processes.** Sorted by CPU or memory; helper processes are shown under their app. +- **Thermal.** CPU, GPU, memory, battery and palm-rest temperatures, and the fans. +- **Keep awake.** Keep the display on, keep only the system awake, or keep running with the + lid closed — for a set time or indefinitely. +- **Clean.** Scan, review, clean. See [Cleanup](#cleanup). + +The panel sizes itself to its content and needs no scrolling on common screens. `Esc` +closes it. Light, dark or follow the system. + +

+ Thermal + Keep awake +

+ +## Fans and sleep + +| Fan mode | What it does | |---|---| -| 自动 | 交还 macOS 温控 | -| 降温 | 固定在最低到最高转速的 60% | -| 强冷 | 最高转速 | -| 自定义 | 滑块自由设定 | +| Auto | Hands the fans back to macOS | +| Cool | Holds them at 60% between minimum and maximum speed | +| Max | Full speed | +| Custom | A slider, anywhere in the fan's range | -安全机制:自定义模式下 CPU 达到设定温度(默认 95°C)自动交还系统控制;应用退出、崩溃或断开连接时风扇自动恢复。 +In Custom, the fans are handed back to macOS the moment the CPU reaches the safety +temperature (95 °C by default). Quitting OpenStats, or the app crashing, restores automatic +control. Running with the lid closed switches itself off on battery below a floor you choose. -### 清理与系统维护 +Both need system privileges, provided by a small helper registered with `SMAppService` and +approved once in System Settings → General → Login Items. The helper offers a fixed set of +operations — set a fan's target speed, return fans to auto, toggle `pmset disablesleep`, +flush the DNS cache, purge memory — and **never runs arbitrary commands**. It checks the +caller's code signature, restores fans and sleep when the app disconnects, and after an +unclean exit restores them at the next boot. -- **一键扫描**:应用缓存、日志与崩溃报告、浏览器缓存(Chrome / Edge / Brave / Arc / Firefox / Safari)、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包、废纸篓。 -- **先预览再清理**:按类别显示大小,可展开查看具体项目并在访达中定位;清理前二次确认。 -- **安全边界**:只清理白名单目录;钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过;执行前逐项重新校验;所有操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 -- **系统维护**:一键刷新 DNS 缓存、释放内存(已安装辅助工具时直接执行,否则请求一次管理员授权)。 +## Cleanup -

清理

+- **What it scans.** App caches, logs and crash reports, browser caches (Chrome, Edge, + Brave, Arc, Firefox, Safari), Xcode DerivedData, simulator caches, the npm cache, Xcode + archives, unfinished downloads, installers and the Trash. +- **Review first.** Sizes by category, every rule expandable to its items, a second click to + confirm. Caches and logs are deleted outright so the space comes back at once; downloads go + to the Trash. One switch sends everything to the Trash instead. +- **Safety.** Only allow-listed folders are ever touched. Keychains, password managers, + VPNs, cookies and history are off limits. Caches of running apps are skipped, browsers must + be quit first, and every item is checked again right before it goes. Each action is logged + to `~/Library/Logs/OpenStats/cleanup.log`. +- **Maintenance.** Flush the DNS cache and purge memory — through the helper if it is + installed, otherwise after a one-time administrator prompt. -### 设置 +

Cleanup

-

设置

+## Your data -外观(跟随系统 / 浅色 / 深色)、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖模式电量下限。 +OpenStats makes **no network connections**. Metrics come from the kernel (`host_processor_info`, +`host_statistics64`, `sysctl`), IOKit and the SMC on your own Mac. Preferences live in the +app's user defaults and contain nothing personal. There is no analytics and no telemetry. -## 技术实现 +## Build & run -| 数据 | 来源 | -|---|---| -| CPU | `host_processor_info`,按 `hw.perflevelN` 区分核心类型 | -| 内存 | `host_statistics64`,口径与活动监视器一致;`kern.memorystatus_vm_pressure_level` | -| 网络 | `sysctl(NET_RT_IFLIST2)` 读取 64 位计数器(避免 `getifaddrs` 32 位回绕) | -| GPU | `IOAccelerator` 的 `PerformanceStatistics` | -| 温度 / 风扇 | AppleSMC;启动时枚举全部 SMC 键按前缀归类,**不硬编码芯片型号** | -| 进程 | `proc_pid_rusage`,CPU 时间与墙钟时间同用 mach 时间单位 | -| 电池 | `IOPowerSources` + `AppleSmartBattery` | - -采集按界面需要分级:面板收起时只采菜单栏用到的指标;锁屏、屏幕休眠、系统睡眠时暂停。图表全部用 Canvas 自绘,面板关闭后立即销毁视图。 - -## 辅助工具与安全 - -风扇调速、“合盖后继续运行”、刷新 DNS 与释放内存需要系统权限,由 `SMAppService.daemon` 注册的辅助工具完成,首次使用时在“系统设置 › 通用 › 登录项”中批准一次。 - -- 只暴露固定的几个操作:设置风扇目标转速、恢复自动、切换 `pmset disablesleep`、刷新 DNS、释放内存,**不提供任意命令执行**。 -- 通过 XPC 通信,并校验调用方代码签名。 -- 客户端断开时自动恢复风扇与睡眠设置;异常退出后下次开机也会恢复。 - -## 安装 - -尚未发布正式版本。发布后将提供经过 Developer ID 签名与 Apple 公证的 DMG。 - -## 从源码构建 - -需要 Xcode 16 及以上,以及 [XcodeGen](https://github.com/yonaskolb/XcodeGen): +Requires macOS 14+, a **full Xcode** (the SwiftUI macro plugins do not ship with +CommandLineTools) and [XcodeGen](https://github.com/yonaskolb/XcodeGen). ```bash brew install xcodegen -make run # 生成工程、编译并启动(Debug) -make run CONFIG=Release # Release 构建 -make install # Release 构建并安装到 /Applications 后启动 -make test # 单元测试 -make snapshot # 用本机实时数据渲染各页面截图到 build/snapshots -make open # 生成并用 Xcode 打开工程 +make run # generate the project, build Debug and launch +make install # build Release, install into /Applications and launch +make test # unit tests: metrics, SMC decoding, cleanup safety +make open # generate the project and open it in Xcode ``` -开发调试参数: +The app is a menu-bar agent, so there is little to screenshot. Render the surfaces with +live data instead: -- `--snapshot <目录>`:输出面板、设置页、菜单栏的浅色 / 深色截图 -- `--show-panel`:启动后展开并固定面板,用于测量面板打开时的资源占用 - -## 项目结构 - -``` -App/ 应用入口与图标 -Helper/ root 辅助工具及其 launchd 配置 -Packages/OpenStatsKit/ - Sources/SMC SMC 读写、风扇控制、温度传感器归类 - Sources/Metrics 指标采集与分级调度(MetricsHub) - Sources/HelperShared XPC 协议与系统维护命令 - Sources/Cleaner 清理规则、安全守卫、扫描与执行引擎 - Sources/OpenStatsUI 设计 token、面板、设置窗口、菜单栏绘制 - Tests/ 单元测试(采集、SMC 编解码、清理安全边界) -project.yml XcodeGen 工程描述 -docs/PLAN.md 技术方案与调研记录 +```bash +build/DerivedData/Build/Products/Debug/OpenStats.app/Contents/MacOS/OpenStats --snapshot ./snapshots ``` -## 路线图 +Glass and vibrancy exist only on screen. To measure the panel while it is open, launch with +`--show-panel`, which opens the panel and pins it. -- [x] 菜单栏多指标与多种显示样式 -- [x] 概览仪表盘、进程、散热、防休眠 -- [x] 液态玻璃面板与浅色 / 深色主题 -- [x] 风扇调速与合盖运行(辅助工具) -- [x] 缓存清理、刷新 DNS、释放内存 -- [ ] 应用卸载(含残留文件与程序坞图标) -- [ ] 磁盘健康(SSD 寿命、写入量、温度) -- [ ] 点击菜单栏各指标弹出独立详情 -- [ ] Developer ID 签名、公证与 DMG 发布 -- [ ] Sparkle 自动更新 -- [ ] 功耗、磁盘读写等更多指标 -- [ ] 桌面小组件 +After editing `CHANGELOG.md`, run `python3 Scripts/sync_changelog.py` to refresh the recent +updates above and the activity chart. -## 致谢 +## Distribution -SMC 通信与 Apple Silicon 风扇控制流程移植自 [exelban/stats](https://github.com/exelban/stats)(MIT License),详见 [ThirdPartyNotices.md](ThirdPartyNotices.md)。 +Developer ID only, not the App Store — the sandbox forbids talking to the SMC, reading other +apps' caches and installing a privileged helper, which is most of the feature set. -## 许可证 +| What you have | What others get | +|---|---| +| Nothing | Ad-hoc signature — runs on your Mac only. Others see *"OpenStats is damaged"*. | +| Developer ID certificate | Hardened runtime. Others see *"Apple cannot check it for malicious software"*. | +| Certificate + notarization | Gatekeeper accepts it — the normal *"downloaded from the internet"* prompt. | -待定。 +Before a release, set the Team ID in `HelperShared/HelperProtocol.swift` so the helper +requires the caller to be signed by it. + +## Architecture + +- `Packages/OpenStatsKit/Sources/SMC` — SMC reads and writes, fan control, temperature + sensor discovery. +- `Packages/OpenStatsKit/Sources/Metrics` — samplers for CPU, memory, network, GPU, disk, + battery, processes and sensors, and `MetricsHub`, which samples only what is on screen. +- `Packages/OpenStatsKit/Sources/Cleaner` — cleanup rules, the safety guard, scanning and + cleaning. +- `Packages/OpenStatsKit/Sources/HelperShared` — the XPC protocol and maintenance commands. +- `Packages/OpenStatsKit/Sources/OpenStatsUI` — design tokens, the panel, settings and the + menu-bar renderer. +- `Helper/` — the privileged helper and its launchd plist. `App/` — the entry point. + +More in [ARCHITECTURE.md](ARCHITECTURE.md). Every change to the app is logged, dated, in +[CHANGELOG.md](CHANGELOG.md). + +## Acknowledgements + +OpenStats builds on these open-source projects. Thank you. + +| Project | Author | License | What OpenStats took | +|---|---|---|---| +| [Stats](https://github.com/exelban/stats) | Serhiy Mytrovtsiy | MIT | SMC access, the Apple Silicon fan unlock sequence and the menu-bar mini widget metrics | +| [Mole](https://github.com/tw93/Mole) | tw93 | GPL-3.0 | Which folders are worth cleaning and which must never be touched; the cleaner is an independent Swift implementation and contains no Mole code | +| [QuotaBar](https://github.com/gentpan/quotabar) | GiantAccel, LLC | MIT | This README's layout, the changelog sync and the activity chart | + +Details in [ThirdPartyNotices.md](ThirdPartyNotices.md). + +OpenStats is an independent third-party app. It is not affiliated with, endorsed by, or +sponsored by Apple or any other company it mentions. Their names and logos belong to their +respective owners. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..738bc65 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,233 @@ +
+ +OpenStats + +# OpenStats + +**Mac 的状态,抬眼就看见——CPU、GPU、内存、网络与温度常驻菜单栏,还能调风扇、防休眠、一键清理。** + +[![Release](https://img.shields.io/github/v/release/gentpan/OpenStats?color=6ee02b&label=%E7%89%88%E6%9C%AC)](https://github.com/gentpan/OpenStats/releases/latest) +[![Downloads](https://img.shields.io/github/downloads/gentpan/OpenStats/total?color=6ee02b&label=%E4%B8%8B%E8%BD%BD)](https://github.com/gentpan/OpenStats/releases) +[![Stars](https://img.shields.io/github/stars/gentpan/OpenStats?style=flat&color=f5c518&label=%E6%98%9F%E6%A0%87)](https://github.com/gentpan/OpenStats/stargazers) +[![Last commit](https://img.shields.io/github/last-commit/gentpan/OpenStats?color=black&label=%E6%9C%80%E8%BF%91%E6%8F%90%E4%BA%A4)](https://github.com/gentpan/OpenStats/commits/main) +[![Commit activity](https://img.shields.io/github/commit-activity/m/gentpan/OpenStats?color=black&label=%E6%8F%90%E4%BA%A4)](https://github.com/gentpan/OpenStats/graphs/commit-activity) +[![CI](https://github.com/gentpan/OpenStats/actions/workflows/ci.yml/badge.svg)](https://github.com/gentpan/OpenStats/actions/workflows/ci.yml) +[![macOS](https://img.shields.io/badge/macOS-14%2B-black)](https://github.com/gentpan/OpenStats/releases/latest) +[![License](https://img.shields.io/badge/license-MIT-black)](LICENSE) + +OpenStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各核心 CPU 负载、GPU、内存压力、 +网速、磁盘、电池、温度和风扇,并且可以直接处理:给风扇提速、合盖后继续运行、清理缓存。 +所有数据都在你自己的 Mac 上读取。无需注册账号,没有统计上报,完全不联网。 + +[下载](https://github.com/gentpan/OpenStats/releases/latest) · +[官网](https://getopenstats.com) · +[更新日志](CHANGELOG.md) · +[架构说明](ARCHITECTURE.md) + +[English](README.md) · **简体中文** + +
+ +--- + +## 安装 + +OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https://github.com/gentpan/OpenStats/releases/latest) +提供 `.dmg`,使用 Developer ID 证书签名并经过 Apple 公证,之后提供 Homebrew cask。 +在此之前请从源码构建,见[构建与运行](#构建与运行)。 + +需要 macOS 14(Sonoma)或更高版本;macOS 26 起面板使用液态玻璃。在 Apple Silicon 上开发和测试。 +界面为简体中文。 + +## 最近更新 + + + + +尚未发布正式版本 · 开发中 **23** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) + +
+2026-09-13 · 未发布 · 新增 15 · 样式 4 · 修复 4 + +**新增** + +- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。CPU、GPU、内存可以分别选择数字、柱状图、圆环、饼图、柱状图加数字五种样式;网速两行显示,绿点上传、蓝点下载,带完整单位。 +- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。 +- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。 +- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。 +- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。 +- 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。 +- 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。 +- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作,并校验调用方签名;异常退出后下次开机自动恢复风扇与睡眠设置。 +- 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。 +- 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。 +- 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。 +- 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。 +- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。 +- 外观可选跟随系统、浅色、深色;macOS 26 起面板使用液态玻璃。 +- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。 + +**样式** + +- 菜单栏字号对齐常见菜单栏监控工具:标签 7pt 细体、数值 12pt 常规、网速 9pt 细体,指标之间留出更宽的间距。 +- 面板加宽为三列布局,高度按内容自动计算,常见屏幕上不需要滚动。 +- 更换应用图标,面板左上角和关于页使用同一图标。 +- 首次启动默认在菜单栏显示 CPU、内存与网速。 + +**修复** + +- 深色菜单栏上 CPU、RAM 小标签颜色过深,几乎看不清。 +- 面板打开后高度会再调整一次、内容轻微回弹,看起来在抖动;面板打开时点击菜单栏图标关闭,会立刻又被打开。 +- 风扇被其他程序设为手动模式时,误显示为由 OpenStats 控制。 +- 风扇转速、PID 等数字出现千分位分隔符,例如 1,350 RPM。 + +
+ + + +## 活跃度 + +

+ 近 26 周每天的提交数 +

+ +

+ + + + 星标历史 + + +

+ +## 界面一览 + +

+ 概览(深色) + 概览(浅色) +

+ +## 数据显示在哪里 + +**菜单栏** +- CPU、GPU、内存可以分别选择数字、柱状图、圆环、饼图、柱状图加数字五种样式,另外还能显示网速、 + CPU 温度和转速最高的风扇。 +- 网速两行显示:绿点上传、蓝点下载,始终带单位(`KB/s`、`MB/s`、`GB/s`)。 +- 标签 7pt、数值 12pt,与常见的菜单栏监控工具一致;数值等宽,刷新时菜单栏不抖动。 + +

菜单栏

+ +**面板**——点击菜单栏图标打开。 +- **概览**:健康评分,芯片、内存、系统版本、运行时长、机型徽章;CPU 柱状历史、GPU 折线、内存、 + 磁盘、网络、风扇三列卡片;按超级核、性能核、能效核分组的核心负载;电池环形图;高占用进程和快捷开关。 +- **进程**:按 CPU 或内存排序,辅助进程归并到所属应用。 +- **散热**:CPU、GPU、内存、电池、掌托温度,以及风扇。 +- **防休眠**:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可定时或不限时。 +- **清理**:扫描、预览、清理,见[清理](#清理)。 + +面板高度按内容自动计算,常见屏幕上不需要滚动;按 `Esc` 关闭。外观可选浅色、深色或跟随系统。 + +

+ 散热 + 防休眠 +

+ +## 风扇与睡眠 + +| 风扇模式 | 行为 | +|---|---| +| 自动 | 交还 macOS 温控 | +| 降温 | 固定在最低到最高转速之间的 60% | +| 强冷 | 最高转速 | +| 自定义 | 用滑块在风扇转速范围内自由设定 | + +自定义模式下,CPU 一旦达到安全温度(默认 95°C)就交还系统控制。退出 OpenStats 或应用崩溃时, +风扇恢复自动。合盖运行在使用电池且电量低于你设定的下限时自动关闭。 + +这两项需要系统权限,由一个通过 `SMAppService` 注册的小型辅助工具完成,首次使用时在 +“系统设置 › 通用 › 登录项”中批准一次。辅助工具只提供固定的几个操作:设置风扇目标转速、恢复自动、 +切换 `pmset disablesleep`、刷新 DNS 缓存、释放内存,**不执行任意命令**。它会校验调用方的代码签名, +应用断开连接时恢复风扇与睡眠设置;异常退出后,下次开机也会恢复。 + +## 清理 + +- **扫描范围**:应用缓存、日志与崩溃报告、浏览器缓存(Chrome、Edge、Brave、Arc、Firefox、Safari)、 + Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。 +- **先预览**:按类别显示大小,每条规则可展开查看具体项目,清理前再确认一次。缓存与日志直接删除, + 空间立即释放;下载目录的内容移到废纸篓。也可以一键改为全部先移到废纸篓。 +- **安全**:只处理白名单目录。钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰。正在运行的应用的缓存 + 会跳过,浏览器需要先退出,删除前每一项都会再校验一次。所有操作记录在 `~/Library/Logs/OpenStats/cleanup.log`。 +- **系统维护**:刷新 DNS 缓存、释放内存。已安装辅助工具时直接执行,否则请求一次管理员授权。 + +

清理

+ +## 你的数据 + +OpenStats **不发起任何网络连接**。指标来自本机的内核接口(`host_processor_info`、`host_statistics64`、 +`sysctl`)、IOKit 与 SMC。偏好设置保存在应用自己的 user defaults 中,不含个人信息。没有任何统计与上报。 + +## 构建与运行 + +需要 macOS 14+、**完整的 Xcode**(CommandLineTools 不含 SwiftUI 宏插件)以及 +[XcodeGen](https://github.com/yonaskolb/XcodeGen)。 + +```bash +brew install xcodegen +make run # 生成工程,编译 Debug 并启动 +make install # 编译 Release,安装到 /Applications 并启动 +make test # 单元测试:指标采集、SMC 编解码、清理安全边界 +make open # 生成工程并用 Xcode 打开 +``` + +菜单栏应用没什么可截图的窗口,可以用本机实时数据渲染各个界面: + +```bash +build/DerivedData/Build/Products/Debug/OpenStats.app/Contents/MacOS/OpenStats --snapshot ./snapshots +``` + +玻璃与半透明效果只在屏幕上存在。需要测量面板打开时的资源占用,用 `--show-panel` 启动,面板会自动展开并固定。 + +修改 `CHANGELOG.md` 后运行 `python3 Scripts/sync_changelog.py`,更新上方的“最近更新”和活跃度图。 + +## 分发 + +只走 Developer ID,不上 App Store——沙盒不允许访问 SMC、读取其他应用的缓存、安装特权辅助工具, +而这正是大部分功能。 + +| 你拥有的 | 别人打开时看到的 | +|---|---| +| 什么都没有 | ad-hoc 签名,只能在你自己的 Mac 上运行。别人会看到 *“OpenStats 已损坏”*。 | +| Developer ID 证书 | 启用 Hardened Runtime。别人会看到 *“Apple 无法检查其是否包含恶意软件”*。 | +| 证书 + 公证 | Gatekeeper 放行,只有常规的 *“从互联网下载”* 提示。 | + +发布前请在 `HelperShared/HelperProtocol.swift` 中填写 Team ID,让辅助工具只接受由该团队签名的调用方。 + +## 架构 + +- `Packages/OpenStatsKit/Sources/SMC`——SMC 读写、风扇控制、温度传感器发现。 +- `Packages/OpenStatsKit/Sources/Metrics`——CPU、内存、网络、GPU、磁盘、电池、进程、传感器的采集器, + 以及只采集屏幕上需要的指标的 `MetricsHub`。 +- `Packages/OpenStatsKit/Sources/Cleaner`——清理规则、安全守卫、扫描与执行。 +- `Packages/OpenStatsKit/Sources/HelperShared`——XPC 协议与系统维护命令。 +- `Packages/OpenStatsKit/Sources/OpenStatsUI`——设计 token、面板、设置窗口、菜单栏绘制。 +- `Helper/`——特权辅助工具及其 launchd 配置;`App/`——应用入口。 + +更多内容见 [ARCHITECTURE.md](ARCHITECTURE.md)。应用的每一项改动都按日期记录在 [CHANGELOG.md](CHANGELOG.md)。 + +## 致谢 + +OpenStats 基于以下开源项目,谢谢。 + +| 项目 | 作者 | 许可证 | OpenStats 借鉴了什么 | +|---|---|---|---| +| [Stats](https://github.com/exelban/stats) | Serhiy Mytrovtsiy | MIT | SMC 访问、Apple Silicon 风扇解锁流程、菜单栏迷你样式的字号参数 | +| [Mole](https://github.com/tw93/Mole) | tw93 | GPL-3.0 | 哪些目录值得清理、哪些绝对不能碰;清理模块为独立的 Swift 实现,不含 Mole 代码 | +| [QuotaBar](https://github.com/gentpan/quotabar) | GiantAccel, LLC | MIT | 本 README 的版式、更新日志同步与活跃度图脚本 | + +详见 [ThirdPartyNotices.md](ThirdPartyNotices.md)。 + +OpenStats 是独立的第三方应用,与 Apple 及文中提到的其他公司没有隶属、认可或赞助关系。相关名称与标志归各自所有者所有。 + +## 许可证 + +MIT,见 [LICENSE](LICENSE)。 diff --git a/Scripts/sync_changelog.py b/Scripts/sync_changelog.py new file mode 100644 index 0000000..72c9947 --- /dev/null +++ b/Scripts/sync_changelog.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Puts CHANGELOG.md where people look, and draws the commit calendar. + + python3 Scripts/sync_changelog.py + +Rewrites, from CHANGELOG.md and `git log`: + +- the "recent updates" block in README.md and README.zh-CN.md, between + and ; +- Assets/readme/activity.svg and activity.zh.svg, 26 weeks of commits. + +Run it after every CHANGELOG edit. Standard library only, so it runs on a +stock Mac and in CI. Adapted from QuotaBar's script of the same name. +""" + +import argparse +import datetime +import math +import pathlib +import re +import subprocess +from collections import Counter + +ROOT = pathlib.Path(__file__).resolve().parent.parent +RECENT_DAYS = 3 +WEEKS = 26 + +KIND_EN = {"新增": "Added", "样式": "Style", "修复": "Fixed"} + + +# ── CHANGELOG.md ────────────────────────────────────────────────────────── + +def parse(text): + """Releases, newest first: each with its days, each day with its groups. + + "## 未发布" holds "### YYYY-MM-DD" days; "## 0.1.0 · 2026-09-20" holds its + groups directly, which count as one day on the release date. + """ + releases, release, day, group = [], None, None, None + for line in text.splitlines(): + heading = re.match(r"^## (.+)$", line) + dated = re.match(r"^### (\d{4}-\d{2}-\d{2})\s*$", line) + kind = re.match(r"^#### (.+)$", line) + if heading: + title = heading.group(1).strip() + version = re.match(r"^v?([\d.]+)\s*·\s*(\d{4}-\d{2}-\d{2})$", title) + release = { + "version": version.group(1) if version else None, + "date": version.group(2) if version else None, + "days": [], + } + releases.append(release) + day = {"date": release["date"], "groups": []} if version else None + if day: + release["days"].append(day) + group = None + elif dated and release is not None: + # A release that kept its dated days: drop the empty day that + # stood in for the release date. + if release["days"] and not release["days"][-1]["groups"]: + release["days"].pop() + day = {"date": dated.group(1), "groups": []} + release["days"].append(day) + group = None + elif kind and day is not None: + group = {"kind": kind.group(1).strip(), "items": []} + day["groups"].append(group) + elif line.startswith("- ") and group is not None: + group["items"].append(line[2:].strip()) + elif line.startswith(" ") and line.strip() and group is not None and group["items"]: + group["items"][-1] += line.strip() + for release in releases: + release["days"] = [d for d in release["days"] if any(g["items"] for g in d["groups"])] + return [r for r in releases if r["days"]] + + +def day_entries(releases): + """Every day, newest first, with the release it belongs to.""" + days = [(release, day) for release in releases for day in release["days"]] + days.sort(key=lambda pair: pair[1]["date"] or "", reverse=True) + return days + + +def item_count(day): + return sum(len(g["items"]) for g in day["groups"]) + + +def counts(day, en): + parts = [] + for g in day["groups"]: + n = len(g["items"]) + if en: + parts.append(f"{n} {KIND_EN.get(g['kind'], g['kind']).lower()}") + else: + parts.append(f"{g['kind']} {n}") + return " · ".join(parts) + + +def latest_release(releases): + return next((r for r in releases if r["version"]), None) + + +def unreleased(releases): + return next((r for r in releases if not r["version"]), None) + + +# ── README ──────────────────────────────────────────────────────────────── + +def readme_block(releases, en): + latest = latest_release(releases) + pending = unreleased(releases) + pending_count = sum(item_count(d) for d in pending["days"]) if pending else 0 + lines = [""] + lines.append("" + if en else "") + head = [] + if latest: + head.append(f"Latest release **{latest['version']}** ({latest['date']})" if en + else f"最新版本 **{latest['version']}**({latest['date']})") + elif en: + head.append("Not released yet") + else: + head.append("尚未发布正式版本") + if pending_count: + head.append(f"**{pending_count}** changes in development" if en + else f"开发中 **{pending_count}** 项改动尚未发布") + head.append("[full changelog](CHANGELOG.md) (kept in Chinese)" if en else "[完整更新日志](CHANGELOG.md)") + lines += ["", " · ".join(head), ""] + for index, (release, day) in enumerate(day_entries(releases)[:RECENT_DAYS]): + label = release["version"] or ("Unreleased" if en else "未发布") + opened = " open" if index == 0 else "" + lines.append(f"") + lines.append(f"{day['date']} · {label} · {counts(day, en)}") + lines.append("") + for g in day["groups"]: + lines.append(f"**{KIND_EN.get(g['kind'], g['kind']) if en else g['kind']}**") + lines.append("") + lines += [f"- {item}" for item in g["items"]] + lines.append("") + lines.append("") + lines.append("") + lines.append("") + return "\n".join(lines) + + +def replace_block(path, block, start="", end=""): + text = path.read_text(encoding="utf-8") + pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.S) + if not pattern.search(text): + raise SystemExit(f"{path.relative_to(ROOT)}: no {start} … {end} markers") + updated = pattern.sub(lambda _: block, text, count=1) + if updated != text: + path.write_text(updated, encoding="utf-8") + return True + return False + + +# ── Commit calendar ─────────────────────────────────────────────────────── + +def commit_days(): + dates = subprocess.run( + ["git", "log", "--format=%ad", "--date=short"], + cwd=ROOT, capture_output=True, text=True, check=True).stdout.split() + return Counter(dates) + + +def activity_svg(per_day, today, en): + cell, gap, left, top = 11, 3, 30, 18 + step = cell + gap + # Columns are weeks starting Sunday, the last one holding today. + end = today + datetime.timedelta(days=(5 - today.weekday()) % 7) # the Saturday ending this week + start = end - datetime.timedelta(days=WEEKS * 7 - 1) + days = [start + datetime.timedelta(days=i) for i in range(WEEKS * 7)] + window = {d: per_day.get(d.isoformat(), 0) for d in days if d <= today} + total = sum(window.values()) + peak = max(window.values(), default=0) + palette = ["rgba(139,148,158,0.18)", "#9be9a8", "#40c463", "#30a14e", "#216e39"] + + def level(n): + return 0 if n == 0 or peak == 0 else max(1, min(4, math.ceil(4 * n / peak))) + + width = left + WEEKS * step + height = top + 7 * step + 40 + font = 'font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif"' + parts = [f''] + title = (f"{total} commits in the last {WEEKS} weeks" if en else f"近 {WEEKS} 周共 {total} 次提交") + parts.append(f"{title}") + parts.append(f'') + months_en = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + last_month = None + for w in range(WEEKS): + first = days[w * 7] + if first.month != last_month and first.day <= 7: + label = months_en[first.month - 1] if en else f"{first.month}月" + parts.append(f'{label}') + last_month = first.month + elif last_month is None: + last_month = first.month + for row, label in ((1, "Mon" if en else "一"), (3, "Wed" if en else "三"), (5, "Fri" if en else "五")): + parts.append(f'{label}') + parts.append("") + for i, d in enumerate(days): + if d > today: + continue + n = window[d] + x, y = left + (i // 7) * step, top + (i % 7) * step + tip = f"{n} commits on {d.isoformat()}" if en else f"{d.isoformat()}:{n} 次提交" + parts.append(f'{tip}') + # The legend under the grid on the right, the caption on its own line + # below: side by side they collide in English. + base = top + 7 * step + 14 + caption = (f"{total} commits in the last {WEEKS} weeks · through {today.isoformat()}" if en + else f"近 {WEEKS} 周共 {total} 次提交 · 截至 {today.isoformat()}") + parts.append(f'') + parts.append(f'{caption}') + legend_x = width - 5 * step - 34 + parts.append(f'{"Less" if en else "少"}') + for k in range(5): + parts.append(f'') + parts.append(f'{"More" if en else "多"}') + parts.append("") + return "\n".join(parts) + "\n" + + +def write_if_changed(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists() and path.read_text(encoding="utf-8") == content: + return False + path.write_text(content, encoding="utf-8") + return True + + +def main(): + parser = argparse.ArgumentParser(description="Sync CHANGELOG.md into the READMEs and the activity chart.") + parser.add_argument("--changelog", default=str(ROOT / "CHANGELOG.md"), + help="read this file instead, e.g. the committed copy while other edits are pending") + parser.add_argument("--through-today", action="store_true", + help="include today's commits in the calendar (default stops at yesterday)") + args = parser.parse_args() + releases = parse(pathlib.Path(args.changelog).read_text(encoding="utf-8")) + changed = [] + if replace_block(ROOT / "README.md", readme_block(releases, en=True)): + changed.append("README.md") + if replace_block(ROOT / "README.zh-CN.md", readme_block(releases, en=False)): + changed.append("README.zh-CN.md") + # The calendar runs through yesterday by default: a finished day does not + # change, so running this again after today's commits leaves the chart + # alone rather than redrawing it with every commit that records the redraw. + today = datetime.date.today() + if not args.through_today: + today -= datetime.timedelta(days=1) + per_day = commit_days() + for name, en in (("activity.svg", True), ("activity.zh.svg", False)): + if write_if_changed(ROOT / "Assets" / "readme" / name, activity_svg(per_day, today, en)): + changed.append(f"Assets/readme/{name}") + print("已更新:" + "、".join(changed) if changed else "已是最新") + + +if __name__ == "__main__": + main()