菜单栏风格、面板浅色与深色主题,修复面板打不开

- 菜单栏八种风格整体套用、可按指标单独指定,网速三种样式,悬停显示完整读数,字号缩小并统一
- 面板浅色、深色实色主题与一键切换,液态玻璃改为可选
- 修复关闭液态玻璃后面板内容尺寸翻倍导致看起来打不开,首次打开时高度偏小
- Developer ID 自动签名,辅助工具按自身签名团队校验调用方,新增发布脚本
- 更新日志、README、截图同步
This commit is contained in:
gentpan committed 2026-09-13 04:09:02 +05:00
1 parent 7e8fffbe9e
commit dc5c987b96
28 files changed
+787 -264

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ xcuserdata/
.build/
.swiftpm/
Packages/OpenStatsKit/Package.resolved
dist/
+6 -3
View File
@@ -29,9 +29,12 @@ 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.
`project.yml` defaults to ad-hoc signing so the project opens anywhere. The Makefile passes
the first Developer ID Application identity from the keychain (and `--timestamp` for Release)
when there is one. `make release` runs `Scripts/release.sh`: build, verify team, timestamp and
hardened runtime on both binaries, notarize and staple the app, build and notarize the DMG,
and write a Homebrew cask. The helper derives its client requirement from its own signing
team at run time, so no team ID is hard-coded.
## Sampling
+3 -3
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img">
<title>4 commits in the last 26 weeks</title>
<title>6 commits in the last 26 weeks</title>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e">
<text x="58" y="12">Apr</text>
<text x="114" y="12">May</text>
@@ -186,9 +186,9 @@
<rect x="366" y="74" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>0 commits on 2026-09-10</title></rect>
<rect x="366" y="88" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>0 commits on 2026-09-11</title></rect>
<rect x="366" y="102" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>0 commits on 2026-09-12</title></rect>
<rect x="380" y="18" width="11" height="11" rx="2" fill="#216e39"><title>4 commits on 2026-09-13</title></rect>
<rect x="380" y="18" width="11" height="11" rx="2" fill="#216e39"><title>6 commits on 2026-09-13</title></rect>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e">
<text x="30" y="146">4 commits in the last 26 weeks · through 2026-09-13</text>
<text x="30" y="146">6 commits in the last 26 weeks · through 2026-09-13</text>
<text x="286" y="130" text-anchor="end">Less</text>
<rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/>
<rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+3 -3
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="394" height="156" viewBox="0 0 394 156" role="img">
<title>近 26 周共 4 次提交</title>
<title>近 26 周共 6 次提交</title>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="9" fill="#8b949e">
<text x="58" y="12">4月</text>
<text x="114" y="12">5月</text>
@@ -186,9 +186,9 @@
<rect x="366" y="74" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>2026-09-10:0 次提交</title></rect>
<rect x="366" y="88" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>2026-09-11:0 次提交</title></rect>
<rect x="366" y="102" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"><title>2026-09-12:0 次提交</title></rect>
<rect x="380" y="18" width="11" height="11" rx="2" fill="#216e39"><title>2026-09-13:4 次提交</title></rect>
<rect x="380" y="18" width="11" height="11" rx="2" fill="#216e39"><title>2026-09-13:6 次提交</title></rect>
<g font-family="-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,PingFang SC,sans-serif" font-size="10" fill="#8b949e">
<text x="30" y="146">近 26 周共 4 次提交 · 截至 2026-09-13</text>
<text x="30" y="146">近 26 周共 6 次提交 · 截至 2026-09-13</text>
<text x="286" y="130" text-anchor="end">少</text>
<rect x="290" y="120" width="11" height="11" rx="2" fill="rgba(139,148,158,0.18)"/>
<rect x="304" y="120" width="11" height="11" rx="2" fill="#9be9a8"/>

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 325 KiB

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 208 KiB

