菜单栏风格、面板浅色与深色主题,修复面板打不开
- 菜单栏八种风格整体套用、可按指标单独指定,网速三种样式,悬停显示完整读数,字号缩小并统一 - 面板浅色、深色实色主题与一键切换,液态玻璃改为可选 - 修复关闭液态玻璃后面板内容尺寸翻倍导致看起来打不开,首次打开时高度偏小 - Developer ID 自动签名,辅助工具按自身签名团队校验调用方,新增发布脚本 - 更新日志、README、截图同步
No files matched your search
@@ -5,3 +5,4 @@ xcuserdata/
|
||||
.build/
|
||||
.swiftpm/
|
||||
Packages/OpenStatsKit/Package.resolved
|
||||
dist/
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 337 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 325 KiB After Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 208 KiB |
@@ -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。
|
||||
- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。
|
||||
- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 架构
|
||||
|
||||
|
||||
@@ -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
|
||||