+10 -4
View File
@@ -9,25 +9,29 @@
#### 新增
- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。CPU、GPU、内存可以分别选择数字、柱状图、圆环、饼图、柱状图加数字五种样式;网速两行显示,绿点上传、蓝点下载,带完整单位。
- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。
- 菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。
- 网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。
- 鼠标悬停在菜单栏图标上显示完整读数。
- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。
- 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。
- 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。
- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作,并校验调用方签名;异常退出后下次开机自动恢复风扇与睡眠设置。
- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作;运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。
- 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。
- 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。
- 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。
- 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。
- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。
- 外观可选跟随系统、浅色、深色;macOS 26 起面板使用液态玻璃。
- 面板提供浅色、深色两套实色主题,顶部的太阳 / 月亮按钮一键切换,也可以跟随系统;液态玻璃背景改为可选(macOS 26 起)。
- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。
#### 样式
- 菜单栏字号对齐常见菜单栏监控工具:标签 7pt 细体、数值 12pt 常规、网速 9pt 细体,指标之间留出更宽的间距。
- 菜单栏字号缩小并统一:两行布局为 7pt 标签加 10pt 数值,单行布局 11pt,网速两行 9pt;每种风格内部只用一套字号,指标之间留出更宽的间距。
- 柱状历史去掉满高的灰色底槽,只保留一条淡基线,负载起伏更清楚。
- 面板加宽为三列布局,高度按内容自动计算,常见屏幕上不需要滚动。
- 更换应用图标,面板左上角和关于页使用同一图标。
- 首次启动默认在菜单栏显示 CPU、内存与网速。
@@ -38,3 +42,5 @@
- 面板打开后高度会再调整一次、内容轻微回弹,看起来在抖动;面板打开时点击菜单栏图标关闭,会立刻又被打开。
- 风扇被其他程序设为手动模式时,误显示为由 OpenStats 控制。
- 风扇转速、PID 等数字出现千分位分隔符,例如 1,350 RPM。
- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。
- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。
+3 -1
View File
@@ -16,6 +16,8 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
private let listener = NSXPCListener(machServiceName: HelperConstants.machServiceName)
private let stateURL = URL(fileURLWithPath: "/Library/Application Support/OpenStats/helper-state.plist")
private let idleTimeout: TimeInterval = 30
/// 只接受与辅助工具自身同一团队签名的调用方
private let clientRequirement = HelperConstants.clientRequirement(teamIdentifier: CodeSigningInfo.currentTeamIdentifier())
private var connections: Set<ObjectIdentifier> = []
private var fans: FanControl?
@@ -38,7 +40,7 @@ final class HelperService: NSObject, NSXPCListenerDelegate, OpenStatsHelperProto
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
// 系统在收到消息前校验调用方签名,不符合要求的连接会被直接失效
connection.setCodeSigningRequirement(HelperConstants.clientRequirement)
connection.setCodeSigningRequirement(clientRequirement)
connection.exportedInterface = NSXPCInterface(with: OpenStatsHelperProtocol.self)
connection.exportedObject = self
+16 -2
View File
@@ -4,7 +4,17 @@ CONFIG ?= Debug
DERIVED := build/DerivedData
APP := $(DERIVED)/Build/Products/$(CONFIG)/OpenStats.app
.PHONY: generate build run install stop test snapshot open clean
# 与 QuotaBar 相同:钥匙串里有 Developer ID 证书就自动用它签名,否则退回 ad-hoc(仅限本机)。
# 也可以用 SIGN_ID="..." 显式指定。
SIGN_ID ?= $(shell security find-identity -v -p codesigning 2>/dev/null | grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)".*/\1/')
TEAM_ID := $(shell echo '$(SIGN_ID)' | sed -nE 's/.*\(([A-Z0-9]+)\)$$/\1/p')
ifneq ($(strip $(TEAM_ID)),)
# Release 加安全时间戳,公证要求如此,证书过期后签名依然有效
SIGN_FLAGS := CODE_SIGN_IDENTITY="$(SIGN_ID)" DEVELOPMENT_TEAM=$(TEAM_ID) \
$(if $(filter Release,$(CONFIG)),OTHER_CODE_SIGN_FLAGS=--timestamp CODE_SIGN_INJECT_BASE_ENTITLEMENTS=NO)
endif
.PHONY: generate build run install release stop test snapshot open clean
## 由 project.yml 生成 Xcode 工程
generate:
@@ -13,7 +23,7 @@ generate:
## 编译 App(含辅助工具)
build: generate
xcodebuild -project $(PROJECT) -scheme $(SCHEME) -configuration $(CONFIG) \
-derivedDataPath $(DERIVED) -destination 'platform=macOS' -quiet build
-derivedDataPath $(DERIVED) -destination 'platform=macOS' -quiet $(SIGN_FLAGS) build
## 编译并启动(会先结束正在运行的 OpenStats)
run: build stop
@@ -28,6 +38,10 @@ install:
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f /Applications/OpenStats.app
open /Applications/OpenStats.app
## 发布:签名、公证、装订、打包 DMG,生成 Homebrew cask(见 Scripts/release.sh)
release:
./Scripts/release.sh
stop:
-pkill -x OpenStats
@@ -1,4 +1,5 @@
import Foundation
import Security
public enum HelperConstants {
public static let machServiceName = "com.openstats.helper"
@@ -7,17 +8,29 @@ public enum HelperConstants {
/// 与 App 版本同步;App 发现辅助工具版本不一致时提示重新安装
public static let protocolVersion = 2
/// 用 Developer ID 签名后填入 Team ID,辅助工具会据此严格校验调用方。
/// 为空时只校验 bundle identifier,仅适用于本地开发。
public static let teamIdentifier = ""
public static var clientRequirement: String {
/// 辅助工具对调用方的签名要求:与辅助工具自身同一团队签名的 OpenStats。
/// ad-hoc 签名的开发构建没有团队,只能校验 bundle identifier。
public static func clientRequirement(teamIdentifier: String?) -> String {
let identifier = "identifier \"\(appBundleIdentifier)\""
guard !teamIdentifier.isEmpty else { return identifier }
guard let teamIdentifier, !teamIdentifier.isEmpty else { return identifier }
return identifier + " and anchor apple generic and certificate leaf[subject.OU] = \"\(teamIdentifier)\""
}
}
public enum CodeSigningInfo {
/// 当前进程签名所属的 Team ID;ad-hoc 签名时为 nil
public static func currentTeamIdentifier() -> String? {
var code: SecCode?
guard SecCodeCopySelf([], &code) == errSecSuccess, let code else { return nil }
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { return nil }
var information: CFDictionary?
guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
let dictionary = information as? [String: Any] else { return nil }
return dictionary[kSecCodeInfoTeamIdentifier as String] as? String
}
}
/// 辅助工具以 root 运行,只暴露固定的少量操作,不提供任意命令执行能力。
/// 所有回调中的 `String?` 为错误描述,nil 表示成功。
@objc public protocol OpenStatsHelperProtocol {
@@ -27,6 +27,8 @@ public actor MetricsHub {
private var handler: (@MainActor @Sendable (MetricsSnapshot) -> Void)?
private var loop: Task<Void, Never>?
private var paused = false
/// 启动后第一次采集全部指标,面板首次打开时各页面已有数据,高度一次测准
private var primed = false
private var lastDisk = Date.distantPast
private var lastBattery = Date.distantPast
@@ -66,6 +68,20 @@ public actor MetricsHub {
handler = nil
}
private static func everything(interval: Duration) -> MetricsDemand {
var demand = MetricsDemand()
demand.interval = interval
demand.memory = true
demand.network = true
demand.gpu = true
demand.disk = true
demand.battery = true
demand.processes = true
demand.temperatures = Set(TemperatureGroup.allCases)
demand.fans = true
return demand
}
private func restart() {
loop?.cancel()
guard !paused, handler != nil else {
@@ -87,6 +103,8 @@ public actor MetricsHub {
private func collect() -> MetricsSnapshot {
let now = Date()
var snapshot = MetricsSnapshot()
let demand = primed ? self.demand : Self.everything(interval: self.demand.interval)
primed = true
snapshot.cpu = cpu.sample()
if demand.memory { snapshot.memory = memory.sample() }
@@ -71,6 +71,9 @@ public final class AppController: NSObject, NSApplicationDelegate {
private func refreshStatusItem() {
statusItem.button?.image = MenuBarRenderer.image(for: model)
// 悬停时显示完整读数
statusItem.button?.toolTip = MenuBarReading(model: model)
.tooltip(items: model.settings.orderedMenuBarItems, fahrenheit: model.settings.useFahrenheit)
}
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
@@ -107,7 +110,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
private func setUpPanel() {
let model = self.model
panel = StatusPanel(content: { PanelRootView().environment(model) },
measuring: { PanelRootView().environment(model).environment(\.isSnapshot, true) })
measuring: { PanelRootView().environment(model).environment(\.isSnapshot, true) },
usesGlass: { model.settings.panelGlass })
panel.onVisibilityChange = { [weak self] visible in
guard let self else { return }
self.model.isPanelVisible = visible
@@ -142,7 +146,9 @@ public final class AppController: NSObject, NSApplicationDelegate {
withObservationTracking {
_ = model.demand
_ = model.settings.menuBarItems
_ = model.settings.gaugeStyles
_ = model.settings.menuBarStyle
_ = model.settings.networkStyle
_ = model.settings.styleOverrides
_ = model.settings.colorizeHighLoad
_ = model.settings.useFahrenheit
_ = model.keepAwake.isActive
@@ -1,70 +1,156 @@
import AppKit
import Metrics
/// 菜单栏上要画的读数,与数据来源解耦,设置页预览可以用示例数据绘制
struct MenuBarReading {
var cpu: Double?
var cpuHistory: [Double] = []
var gpu: Double?
var gpuHistory: [Double] = []
var memory: Double?
var memoryHistory: [Double] = []
var upload: Double?
var download: Double?
var temperature: Double?
var fanRPM: Double?
var keepAwake = false
@MainActor
init(model: AppModel) {
let store = model.store
cpu = store.cpu?.total
cpuHistory = store.cpuTotal.elements
gpu = store.gpu?.utilization
gpuHistory = store.gpuHistory.elements
memory = store.memory?.usedFraction
memoryHistory = store.memoryHistory.elements
upload = store.network?.uploadBytesPerSecond
download = store.network?.downloadBytesPerSecond
temperature = store.sensors?.temperature(.cpu)?.maximum
fanRPM = store.fastestFan?.current
keepAwake = model.keepAwake.isActive
}
init() {}
/// 设置页预览用的示例读数
static let sample: MenuBarReading = {
var reading = MenuBarReading()
reading.cpu = 0.34
reading.cpuHistory = [0.18, 0.22, 0.41, 0.36, 0.28, 0.52, 0.47, 0.31, 0.29, 0.34]
reading.gpu = 0.22
reading.gpuHistory = [0.05, 0.12, 0.30, 0.18, 0.10, 0.26, 0.40, 0.21, 0.15, 0.22]
reading.memory = 0.68
reading.memoryHistory = Array(repeating: 0.68, count: 10)
reading.upload = 12 * 1024
reading.download = 1.4 * 1024 * 1024
reading.temperature = 52
reading.fanRPM = 1840
return reading
}()
func percent(_ item: MenuBarItem) -> (value: Double?, history: [Double]) {
switch item {
case .cpu: (cpu, cpuHistory)
case .gpu: (gpu, gpuHistory)
case .memory: (memory, memoryHistory)
default: (nil, [])
}
}
/// 鼠标悬停时的完整读数
func tooltip(items: [MenuBarItem], fahrenheit: Bool) -> String {
items.compactMap { item -> String? in
switch item {
case .cpu: cpu.map { "CPU \(Format.percent($0))" }
case .gpu: gpu.map { "GPU \(Format.percent($0))" }
case .memory: memory.map { "内存 \(Format.percent($0))" }
case .network:
upload.flatMap { up in download.map { "上传 \(Format.menuBarRate(up)) · 下载 \(Format.menuBarRate($0))" } }
case .temperature: temperature.map { "CPU 温度 \(Format.temperature($0, fahrenheit: fahrenheit))" }
case .fan: fanRPM.map { "风扇 \(Format.rpm($0))" }
}
}
.joined(separator: "\n")
}
}
/// 菜单栏图标自绘:每次刷新只生成一张小图,避免在菜单栏里重建 SwiftUI 视图
@MainActor
enum MenuBarRenderer {
/// 菜单栏只有 22pt 高,字号与基线对齐 Stats 的迷你样式:标签 7pt 细体、数值 12pt 常规、网速两行 9pt 细体
/// 菜单栏只有 22pt 高,字号刻意小于面板的字号体系,与 Stats 等菜单栏工具同一量级。
/// 每种风格内部只用这一套排版:两行布局是 7pt 标签 + 10pt 数值,单行布局 11pt,网速两行 9pt。
private enum Metrics {
@MainActor static let labelFont = NSFont.systemFont(ofSize: 7, weight: .light)
@MainActor static let valueFont = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular)
@MainActor static let networkFont = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .light)
static let upperBaseline: CGFloat = 12 // 上行基线(距底部)
static let lowerBaseline: CGFloat = 1 // 下行基线
@MainActor static let labelFont = NSFont.systemFont(ofSize: 7, weight: .medium)
@MainActor static let stackedValueFont = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .medium)
@MainActor static let inlineLabelFont = NSFont.systemFont(ofSize: 10, weight: .regular)
@MainActor static let inlineValueFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .medium)
@MainActor static let networkFont = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
static let barHeight: CGFloat = 22
static let upperBaseline: CGFloat = 13
static let lowerBaseline: CGFloat = 3
static let symbolSize: CGFloat = 11
static let itemGap = DS.Space.s3
static let innerGap = DS.Space.s1
static let barCount = 10
static let barWidth: CGFloat = 2
static let barGap: CGFloat = 1
static let chartHeight: CGFloat = 16
static let smallChartHeight: CGFloat = 9
static let gaugeDiameter: CGFloat = 14
static let ringWidth: CGFloat = 2.5
static let networkDot: CGFloat = 6
static let historyCount = 10
static let historyBarWidth: CGFloat = 2
static let historyBarGap: CGFloat = 1
static let chartHeight: CGFloat = 13
static let gaugeDiameter: CGFloat = 13
static let ringWidth: CGFloat = 2
static let meterSize = NSSize(width: 5, height: 13)
static let dotSize: CGFloat = 5
static let trackAlpha: CGFloat = 0.25
static let highLoad = 0.85
static let warningLevel = 0.6
static let highLevel = 0.85
}
private struct Segment {
let width: CGFloat
/// 是否含有彩色元素(需要非模板图)
var colored = false
let draw: (NSRect) -> Void
}
static func image(for model: AppModel) -> NSImage {
let settings = model.settings
let height = NSStatusBar.system.thickness
return image(reading: MenuBarReading(model: model),
items: settings.orderedMenuBarItems,
style: { settings.style(for: $0) },
networkStyle: settings.networkStyle,
colorizeHighLoad: settings.colorizeHighLoad,
fahrenheit: settings.useFahrenheit)
}
static func image(reading: MenuBarReading,
items: [MenuBarItem],
style: (MenuBarItem) -> MenuBarStyle,
networkStyle: NetworkMenuStyle,
colorizeHighLoad: Bool,
fahrenheit: Bool) -> NSImage {
var segments: [Segment] = []
if model.keepAwake.isActive {
segments.append(symbolSegment("cup.and.saucer.fill"))
}
var usesColor = false
for item in settings.orderedMenuBarItems {
let (segment, colored) = self.segment(for: item, model: model)
segments.append(segment)
usesColor = usesColor || colored
}
if segments.isEmpty {
segments.append(symbolSegment("waveform.path.ecg"))
if reading.keepAwake { segments.append(symbolSegment("cup.and.saucer.fill")) }
for item in items {
segments.append(segment(for: item, reading: reading, style: style(item), networkStyle: networkStyle,
colorizeHighLoad: colorizeHighLoad, fahrenheit: fahrenheit))
}
if segments.isEmpty { segments.append(symbolSegment("waveform.path.ecg")) }
let totalWidth = segments.reduce(0) { $0 + $1.width } + CGFloat(segments.count - 1) * Metrics.itemGap
let image = NSImage(size: NSSize(width: ceil(totalWidth), height: height), flipped: false) { rect in
let width = segments.reduce(0) { $0 + $1.width } + CGFloat(segments.count - 1) * Metrics.itemGap
let image = NSImage(size: NSSize(width: ceil(width), height: Metrics.barHeight), flipped: false) { rect in
var x: CGFloat = 0
for segment in segments {
segment.draw(NSRect(x: x, y: 0, width: segment.width, height: rect.height))
segment.draw(NSRect(x: x, y: rect.minY, width: segment.width, height: rect.height))
x += segment.width + Metrics.itemGap
}
return true
}
// 模板图自动适配浅色 / 深色菜单栏;含彩色元素时保留真实颜色,文字用 labelColor 跟随外观
image.isTemplate = !usesColor
image.accessibilityDescription = accessibilityText(for: model)
// 模板图自动适配浅色 / 深色菜单栏;含彩色元素时保留真实颜色,文字仍用动态的 labelColor
image.isTemplate = !segments.contains(where: \.colored)
return image
}
/// 设置页预览用:按指定的浅色 / 深色菜单栏外观绘制
/// 设置页预览:按指定外观绘制到不透明底色上
static func preview(_ image: NSImage, dark: Bool) -> NSImage {
let appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
return NSImage(size: image.size, flipped: false) { rect in
@@ -82,137 +168,128 @@ enum MenuBarRenderer {
// MARK: 各指标
private static func segment(for item: MenuBarItem, model: AppModel) -> (Segment, colored: Bool) {
let store = model.store
let settings = model.settings
private static func segment(for item: MenuBarItem, reading: MenuBarReading, style: MenuBarStyle,
networkStyle: NetworkMenuStyle, colorizeHighLoad: Bool, fahrenheit: Bool) -> Segment {
switch item {
case .cpu, .gpu, .memory:
let value: Double?
let history: [Double]
switch item {
case .cpu: (value, history) = (store.cpu?.total, store.cpuTotal.elements)
case .gpu: (value, history) = (store.gpu?.utilization, store.gpuHistory.elements)
default: (value, history) = (store.memory?.usedFraction, store.memoryHistory.elements)
}
let alert = settings.colorizeHighLoad && (value ?? 0) >= Metrics.highLoad
return (gaugeSegment(label: item.menuBarLabel, value: value, history: history,
style: settings.gaugeStyle(for: item), alert: alert), alert)
let (value, history) = reading.percent(item)
return percentSegment(item: item, value: value, history: history, style: style, colorizeHighLoad: colorizeHighLoad)
case .network:
return (networkSegment(store.network), true)
return networkSegment(upload: reading.upload, download: reading.download, style: networkStyle)
case .temperature:
let text = store.sensors?.temperature(.cpu).map { reading -> String in
let celsius = reading.maximum
let degrees = settings.useFahrenheit ? celsius * 9 / 5 + 32 : celsius
let text = reading.temperature.map { celsius -> String in
let degrees = fahrenheit ? celsius * 9 / 5 + 32 : celsius
return "\(Int(degrees.rounded()))°"
} ?? "—"
return (stackedSegment(label: item.menuBarLabel, value: text, sample: "100°"), false)
return textSegment(item: item, value: text, sample: "100°", style: style)
case .fan:
let text = store.fastestFan.map { "\(Int($0.current))" } ?? "—"
return (stackedSegment(label: item.menuBarLabel, value: text, sample: "8888"), false)
return textSegment(item: item, value: reading.fanRPM.map { "\(Int($0))" } ?? "—", sample: "8888", style: style)
}
}
private static func gaugeSegment(label: String, value: Double?, history: [Double], style: GaugeStyle, alert: Bool) -> Segment {
private static func percentSegment(item: MenuBarItem, value: Double?, history: [Double],
style: MenuBarStyle, colorizeHighLoad: Bool) -> Segment {
let fraction = min(1, max(0, value ?? 0))
let text = value.map { Format.percent($0) } ?? "—"
let fraction = value ?? 0
let stacked = stackedSegment(label: label, value: text, sample: "100%", alert: alert)
let alert = colorizeHighLoad && fraction >= Metrics.highLevel
let stacked = stackedText(label: item.menuBarLabel, value: text, sample: "100%", alert: alert)
switch style {
case .value:
case .stacked:
return stacked
case .bars:
return barsWithLabel(label: label, history: history, alert: alert)
case .barsAndValue:
return combine([barsSegment(history: history, height: Metrics.chartHeight, alert: alert), stacked])
case .inline:
return inlineText(label: item.menuBarLabel, value: text, sample: "100%", alert: alert)
case .icon:
return combine([symbolSegment(item.symbol), inlineValue(text, sample: "100%", alert: alert)])
case .ring:
return combine([ringSegment(fraction: fraction, alert: alert), stacked])
return combine([ring(fraction: fraction, alert: alert), stacked])
case .pie:
return combine([pieSegment(fraction: fraction, alert: alert), stacked])
return combine([pie(fraction: fraction, alert: alert), stacked])
case .history:
return combine([historyBars(history, alert: alert), stacked])
case .meter:
return combine([meter(fraction: fraction, alert: alert), stacked])
case .dot:
return combine([levelDot(fraction: fraction), stacked])
}
}
// MARK: 基础片段
/// 温度、风扇没有百分比图形:两行风格用标签 + 数值,单行风格与图标风格保持单行
private static func textSegment(item: MenuBarItem, value: String, sample: String, style: MenuBarStyle) -> Segment {
switch style {
case .inline: inlineText(label: item.menuBarLabel, value: value, sample: sample, alert: false)
case .icon: combine([symbolSegment(item.symbol), inlineValue(value, sample: sample, alert: false)])
default: stackedText(label: item.menuBarLabel, value: value, sample: sample, alert: false)
}
}
// MARK: 文字
private static func foreground(_ alert: Bool) -> NSColor {
alert ? .systemRed : .labelColor
}
/// 颜色必须保持动态(labelColor),在绘制时才按菜单栏外观解析;
/// 提前调用 withAlphaComponent 会按应用外观固定颜色,深色菜单栏上就会发黑
private static var labelAttributes: [NSAttributedString.Key: Any] {
[.font: Metrics.labelFont, .foregroundColor: NSColor.labelColor]
}
/// 上方小标签、下方数值,右对齐
private static func stackedSegment(label: String, value: String, sample: String, alert: Bool = false) -> Segment {
let labelAttributes = labelAttributes
let valueAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.valueFont, .foregroundColor: foreground(alert)]
let width = ceil([
(label as NSString).size(withAttributes: labelAttributes).width,
(value as NSString).size(withAttributes: valueAttributes).width,
(sample as NSString).size(withAttributes: valueAttributes).width,
].max() ?? 0)
/// 颜色保持动态(labelColor / secondaryLabelColor),在绘制时才按菜单栏外观解析
private static func stackedText(label: String, value: String, sample: String, alert: Bool) -> Segment {
let labelAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.labelFont, .foregroundColor: NSColor.secondaryLabelColor]
let valueAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.stackedValueFont, .foregroundColor: foreground(alert)]
let width = ceil(max(textWidth(label, labelAttributes), textWidth(value, valueAttributes), textWidth(sample, valueAttributes)))
return Segment(width: width) { rect in
drawText(label, attributes: labelAttributes, rightEdge: rect.maxX, baseline: rect.minY + Metrics.upperBaseline)
drawText(value, attributes: valueAttributes, rightEdge: rect.maxX, baseline: rect.minY + Metrics.lowerBaseline)
let base = baselineOrigin(in: rect)
drawText(label, labelAttributes, rightEdge: rect.maxX, baseline: base + Metrics.upperBaseline)
drawText(value, valueAttributes, rightEdge: rect.maxX, baseline: base + Metrics.lowerBaseline)
}
}
/// 以基线定位(与 Stats 相同的 draw(with:) 方式),右对齐
private static func drawText(_ text: String, attributes: [NSAttributedString.Key: Any],
rightEdge: CGFloat, baseline: CGFloat) {
private static func inlineText(label: String, value: String, sample: String, alert: Bool) -> Segment {
let labelAttributes: [NSAttributedString.Key: Any] = [.font: Metrics.inlineLabelFont, .foregroundColor: NSColor.secondaryLabelColor]
let labelWidth = ceil(textWidth(label, labelAttributes))
let valueSegment = inlineValue(value, sample: sample, alert: alert)
return Segment(width: labelWidth + Metrics.innerGap + valueSegment.width) { rect in
drawText(label, labelAttributes, rightEdge: rect.minX + labelWidth,
baseline: rect.midY - Metrics.inlineLabelFont.capHeight / 2)
valueSegment.draw(NSRect(x: rect.maxX - valueSegment.width, y: rect.minY, width: valueSegment.width, height: rect.height))
}
}
private static func inlineValue(_ value: String, sample: String, alert: Bool) -> Segment {
let attributes: [NSAttributedString.Key: Any] = [.font: Metrics.inlineValueFont, .foregroundColor: foreground(alert)]
let width = ceil(max(textWidth(value, attributes), textWidth(sample, attributes)))
return Segment(width: width) { rect in
drawText(value, attributes, rightEdge: rect.maxX, baseline: rect.midY - Metrics.inlineValueFont.capHeight / 2)
}
}
private static func textWidth(_ text: String, _ attributes: [NSAttributedString.Key: Any]) -> CGFloat {
(text as NSString).size(withAttributes: attributes).width
}
private static func drawText(_ text: String, _ attributes: [NSAttributedString.Key: Any], rightEdge: CGFloat, baseline: CGFloat) {
let string = NSAttributedString(string: text, attributes: attributes)
let width = string.size().width
string.draw(with: NSRect(x: rightEdge - width, y: baseline, width: width, height: 0))
}
private static func barsSegment(history: [Double], height: CGFloat, alert: Bool) -> Segment {
let recent = Array(history.suffix(Metrics.barCount))
let padded = Array(repeating: 0, count: Metrics.barCount - recent.count) + recent
let width = CGFloat(Metrics.barCount) * Metrics.barWidth + CGFloat(Metrics.barCount - 1) * Metrics.barGap
return Segment(width: width) { rect in
let baseline = rect.midY - height / 2
for (index, value) in padded.enumerated() {
let x = rect.minX + CGFloat(index) * (Metrics.barWidth + Metrics.barGap)
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setFill()
NSBezierPath(rect: NSRect(x: x, y: baseline, width: Metrics.barWidth, height: height)).fill()
foreground(alert && value >= Metrics.highLoad).setFill()
NSBezierPath(rect: NSRect(x: x, y: baseline, width: Metrics.barWidth,
height: height * min(1, max(0, value)))).fill()
}
}
/// 刘海屏的菜单栏更高,两行内容整体在按钮内垂直居中
private static func baselineOrigin(in rect: NSRect) -> CGFloat {
rect.midY - Metrics.barHeight / 2
}
/// 柱状图模式:上方小标签,下方矮柱
private static func barsWithLabel(label: String, history: [Double], alert: Bool) -> Segment {
let labelAttributes = labelAttributes
let bars = barsSegment(history: history, height: Metrics.smallChartHeight, alert: alert)
let width = max(bars.width, ceil((label as NSString).size(withAttributes: labelAttributes).width))
return Segment(width: width) { rect in
drawText(label, attributes: labelAttributes, rightEdge: rect.maxX, baseline: rect.minY + Metrics.upperBaseline)
// 矮柱放在标签下方的区域内
let area = NSRect(x: rect.maxX - bars.width, y: rect.minY, width: bars.width,
height: Metrics.upperBaseline - 1)
bars.draw(area)
}
}
// MARK: 图形
private static func ringSegment(fraction: Double, alert: Bool) -> Segment {
private static func ring(fraction: Double, alert: Bool) -> Segment {
Segment(width: Metrics.gaugeDiameter) { rect in
let inset = Metrics.ringWidth / 2
let circle = NSRect(x: rect.minX + inset, y: rect.midY - Metrics.gaugeDiameter / 2 + inset,
width: Metrics.gaugeDiameter - Metrics.ringWidth,
height: Metrics.gaugeDiameter - Metrics.ringWidth)
width: Metrics.gaugeDiameter - Metrics.ringWidth, height: Metrics.gaugeDiameter - Metrics.ringWidth)
let track = NSBezierPath(ovalIn: circle)
track.lineWidth = Metrics.ringWidth
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setStroke()
track.stroke()
let clamped = min(1, max(0, fraction))
guard clamped > 0 else { return }
guard fraction > 0 else { return }
let arc = NSBezierPath()
arc.appendArc(withCenter: NSPoint(x: circle.midX, y: circle.midY), radius: circle.width / 2,
startAngle: 90, endAngle: 90 - 360 * clamped, clockwise: true)
startAngle: 90, endAngle: 90 - 360 * fraction, clockwise: true)
arc.lineWidth = Metrics.ringWidth
arc.lineCapStyle = .round
foreground(alert).setStroke()
@@ -220,73 +297,131 @@ enum MenuBarRenderer {
}
}
private static func pieSegment(fraction: Double, alert: Bool) -> Segment {
private static func pie(fraction: Double, alert: Bool) -> Segment {
Segment(width: Metrics.gaugeDiameter) { rect in
let circle = NSRect(x: rect.minX, y: rect.midY - Metrics.gaugeDiameter / 2,
width: Metrics.gaugeDiameter, height: Metrics.gaugeDiameter)
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setFill()
NSBezierPath(ovalIn: circle).fill()
let clamped = min(1, max(0, fraction))
guard clamped > 0 else { return }
guard fraction > 0 else { return }
let center = NSPoint(x: circle.midX, y: circle.midY)
let wedge = NSBezierPath()
wedge.move(to: center)
wedge.appendArc(withCenter: center, radius: circle.width / 2,
startAngle: 90, endAngle: 90 - 360 * clamped, clockwise: true)
startAngle: 90, endAngle: 90 - 360 * fraction, clockwise: true)
wedge.close()
foreground(alert).setFill()
wedge.fill()
}
}
/// 最近 10 次采样的负载,每根柱子一次采样
private static func historyBars(_ history: [Double], alert: Bool) -> Segment {
let recent = Array(history.suffix(Metrics.historyCount))
let padded = Array(repeating: 0, count: Metrics.historyCount - recent.count) + recent
let width = CGFloat(Metrics.historyCount) * Metrics.historyBarWidth + CGFloat(Metrics.historyCount - 1) * Metrics.historyBarGap
return Segment(width: width) { rect in
let bottom = rect.midY - Metrics.chartHeight / 2
// 不画满高的底槽,只留一条淡基线,柱子的起伏才看得清
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setFill()
NSRect(x: rect.minX, y: bottom, width: width, height: 1).fill()
for (index, value) in padded.enumerated() where value > 0 {
let x = rect.minX + CGFloat(index) * (Metrics.historyBarWidth + Metrics.historyBarGap)
foreground(alert && value >= Metrics.highLevel).setFill()
NSRect(x: x, y: bottom, width: Metrics.historyBarWidth,
height: max(1, Metrics.chartHeight * min(1, value))).fill()
}
}
}
/// 竖向电量条
private static func meter(fraction: Double, alert: Bool) -> Segment {
Segment(width: Metrics.meterSize.width) { rect in
let frame = NSRect(x: rect.minX, y: rect.midY - Metrics.meterSize.height / 2,
width: Metrics.meterSize.width, height: Metrics.meterSize.height)
let radius = Metrics.meterSize.width / 3
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setFill()
NSBezierPath(roundedRect: frame, xRadius: radius, yRadius: radius).fill()
guard fraction > 0 else { return }
let filled = NSRect(x: frame.minX, y: frame.minY, width: frame.width,
height: max(radius * 2, frame.height * fraction))
foreground(alert).setFill()
NSBezierPath(roundedRect: filled, xRadius: radius, yRadius: radius).fill()
}
}
/// 按负载着色的状态点:绿色正常、橙色偏高、红色很高
private static func levelDot(fraction: Double) -> Segment {
var segment = Segment(width: Metrics.dotSize) { rect in
let color: NSColor = fraction >= Metrics.highLevel ? .systemRed
: fraction >= Metrics.warningLevel ? .systemOrange : .systemGreen
color.setFill()
NSBezierPath(ovalIn: NSRect(x: rect.minX, y: rect.midY - Metrics.dotSize / 2,
width: Metrics.dotSize, height: Metrics.dotSize)).fill()
}
segment.colored = true
return segment
}
private static func symbolSegment(_ name: String) -> Segment {
let configuration = NSImage.SymbolConfiguration(pointSize: Metrics.symbolSize, weight: .medium)
.applying(.init(paletteColors: [.labelColor]))
let symbol = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
.withSymbolConfiguration(configuration)
let symbol = NSImage(systemSymbolName: name, accessibilityDescription: nil)?.withSymbolConfiguration(configuration)
let size = symbol?.size ?? NSSize(width: Metrics.symbolSize, height: Metrics.symbolSize)
return Segment(width: ceil(size.width)) { rect in
symbol?.draw(in: NSRect(x: rect.minX, y: rect.midY - size.height / 2, width: size.width, height: size.height))
}
}
/// 两行:上传(绿点)在上,下载(蓝点)在下,数值右对齐
private static func networkSegment(_ rate: NetworkRate?) -> Segment {
let attributes: [NSAttributedString.Key: Any] = [.font: Metrics.networkFont, .foregroundColor: NSColor.labelColor]
let rows: [(String, NSColor)] = [
(rate.map { Format.menuBarRate($0.uploadBytesPerSecond) } ?? "— KB/s", DS.NetworkPalette.upload),
(rate.map { Format.menuBarRate($0.downloadBytesPerSecond) } ?? "— KB/s", DS.NetworkPalette.download),
]
let sampleWidth = ("999 KB/s" as NSString).size(withAttributes: attributes).width
let textWidth = ceil(rows.map { ($0.0 as NSString).size(withAttributes: attributes).width }.reduce(sampleWidth, max))
let width = Metrics.networkDot + Metrics.innerGap + textWidth
// MARK: 网速
return Segment(width: width) { rect in
let capHeight = Metrics.networkFont.capHeight
for (index, row) in rows.enumerated() {
let baseline = rect.minY + (index == 0 ? Metrics.upperBaseline : Metrics.lowerBaseline + 1)
row.1.setFill()
NSBezierPath(ovalIn: NSRect(x: rect.minX, y: baseline + capHeight / 2 - Metrics.networkDot / 2,
width: Metrics.networkDot, height: Metrics.networkDot)).fill()
drawText(row.0, attributes: attributes, rightEdge: rect.maxX, baseline: baseline)
private static func networkSegment(upload: Double?, download: Double?, style: NetworkMenuStyle) -> Segment {
let up = upload.map { Format.menuBarRate($0) } ?? "— KB/s"
let down = download.map { Format.menuBarRate($0) } ?? "— KB/s"
switch style {
case .dots, .arrows:
let attributes: [NSAttributedString.Key: Any] = [.font: Metrics.networkFont, .foregroundColor: NSColor.labelColor]
let textWidth = ceil([up, down, "999 KB/s"].map { self.textWidth($0, attributes) }.max() ?? 0)
let markerWidth = style == .dots ? Metrics.dotSize : ceil(self.textWidth("↑", attributes))
var segment = Segment(width: markerWidth + Metrics.innerGap + textWidth) { rect in
let base = baselineOrigin(in: rect)
let rows: [(String, String, NSColor, CGFloat)] = [
(up, "↑", DS.NetworkPalette.upload, base + Metrics.upperBaseline - 1),
(down, "↓", DS.NetworkPalette.download, base + Metrics.lowerBaseline),
]
for (text, arrow, color, baseline) in rows {
if style == .dots {
color.setFill()
let center = baseline + Metrics.networkFont.capHeight / 2
NSBezierPath(ovalIn: NSRect(x: rect.minX, y: center - Metrics.dotSize / 2,
width: Metrics.dotSize, height: Metrics.dotSize)).fill()
} else {
drawText(arrow, [.font: Metrics.networkFont, .foregroundColor: NSColor.secondaryLabelColor],
rightEdge: rect.minX + markerWidth, baseline: baseline)
}
drawText(text, attributes, rightEdge: rect.maxX, baseline: baseline)
}
}
segment.colored = style == .dots
return segment
case .inline:
let upSegment = inlineText(label: "↑", value: up, sample: "999 KB/s", alert: false)
let downSegment = inlineText(label: "↓", value: down, sample: "999 KB/s", alert: false)
return combine([upSegment, downSegment], gap: Metrics.innerGap * 2)
}
}
private static func combine(_ parts: [Segment], gap: CGFloat = Metrics.innerGap) -> Segment {
let width = parts.reduce(0) { $0 + $1.width } + CGFloat(max(0, parts.count - 1)) * gap
return Segment(width: width) { rect in
var segment = Segment(width: width) { rect in
var x = rect.minX
for part in parts {
part.draw(NSRect(x: x, y: rect.minY, width: part.width, height: rect.height))
x += part.width + gap
}
}
}
private static func accessibilityText(for model: AppModel) -> String {
let cpu = model.store.cpu.map { "CPU \(Format.percent($0.total))" } ?? "CPU"
return "OpenStats,\(cpu)"
segment.colored = parts.contains(where: \.colored)
return segment
}
}
@@ -19,8 +19,11 @@ final class StatusPanel: NSPanel {
private let makeContent: () -> NSView
/// 平铺布局(不含滚动容器)的视图,只用来测量内容的自然高度
private let makeMeasuringContent: () -> NSView
private let usesGlass: () -> Bool
init<Content: View, Measuring: View>(content: @escaping () -> Content, measuring: @escaping () -> Measuring) {
init<Content: View, Measuring: View>(content: @escaping () -> Content, measuring: @escaping () -> Measuring,
usesGlass: @escaping () -> Bool) {
self.usesGlass = usesGlass
makeContent = { NSHostingView(rootView: content()) }
makeMeasuringContent = { NSHostingView(rootView: measuring()) }
super.init(contentRect: NSRect(x: 0, y: 0, width: DS.Size.panelWidth, height: DS.Size.panelMinHeight),
@@ -54,8 +57,10 @@ final class StatusPanel: NSPanel {
self.anchor = anchor
anchorScreen = screen
contentView = GlassBackdrop.make(containing: makeContent(), cornerRadius: DS.Radius.xl)
let content = makeContent()
setFrame(targetFrame(), display: false)
content.frame = NSRect(origin: .zero, size: frame.size)
contentView = GlassBackdrop.make(containing: content, cornerRadius: DS.Radius.xl, glass: usesGlass())
alphaValue = 0
makeKeyAndOrderFront(nil)
@@ -15,18 +15,30 @@ extension View {
}
}
/// 面板窗口的玻璃底板,SwiftUI 内容放在其中
/// 面板窗口的底板,SwiftUI 内容放在其中。
/// 默认是圆角裁切的普通容器,由 SwiftUI 画实色背景;开启玻璃时使用系统液态玻璃或毛玻璃
@MainActor
enum GlassBackdrop {
static func make(containing content: NSView, cornerRadius: CGFloat) -> NSView {
/// 容器必须从内容的尺寸开始:若从 0 开始,窗口把它撑满时自动调整会把内容再放大一倍
static func make(containing content: NSView, cornerRadius: CGFloat, glass: Bool) -> NSView {
guard glass else {
let container = NSView(frame: content.frame)
container.wantsLayer = true
container.layer?.cornerRadius = cornerRadius
container.layer?.cornerCurve = .continuous
container.layer?.masksToBounds = true
content.autoresizingMask = [.width, .height]
container.addSubview(content)
return container
}
if #available(macOS 26.0, *) {
let glass = NSGlassEffectView()
let glass = NSGlassEffectView(frame: content.frame)
glass.cornerRadius = cornerRadius
glass.style = .regular
glass.contentView = content
return glass
}
let effect = NSVisualEffectView()
let effect = NSVisualEffectView(frame: content.frame)
effect.material = .popover
effect.blendingMode = .behindWindow
effect.state = .active
@@ -110,7 +110,7 @@ extension DS {
static let panelWidth: CGFloat = 640
static let panelMinHeight: CGFloat = 320
static let panelGap: CGFloat = 4
static let tabsWidth: CGFloat = 340
static let tabsWidth: CGFloat = 300
/// 三列卡片的单列宽度:(面板宽 - 两侧内边距 - 两个列间距) / 3
static let tileWidth: CGFloat = (panelWidth - Space.s3 * 4) / 3
static let settingsWidth: CGFloat = 720
@@ -30,15 +30,13 @@ public struct PanelRootView: View {
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.fixedSize(horizontal: false, vertical: isSnapshot)
.background {
// 实际面板由窗口的液态玻璃底板提供背景;平铺(测量 / 截图)模式使用实色
if isSnapshot {
// 开启液态玻璃时由窗口底板提供背景,否则画浅色 / 深色实色背景
if isSnapshot || !model.settings.panelGlass {
RoundedRectangle(cornerRadius: DS.Radius.xl).fill(DS.Palette.background)
}
}
.overlay {
if isSnapshot {
RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)
}
RoundedRectangle(cornerRadius: DS.Radius.xl).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)
}
}
}
@@ -69,14 +67,31 @@ private struct PanelHeader: View {
Spacer(minLength: DS.Space.s3)
if model.keepAwake.isActive {
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.primary)
.help("防休眠已开启")
HStack(spacing: DS.Space.s1) {
if model.keepAwake.isActive {
Image(systemName: "cup.and.saucer.fill")
.font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold))
.foregroundStyle(DS.Palette.primary)
.frame(width: DS.Size.controlHeight)
.help("防休眠已开启")
}
ThemeToggle()
IconButton(systemName: "gearshape", help: "设置") { model.openSettings() }
IconButton(systemName: "power", help: "退出 OpenStats") { model.quit() }
}
IconButton(systemName: "gearshape", help: "设置") { model.openSettings() }
IconButton(systemName: "power", help: "退出 OpenStats") { model.quit() }
}
}
}
/// 在浅色与深色之间切换;当前跟随系统时,切到与系统相反的那一种
private struct ThemeToggle: View {
@Environment(AppModel.self) private var model
@Environment(\.colorScheme) private var colorScheme
var body: some View {
let isDark = colorScheme == .dark
IconButton(systemName: isDark ? "sun.max" : "moon", help: isDark ? "切换到浅色" : "切换到深色") {
model.settings.appearance = isDark ? .light : .dark
}
}
}
@@ -180,6 +180,11 @@ private struct GeneralSettings: View {
.frame(width: DS.Size.settingsSidebar + DS.Space.s12)
}
}
GroupRow {
SettingRow(title: "液态玻璃背景", subtitle: "面板背景半透明,透出桌面;关闭时为浅色或深色实色背景") {
DSToggle(isOn: $settings.panelGlass, label: "液态玻璃背景")
}
}
GroupRow {
SettingRow(title: "登录时启动", subtitle: model.launchAtLoginError ?? "开机后自动在菜单栏显示 OpenStats") {
DSToggle(isOn: Binding(get: { model.launchAtLoginEnabled },
@@ -214,34 +219,120 @@ private struct MenuBarSettings: View {
MenuBarPreview()
SettingsGroup(caption: "风格") {
LazyVGrid(columns: [GridItem(.flexible(), spacing: DS.Space.s3), GridItem(.flexible(), spacing: DS.Space.s3)],
spacing: DS.Space.s3) {
ForEach(MenuBarStyle.allCases) { style in
StyleCard(style: style, isSelected: settings.menuBarStyle == style) {
settings.menuBarStyle = style
}
}
}
.padding(DS.Space.s3)
}
SettingsGroup(caption: "显示项目") {
ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in
GroupRow(showsDivider: index > 0) {
VStack(alignment: .leading, spacing: DS.Space.s3) {
SettingRow(title: item.title, subtitle: item.subtitle, icon: item.symbol) {
SettingRow(title: item.title, subtitle: item.subtitle, icon: item.symbol) {
HStack(spacing: DS.Space.s3) {
if settings.isEnabled(item) {
itemStylePicker(item)
}
DSToggle(isOn: Binding(get: { settings.isEnabled(item) },
set: { settings.setEnabled(item, $0) }),
label: item.title)
}
if item.supportsGaugeStyle, settings.isEnabled(item) {
SegmentedControl(selection: Binding(get: { settings.gaugeStyle(for: item) },
set: { settings.setGaugeStyle($0, for: item) }),
options: GaugeStyle.allCases.map { ($0, $0.title) })
.padding(.leading, DS.Size.iconStandalone + DS.Space.s3)
}
}
}
}
}
SettingsGroup(caption: "样式") {
SettingsGroup(caption: "其他") {
GroupRow(showsDivider: false) {
SettingRow(title: "高负载时着色", subtitle: "CPU 超过 85% 时数值显示为红色,其余时间跟随菜单栏颜色") {
SettingRow(title: "高负载时着色", subtitle: "占用超过 85% 时数值与图形显示为红色") {
DSToggle(isOn: $settings.colorizeHighLoad, label: "高负载时着色")
}
}
}
}
/// 网速有自己的三种样式;其他指标可以单独指定风格,默认跟随整体
@ViewBuilder
private func itemStylePicker(_ item: MenuBarItem) -> some View {
@Bindable var settings = model.settings
if item == .network {
SegmentedControl(selection: $settings.networkStyle,
options: NetworkMenuStyle.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.settingsSidebar + DS.Space.s6)
} else {
Picker("风格", selection: Binding(get: { settings.styleOverrides[item] },
set: { settings.setStyleOverride($0, for: item) })) {
Text("跟随整体").tag(MenuBarStyle?.none)
Divider()
ForEach(MenuBarStyle.allCases) { style in
Text(style.title).tag(MenuBarStyle?.some(style))
}
}
.pickerStyle(.menu)
.labelsHidden()
.fixedSize()
}
}
}
private struct StyleCard: View {
@Environment(AppModel.self) private var model
@Environment(\.colorScheme) private var colorScheme
let style: MenuBarStyle
let isSelected: Bool
let action: () -> Void
@State private var hovering = false
var body: some View {
let sample = MenuBarRenderer.image(reading: .sample, items: [.cpu, .memory, .gpu],
style: { _ in style }, networkStyle: model.settings.networkStyle,
colorizeHighLoad: false, fahrenheit: model.settings.useFahrenheit)
Button(action: action) {
VStack(alignment: .leading, spacing: DS.Space.s2) {
// 预览按原尺寸显示,超出卡片宽度时等比缩小,保证各卡片对齐
Image(nsImage: MenuBarRenderer.preview(sample, dark: colorScheme == .dark))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: sample.size.width, maxHeight: sample.size.height)
.padding(.horizontal, DS.Space.s2)
.frame(maxWidth: .infinity)
.frame(height: DS.Size.controlHeight + DS.Space.s2)
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
HStack(spacing: DS.Space.s1) {
Text(style.title).dsFont(.sm, weight: .semibold).foregroundStyle(DS.Palette.textPrimary)
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: DS.TextSize.sm.rawValue))
.foregroundStyle(DS.Palette.primary)
}
}
Text(style.detail)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, minHeight: DS.TextSize.xs.rawValue * 3, alignment: .topLeading)
}
.padding(DS.Space.s2)
.background(hovering && !isSelected ? DS.Palette.surfaceHover : Color.clear,
in: RoundedRectangle(cornerRadius: DS.Radius.md))
.overlay(RoundedRectangle(cornerRadius: DS.Radius.md)
.strokeBorder(isSelected ? DS.Palette.primary : DS.Palette.border,
lineWidth: isSelected ? DS.Size.chartLine : DS.Size.stroke))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
private struct MenuBarPreview: View {
@@ -249,20 +340,22 @@ private struct MenuBarPreview: View {
var body: some View {
let image = MenuBarRenderer.image(for: model)
VStack(spacing: DS.Space.s2) {
preview(image: image, dark: false)
preview(image: image, dark: true)
VStack(alignment: .leading, spacing: DS.Space.s2) {
Text("当前效果(实时数据)").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
HStack(spacing: DS.Space.s3) {
preview(image: image, dark: false)
preview(image: image, dark: true)
}
}
}
private func preview(image: NSImage, dark: Bool) -> some View {
let tinted = MenuBarRenderer.preview(image, dark: dark)
return HStack {
HStack {
Spacer()
Image(nsImage: tinted)
Image(nsImage: MenuBarRenderer.preview(image, dark: dark))
Spacer()
}
.frame(height: DS.Size.controlHeight)
.frame(height: DS.Size.controlHeight + DS.Space.s2)
.background(dark ? Color(nsColor: NSColor(hex: 0x1F2937)) : Color(nsColor: NSColor(hex: 0xE5E7EB)),
in: RoundedRectangle(cornerRadius: DS.Radius.md))
.accessibilityLabel(dark ? "深色菜单栏预览" : "浅色菜单栏预览")
@@ -352,8 +445,10 @@ private struct HelperSettings: View {
if let error = helper.lastError {
InfoBanner(icon: "exclamationmark.triangle.fill", text: error, tone: .error)
}
if HelperConstants.teamIdentifier.isEmpty {
InfoBanner(icon: "info.circle", text: "当前为开发构建:辅助工具只校验应用标识。正式发布前需使用 Developer ID 签名并填写 Team ID。", tone: .neutral)
if let team = CodeSigningInfo.currentTeamIdentifier() {
InfoBanner(icon: "checkmark.seal", text: "已使用 Developer ID 签名(团队 \(team)),辅助工具只接受同一团队签名的 OpenStats。", tone: .success)
} else {
InfoBanner(icon: "info.circle", text: "当前为临时签名的开发构建,辅助工具只能校验应用标识。使用 Developer ID 证书构建后会自动启用团队校验。", tone: .neutral)
}
}
@@ -12,8 +12,6 @@ enum SnapshotRenderer {
let defaults = UserDefaults(suiteName: "OpenStats.snapshot") ?? .standard
let settings = AppSettings(defaults: defaults)
settings.menuBarItems = [.cpu, .gpu, .memory, .network, .temperature]
settings.setGaugeStyle(.ring, for: .gpu)
settings.setGaugeStyle(.pie, for: .memory)
let model = AppModel(settings: settings)
model.isPanelVisible = true
@@ -19,9 +19,9 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
var subtitle: String {
switch self {
case .cpu: "总占用,可附带迷你负载图"
case .cpu: "总占用"
case .memory: "已用内存占比"
case .network: "两行显示:绿点上传、蓝点下载"
case .network: "上传与下载速度"
case .gpu: "GPU 占用"
case .temperature: "CPU 核心最高温度"
case .fan: "转速最高的风扇"
@@ -40,8 +40,8 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
}
}
/// 百分比类指标可选择图形样式
var supportsGaugeStyle: Bool { self == .cpu || self == .gpu || self == .memory }
/// 可以单独指定风格的指标(网速有自己的样式)
var supportsStyleOverride: Bool { self != .network }
var symbol: String {
switch self {
@@ -55,18 +55,49 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
}
}
public enum GaugeStyle: String, CaseIterable, Identifiable, Sendable {
case value, bars, ring, pie, barsAndValue
/// 菜单栏风格:一种风格统一套用到所有指标,风格内部的排版保持一致
public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
case stacked, inline, icon, ring, pie, history, meter, dot
public var id: String { rawValue }
var title: String {
switch self {
case .value: "数字"
case .bars: "柱状图"
case .stacked: "双行文字"
case .inline: "单行文字"
case .icon: "图标"
case .ring: "圆环"
case .pie: "饼图"
case .barsAndValue: "柱状+数字"
case .history: "柱状历史"
case .meter: "电量条"
case .dot: "状态圆点"
}
}
var detail: String {
switch self {
case .stacked: "小标签在上、数值在下,最紧凑"
case .inline: "标签与数值同一行,最易读"
case .icon: "用系统图标代替文字标签"
case .ring: "圆环表示当前占用比例"
case .pie: "饼图表示当前占用比例"
case .history: "10 根柱子是最近 10 次采样(约 20 秒)的变化"
case .meter: "竖向电量条表示当前占用比例"
case .dot: "绿色正常、橙色偏高(60% 以上)、红色很高(85% 以上)"
}
}
}
public enum NetworkMenuStyle: String, CaseIterable, Identifiable, Sendable {
case dots, arrows, inline
public var id: String { rawValue }
var title: String {
switch self {
case .dots: "双行圆点"
case .arrows: "双行箭头"
case .inline: "单行"
}
}
}
@@ -110,11 +141,17 @@ public final class AppSettings {
public var menuBarItems: Set<MenuBarItem> {
didSet { defaults.set(menuBarItems.map(\.rawValue).sorted(), forKey: Keys.menuBarItems) }
}
/// CPU / GPU / 内存各自的菜单栏样式
public var gaugeStyles: [MenuBarItem: GaugeStyle] {
public var menuBarStyle: MenuBarStyle {
didSet { defaults.set(menuBarStyle.rawValue, forKey: Keys.menuBarStyle) }
}
public var networkStyle: NetworkMenuStyle {
didSet { defaults.set(networkStyle.rawValue, forKey: Keys.networkStyle) }
}
/// 个别指标单独指定的风格;未指定的跟随 menuBarStyle
public var styleOverrides: [MenuBarItem: MenuBarStyle] {
didSet {
defaults.set(Dictionary(uniqueKeysWithValues: gaugeStyles.map { ($0.key.rawValue, $0.value.rawValue) }),
forKey: Keys.gaugeStyles)
defaults.set(Dictionary(uniqueKeysWithValues: styleOverrides.map { ($0.key.rawValue, $0.value.rawValue) }),
forKey: Keys.styleOverrides)
}
}
public var refreshSeconds: Int {
@@ -140,6 +177,10 @@ public final class AppSettings {
public var appearance: AppearanceMode {
didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) }
}
/// 面板使用液态玻璃(半透明)背景;关闭时为浅色 / 深色实色背景
public var panelGlass: Bool {
didSet { defaults.set(panelGlass, forKey: Keys.panelGlass) }
}
/// 可再生的缓存也先移到废纸篓(可恢复,但不会立即释放空间)
public var cleanPrefersTrash: Bool {
didSet { defaults.set(cleanPrefersTrash, forKey: Keys.cleanPrefersTrash) }
@@ -153,9 +194,11 @@ public final class AppSettings {
self.defaults = defaults
let items = defaults.stringArray(forKey: Keys.menuBarItems)?.compactMap(MenuBarItem.init(rawValue:))
menuBarItems = Set(items ?? [.cpu, .memory, .network])
let storedStyles = defaults.dictionary(forKey: Keys.gaugeStyles) as? [String: String] ?? [:]
gaugeStyles = Dictionary(uniqueKeysWithValues: storedStyles.compactMap { key, value in
guard let item = MenuBarItem(rawValue: key), let style = GaugeStyle(rawValue: value) else { return nil }
menuBarStyle = defaults.string(forKey: Keys.menuBarStyle).flatMap(MenuBarStyle.init(rawValue:)) ?? .stacked
networkStyle = defaults.string(forKey: Keys.networkStyle).flatMap(NetworkMenuStyle.init(rawValue:)) ?? .dots
let storedOverrides = defaults.dictionary(forKey: Keys.styleOverrides) as? [String: String] ?? [:]
styleOverrides = Dictionary(uniqueKeysWithValues: storedOverrides.compactMap { key, value in
guard let item = MenuBarItem(rawValue: key), let style = MenuBarStyle(rawValue: value) else { return nil }
return (item, style)
})
refreshSeconds = Self.refreshOptions.contains(defaults.integer(forKey: Keys.refreshSeconds))
@@ -169,6 +212,7 @@ public final class AppSettings {
panelTab = defaults.string(forKey: Keys.panelTab).flatMap(PanelTab.init(rawValue:)) ?? .overview
appearance = defaults.string(forKey: Keys.appearance).flatMap(AppearanceMode.init(rawValue:)) ?? .system
cleanPrefersTrash = defaults.bool(forKey: Keys.cleanPrefersTrash)
panelGlass = defaults.bool(forKey: Keys.panelGlass)
}
/// 按固定顺序返回已启用的菜单栏项目
@@ -178,12 +222,13 @@ public final class AppSettings {
func isEnabled(_ item: MenuBarItem) -> Bool { menuBarItems.contains(item) }
func gaugeStyle(for item: MenuBarItem) -> GaugeStyle {
gaugeStyles[item] ?? (item == .cpu ? .barsAndValue : .value)
func style(for item: MenuBarItem) -> MenuBarStyle {
styleOverrides[item] ?? menuBarStyle
}
func setGaugeStyle(_ style: GaugeStyle, for item: MenuBarItem) {
gaugeStyles[item] = style
/// nil 表示跟随整体风格
func setStyleOverride(_ style: MenuBarStyle?, for item: MenuBarItem) {
styleOverrides[item] = style
}
func setEnabled(_ item: MenuBarItem, _ enabled: Bool) {
@@ -192,7 +237,9 @@ public final class AppSettings {
private enum Keys {
static let menuBarItems = "menuBarItems"
static let gaugeStyles = "gaugeStyles"
static let menuBarStyle = "menuBarStyle"
static let networkStyle = "networkStyle"
static let styleOverrides = "styleOverrides"
static let refreshSeconds = "refreshSeconds"
static let colorizeHighLoad = "colorizeHighLoad"
static let useFahrenheit = "useFahrenheit"
@@ -201,5 +248,6 @@ public final class AppSettings {
static let panelTab = "panelTab"
static let appearance = "appearance"
static let cleanPrefersTrash = "cleanPrefersTrash"
static let panelGlass = "panelGlass"
}
}
+32 -15
View File
@@ -46,32 +46,36 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese.
<!-- changelog:start -->
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
Not released yet · **23** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
Not released yet · **29** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
<details open>
<summary><b>2026-09-13</b> · Unreleased · 15 added · 4 style · 4 fixed</summary>
<summary><b>2026-09-13</b> · Unreleased · 18 added · 5 style · 6 fixed</summary>
**Added**
- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。CPU、GPU、内存可以分别选择数字、柱状图、圆环、饼图、柱状图加数字五种样式;网速两行显示,绿点上传、蓝点下载,带完整单位。
- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。
- 菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。
- 网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。
- 鼠标悬停在菜单栏图标上显示完整读数。
- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。
- 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。
- 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。
- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作,并校验调用方签名;异常退出后下次开机自动恢复风扇与睡眠设置。
- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作;运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。
- 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。
- 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。
- 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。
- 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。
- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。
- 外观可选跟随系统、浅色、深色;macOS 26 起面板使用液态玻璃。
- 面板提供浅色、深色两套实色主题,顶部的太阳 / 月亮按钮一键切换,也可以跟随系统;液态玻璃背景改为可选(macOS 26 起)。
- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。
**Style**
- 菜单栏字号对齐常见菜单栏监控工具:标签 7pt 细体、数值 12pt 常规、网速 9pt 细体,指标之间留出更宽的间距。
- 菜单栏字号缩小并统一:两行布局为 7pt 标签加 10pt 数值,单行布局 11pt,网速两行 9pt;每种风格内部只用一套字号,指标之间留出更宽的间距。
- 柱状历史去掉满高的灰色底槽,只保留一条淡基线,负载起伏更清楚。
- 面板加宽为三列布局,高度按内容自动计算,常见屏幕上不需要滚动。
- 更换应用图标,面板左上角和关于页使用同一图标。
- 首次启动默认在菜单栏显示 CPU、内存与网速。
@@ -82,6 +86,8 @@ Not released yet · **23** changes in development · [full changelog](CHANGELOG.
- 面板打开后高度会再调整一次、内容轻微回弹,看起来在抖动;面板打开时点击菜单栏图标关闭,会立刻又被打开。
- 风扇被其他程序设为手动模式时,误显示为由 OpenStats 控制。
- 风扇转速、PID 等数字出现千分位分隔符,例如 1,350 RPM。
- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。
- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。
</details>
@@ -112,12 +118,13 @@ Not released yet · **23** changes in development · [full changelog](CHANGELOG.
## 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.
- Eight styles, chosen once for every metric: two-line text, one-line text, icons, rings,
pies, history bars, level meters and status dots. Any metric can be given its own style;
Settings previews each one with sample data and says what the shape means.
- Network speed in two rows with dots or arrows, or on one line — green for upload, blue for
download, always with its unit (`KB/s`, `MB/s`, `GB/s`).
- Small and consistent type: 7 pt labels over 10 pt figures, 11 pt on one line, 9 pt for
network speed. Figures are fixed-width, so the bar does not jitter. Hover for every reading.
<p align="center"><img src="Assets/readme/menubar-dark.png" width="600" alt="Menu bar"></p>
@@ -133,7 +140,8 @@ Not released yet · **23** changes in development · [full changelog](CHANGELOG.
- **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.
closes it. Light and dark themes, switched from the panel's header or following the system;
Liquid Glass is optional.
<p align="center">
<img src="Assets/readme/thermal-dark.png" width="49%" alt="Thermal">
@@ -220,8 +228,17 @@ apps' caches and installing a privileged helper, which is most of the feature se
| 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.
`make build` and `make install` sign with the first Developer ID Application certificate in
the keychain and fall back to ad-hoc without one. The helper reads its own team at run time
and accepts only callers signed by the same team.
```bash
make release # sign, notarize, staple, build the DMG and a Homebrew cask
NOTARY_PROFILE=OpenStats make release # use another notarytool keychain profile
SKIP_NOTARIZE=1 make release # an unnotarized DMG for testing on this Mac only
```
`Scripts/release.sh` refuses to produce a DMG that Gatekeeper would still reject.
## Architecture
+29 -12
View File
@@ -44,32 +44,36 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
<!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
尚未发布正式版本 · 开发中 **23** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
尚未发布正式版本 · 开发中 **29** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
<details open>
<summary><b>2026-09-13</b> · 未发布 · 新增 15 · 样式 4 · 修复 4</summary>
<summary><b>2026-09-13</b> · 未发布 · 新增 18 · 样式 5 · 修复 6</summary>
**新增**
- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。CPU、GPU、内存可以分别选择数字、柱状图、圆环、饼图、柱状图加数字五种样式;网速两行显示,绿点上传、蓝点下载,带完整单位。
- 菜单栏显示 CPU、GPU、内存、网速、CPU 温度和风扇转速。
- 菜单栏风格:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点八种,整体选择一种统一套用,也可以给个别指标单独指定。设置里每种风格都有用示例数据画出的预览和说明,柱状历史注明是最近 10 次采样的变化。
- 网速有双行圆点、双行箭头、单行三种样式,绿色上传、蓝色下载,带完整单位。
- 鼠标悬停在菜单栏图标上显示完整读数。
- 下拉面板分为概览、进程、散热、防休眠、清理五页。概览页顶部是健康评分和芯片、内存、系统版本、运行时长、机型徽章,下面是 CPU 柱状历史、GPU 折线、内存面积图、磁盘、网络双线、风扇三列卡片,以及核心负载、电池环形图、高占用进程和快捷开关。
- CPU 按超级核、性能核、能效核分组显示各核心负载;内存口径与活动监视器一致,并显示内存压力;网速读取 64 位计数器,大流量下不会回绕。
- 温度与风扇:启动时枚举一次 SMC 键并按前缀归类,不按芯片型号硬编码,显示 CPU、GPU、内存、电池和掌托温度。
- 按界面需要分级采样:面板收起时只采集菜单栏用到的指标,锁屏、屏幕休眠和系统睡眠时暂停。
- 风扇调速:自动、降温、强冷、自定义四种模式。自定义模式下 CPU 达到安全温度会自动交还系统控制,应用退出或断开连接时风扇恢复自动。
- 防休眠:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可设定持续时间;使用电池且电量低于下限时自动关闭合盖运行。
- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作,并校验调用方签名;异常退出后下次开机自动恢复风扇与睡眠设置。
- 辅助工具:通过 SMAppService 注册,只提供设置风扇转速、切换合盖不睡眠、刷新 DNS、释放内存几个固定操作;运行时读取自己的签名团队,只接受同一团队签名的 OpenStats 调用;异常退出后下次开机自动恢复风扇与睡眠设置。
- 清理:扫描应用缓存、日志与崩溃报告、Chrome / Edge / Brave / Arc / Firefox / Safari 缓存、Xcode 编译缓存、模拟器缓存、npm 缓存、Xcode 归档、未完成的下载、安装包和废纸篓。按类别显示大小,可展开查看具体项目;清理前二次确认,操作写入 `~/Library/Logs/OpenStats/cleanup.log`。
- 清理的安全边界:只清理白名单目录,钥匙串、密码管理器、VPN、Cookie、历史记录一律不碰;正在运行的应用和浏览器自动跳过,执行前逐项重新校验。
- 系统维护:一键刷新 DNS 缓存、释放内存,已安装辅助工具时直接执行,否则请求一次管理员授权。
- 进程页按 CPU 或内存排序,辅助进程归并显示为所属应用。
- 设置窗口:外观、登录时启动、刷新频率、温度单位、菜单栏显示项与样式、风扇安全温度、合盖运行电量下限、辅助工具安装与卸载。
- 外观可选跟随系统、浅色、深色;macOS 26 起面板使用液态玻璃。
- 面板提供浅色、深色两套实色主题,顶部的太阳 / 月亮按钮一键切换,也可以跟随系统;液态玻璃背景改为可选(macOS 26 起)。
- 截图命令 `OpenStats --snapshot <目录>`:用本机实时数据渲染面板、设置和菜单栏的浅色与深色截图。
**样式**
- 菜单栏字号对齐常见菜单栏监控工具:标签 7pt 细体、数值 12pt 常规、网速 9pt 细体,指标之间留出更宽的间距。
- 菜单栏字号缩小并统一:两行布局为 7pt 标签加 10pt 数值,单行布局 11pt,网速两行 9pt;每种风格内部只用一套字号,指标之间留出更宽的间距。
- 柱状历史去掉满高的灰色底槽,只保留一条淡基线,负载起伏更清楚。
- 面板加宽为三列布局,高度按内容自动计算,常见屏幕上不需要滚动。
- 更换应用图标,面板左上角和关于页使用同一图标。
- 首次启动默认在菜单栏显示 CPU、内存与网速。
@@ -80,6 +84,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
- 面板打开后高度会再调整一次、内容轻微回弹,看起来在抖动;面板打开时点击菜单栏图标关闭,会立刻又被打开。
- 风扇被其他程序设为手动模式时,误显示为由 OpenStats 控制。
- 风扇转速、PID 等数字出现千分位分隔符,例如 1,350 RPM。
- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。
- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。
</details>
@@ -110,10 +116,11 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
## 数据显示在哪里
**菜单栏**
- CPU、GPU、内存可以分别选择数字、柱状图、圆环、饼图、柱状图加数字五种样式,另外还能显示网速、
CPU 温度和转速最高的风扇。
- 网速两行显示:绿点上传、蓝点下载,始终带单位(`KB/s`、`MB/s`、`GB/s`)。
- 标签 7pt、数值 12pt,与常见的菜单栏监控工具一致;数值等宽,刷新时菜单栏不抖动。
- 八种风格整体选择、统一套用:双行文字、单行文字、图标、圆环、饼图、柱状历史、电量条、状态圆点;
也可以给个别指标单独指定。设置里每种风格都有示例预览,并说明图形代表什么。
- 网速可选双行圆点、双行箭头或单行:绿色上传、蓝色下载,始终带单位(`KB/s`、`MB/s`、`GB/s`)。
- 字号小而统一:两行布局 7pt 标签加 10pt 数值,单行 11pt,网速 9pt;数值等宽,刷新时菜单栏不抖动。
鼠标悬停显示完整读数。
<p align="center"><img src="Assets/readme/menubar-dark.png" width="600" alt="菜单栏"></p>
@@ -125,7 +132,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/
- **防休眠**:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可定时或不限时。
- **清理**:扫描、预览、清理,见[清理](#清理)。
面板高度按内容自动计算,常见屏幕上不需要滚动;按 `Esc` 关闭。外观可选浅色、深色或跟随系统。
面板高度按内容自动计算,常见屏幕上不需要滚动;按 `Esc` 关闭。提供浅色、深色两套主题,
可在面板顶部一键切换或跟随系统;液态玻璃背景可选。
<p align="center">
<img src="Assets/readme/thermal-dark.png" width="49%" alt="散热">
@@ -200,7 +208,16 @@ build/DerivedData/Build/Products/Debug/OpenStats.app/Contents/MacOS/OpenStats --
| Developer ID 证书 | 启用 Hardened Runtime。别人会看到 *“Apple 无法检查其是否包含恶意软件”*。 |
| 证书 + 公证 | Gatekeeper 放行,只有常规的 *“从互联网下载”* 提示。 |
发布前请在 `HelperShared/HelperProtocol.swift` 中填写 Team ID,让辅助工具只接受由该团队签名的调用方。
`make build` 与 `make install` 会自动使用钥匙串里的第一个 Developer ID Application 证书签名,没有证书时退回 ad-hoc。
辅助工具在运行时读取自己的签名团队,只接受同一团队签名的调用方。
```bash
make release # 签名、公证、装订,生成 DMG 与 Homebrew cask
NOTARY_PROFILE=OpenStats make release # 使用其他 notarytool 钥匙串凭据
SKIP_NOTARIZE=1 make release # 生成未公证的 DMG,仅供本机测试
```
`Scripts/release.sh` 会拒绝生成 Gatekeeper 仍会拒绝的 DMG。
## 架构
+118
View File
@@ -0,0 +1,118 @@
#!/bin/bash
# 发布一个可分发的版本:Developer ID 签名 → 公证 → 装订 → DMG(签名、公证、装订)→ SHA-256 与 Homebrew cask。
#
# ./Scripts/release.sh
#
# 需要钥匙串里的 Developer ID Application 证书和 notarytool 凭据。凭据按 Apple ID 与团队保存,
# 默认沿用 QuotaBar 的 “QuotaBar” 凭据;也可以单独保存一份:
# xcrun notarytool store-credentials OpenStats --apple-id you@example.com --team-id <TEAM_ID>
# NOTARY_PROFILE=OpenStats ./Scripts/release.sh
#
# SKIP_NOTARIZE=1 只生成未公证的 DMG 供本机测试——不要分发,别的 Mac 上 Gatekeeper 会拒绝打开。
set -euo pipefail
cd "$(dirname "$0")/.."
REPO="${REPO:-gentpan/OpenStats}"
DIST="${DIST:-dist}"
NOTARY_PROFILE="${NOTARY_PROFILE:-QuotaBar}"
SIGN_ID="${SIGN_ID:-$(security find-identity -v -p codesigning 2>/dev/null \
| grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)".*/\1/' || true)}"
VERSION="$(sed -nE 's/^ *MARKETING_VERSION: *"?([0-9.]+)"?.*/\1/p' project.yml | head -1)"
APP="build/DerivedData/Build/Products/Release/OpenStats.app"
DMG_NAME="OpenStats-${VERSION}.dmg"
if [ -z "$SIGN_ID" ]; then
echo "error: 钥匙串里没有 Developer ID Application 证书,无法发布。" >&2
exit 1
fi
TEAM_ID="$(echo "$SIGN_ID" | sed -nE 's/.*\(([A-Z0-9]+)\)$/\1/p')"
echo "版本 ${VERSION} · 签名身份:${SIGN_ID}"
# ---- 构建 --------------------------------------------------------------------
make build CONFIG=Release SIGN_ID="$SIGN_ID"
codesign --verify --deep --strict --verbose=2 "$APP"
for binary in "$APP" "$APP/Contents/MacOS/OpenStatsHelper"; do
details="$(codesign -dvv "$binary" 2>&1)"
echo "$details" | grep -q "TeamIdentifier=${TEAM_ID}" \
|| { echo "error: $binary 未使用团队 ${TEAM_ID} 签名" >&2; exit 1; }
echo "$details" | grep -q "Timestamp=" \
|| { echo "error: $binary 缺少安全时间戳" >&2; exit 1; }
echo "$details" | grep -Eq "flags=.*runtime" \
|| { echo "error: $binary 未启用 Hardened Runtime" >&2; exit 1; }
done
notarize() {
echo "提交公证:$(basename "$1")(通常需要几分钟)…"
xcrun notarytool submit "$1" --keychain-profile "$NOTARY_PROFILE" --wait
}
# ---- 公证 App ----------------------------------------------------------------
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
# ditto 而不是 zip:保留包内的符号链接与扩展属性
ditto -c -k --keepParent "$APP" "$WORK/OpenStats.zip"
notarize "$WORK/OpenStats.zip"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
# 拒绝发布 Gatekeeper 在用户机器上仍会拒绝的包
if ! spctl -a -vv "$APP" 2>&1 | grep -q accepted; then
echo "error: Gatekeeper 仍然拒绝该 App,停止发布。" >&2
spctl -a -vv "$APP" || true
exit 1
fi
fi
# ---- DMG ---------------------------------------------------------------------
rm -rf "$DIST"
mkdir -p "$DIST" "$WORK/dmg"
ditto "$APP" "$WORK/dmg/OpenStats.app"
ln -s /Applications "$WORK/dmg/Applications"
hdiutil create -volname "OpenStats ${VERSION}" -srcfolder "$WORK/dmg" -ov -format UDZO "$DIST/$DMG_NAME" >/dev/null
codesign --force --timestamp --sign "$SIGN_ID" "$DIST/$DMG_NAME"
if [ "${SKIP_NOTARIZE:-0}" != "1" ]; then
notarize "$DIST/$DMG_NAME"
xcrun stapler staple "$DIST/$DMG_NAME"
spctl -a -t open --context context:primary-signature -vv "$DIST/$DMG_NAME"
fi
SHA256="$(shasum -a 256 "$DIST/$DMG_NAME" | cut -d' ' -f1)"
# ---- Homebrew cask ------------------------------------------------------------
cat > "$DIST/openstats.rb" <<CASK
cask "openstats" do
version "${VERSION}"
sha256 "${SHA256}"
url "https://github.com/${REPO}/releases/download/v#{version}/OpenStats-#{version}.dmg"
name "OpenStats"
desc "Menu bar system monitor with fan control, keep-awake and cleanup"
homepage "https://getopenstats.com"
depends_on macos: ">= :sonoma"
app "OpenStats.app"
zap trash: [
"~/Library/Logs/OpenStats",
"~/Library/Preferences/com.openstats.app.plist",
]
end
CASK
echo
echo "✅ ${DIST}/${DMG_NAME}"
echo " SHA-256 ${SHA256}"
echo " Homebrew cask:${DIST}/openstats.rb"
if [ "${SKIP_NOTARIZE:-0}" = "1" ]; then
echo "⚠️ 未公证,仅供本机测试。"
else
echo "下一步:创建 GitHub Release v${VERSION} 并上传 DMG,再把 cask 提交到 gentpan/homebrew-tap。"
fi