diff --git a/.gitignore b/.gitignore index 51abb5a..c10ddd7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ xcuserdata/ .build/ .swiftpm/ Packages/OpenStatsKit/Package.resolved +dist/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 421f4e7..24cf8f8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/Assets/readme/activity.svg b/Assets/readme/activity.svg index a92ae24..f78b5ee 100644 --- a/Assets/readme/activity.svg +++ b/Assets/readme/activity.svg @@ -1,5 +1,5 @@ -4 commits in the last 26 weeks +6 commits in the last 26 weeks Apr May @@ -186,9 +186,9 @@ 0 commits on 2026-09-10 0 commits on 2026-09-11 0 commits on 2026-09-12 -4 commits on 2026-09-13 +6 commits on 2026-09-13 -4 commits in the last 26 weeks · through 2026-09-13 +6 commits in the last 26 weeks · through 2026-09-13 Less diff --git a/Assets/readme/activity.zh.svg b/Assets/readme/activity.zh.svg index 3d4ef2d..11bb1e7 100644 --- a/Assets/readme/activity.zh.svg +++ b/Assets/readme/activity.zh.svg @@ -1,5 +1,5 @@ -近 26 周共 4 次提交 +近 26 周共 6 次提交 4月 5月 @@ -186,9 +186,9 @@ 2026-09-10:0 次提交 2026-09-11:0 次提交 2026-09-12:0 次提交 -2026-09-13:4 次提交 +2026-09-13:6 次提交 -近 26 周共 4 次提交 · 截至 2026-09-13 +近 26 周共 6 次提交 · 截至 2026-09-13 少 diff --git a/Assets/readme/cleaner-light.png b/Assets/readme/cleaner-light.png index f8cf460..ec0105c 100644 Binary files a/Assets/readme/cleaner-light.png and b/Assets/readme/cleaner-light.png differ diff --git a/Assets/readme/keepawake-light.png b/Assets/readme/keepawake-light.png index c022289..251600d 100644 Binary files a/Assets/readme/keepawake-light.png and b/Assets/readme/keepawake-light.png differ diff --git a/Assets/readme/menubar-dark.png b/Assets/readme/menubar-dark.png index 3298f93..47e2d38 100644 Binary files a/Assets/readme/menubar-dark.png and b/Assets/readme/menubar-dark.png differ diff --git a/Assets/readme/overview-dark.png b/Assets/readme/overview-dark.png index fd73c30..2f09402 100644 Binary files a/Assets/readme/overview-dark.png and b/Assets/readme/overview-dark.png differ diff --git a/Assets/readme/overview-light.png b/Assets/readme/overview-light.png index bf54c47..3f570ad 100644 Binary files a/Assets/readme/overview-light.png and b/Assets/readme/overview-light.png differ diff --git a/Assets/readme/settings-menubar-light.png b/Assets/readme/settings-menubar-light.png index 65796e8..778ab4c 100644 Binary files a/Assets/readme/settings-menubar-light.png and b/Assets/readme/settings-menubar-light.png differ diff --git a/Assets/readme/thermal-dark.png b/Assets/readme/thermal-dark.png index ca45584..b3725f3 100644 Binary files a/Assets/readme/thermal-dark.png and b/Assets/readme/thermal-dark.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index b834aef..075e0d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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。 +- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。 +- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。 diff --git a/Helper/main.swift b/Helper/main.swift index 3e37284..1e49277 100644 --- a/Helper/main.swift +++ b/Helper/main.swift @@ -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 = [] 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 diff --git a/Makefile b/Makefile index 362d8a5..5723da6 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift index 4d19de1..1dce024 100644 --- a/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift +++ b/Packages/OpenStatsKit/Sources/HelperShared/HelperProtocol.swift @@ -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 { diff --git a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift index 1ff4515..b6f19b5 100644 --- a/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift +++ b/Packages/OpenStatsKit/Sources/Metrics/MetricsHub.swift @@ -27,6 +27,8 @@ public actor MetricsHub { private var handler: (@MainActor @Sendable (MetricsSnapshot) -> Void)? private var loop: Task? 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() } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index cb7d2dd..b1c6560 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift index ce40fe3..125534f 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarRenderer.swift @@ -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 } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift index fd11056..8b6c29a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift @@ -19,8 +19,11 @@ final class StatusPanel: NSPanel { private let makeContent: () -> NSView /// 平铺布局(不含滚动容器)的视图,只用来测量内容的自然高度 private let makeMeasuringContent: () -> NSView + private let usesGlass: () -> Bool - init(content: @escaping () -> Content, measuring: @escaping () -> Measuring) { + init(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) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift index 4b92e5e..d703472 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Glass.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift index d9b6fdb..8b5d5f6 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Design/Tokens.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift index c3615dc..1f3f303 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/PanelRootView.swift @@ -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 } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift index f60f254..3adfd7c 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsView.swift @@ -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) } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index 0ce137f..a58de9e 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -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 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index a546c2d..80b8e7d 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -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 { 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" } } diff --git a/README.md b/README.md index 0aa1f10..17fbc36 100644 --- a/README.md +++ b/README.md @@ -46,32 +46,36 @@ Developed and tested on Apple Silicon. The interface is in Simplified Chinese. -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)
-
2026-09-13 · Unreleased · 15 added · 4 style · 4 fixed +2026-09-13 · Unreleased · 18 added · 5 style · 6 fixed **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。 +- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。 +- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。 @@ -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.

Menu bar

@@ -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.

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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 5e07f32..a242b61 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,32 +44,36 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ -尚未发布正式版本 · 开发中 **23** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +尚未发布正式版本 · 开发中 **29** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)

-2026-09-13 · 未发布 · 新增 15 · 样式 4 · 修复 4 +2026-09-13 · 未发布 · 新增 18 · 样式 5 · 修复 6 **新增** -- 菜单栏显示 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。 +- 关闭液态玻璃后点击菜单栏图标,面板看起来打不开:面板内容被放大成两倍,窗口里只显示空白的一角。 +- 面板第一次打开时高度偏小:打开瞬间温度、风扇、进程等数据还没采集,现在启动时先完整采集一次。
@@ -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;数值等宽,刷新时菜单栏不抖动。 + 鼠标悬停显示完整读数。

菜单栏

@@ -125,7 +132,8 @@ OpenStats 还没有发布正式版本。第一个版本会在 [Releases](https:/ - **防休眠**:屏幕保持常亮、仅系统不休眠、合盖后继续运行,可定时或不限时。 - **清理**:扫描、预览、清理,见[清理](#清理)。 -面板高度按内容自动计算,常见屏幕上不需要滚动;按 `Esc` 关闭。外观可选浅色、深色或跟随系统。 +面板高度按内容自动计算,常见屏幕上不需要滚动;按 `Esc` 关闭。提供浅色、深色两套主题, +可在面板顶部一键切换或跟随系统;液态玻璃背景可选。

散热 @@ -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。 ## 架构 diff --git a/Scripts/release.sh b/Scripts/release.sh new file mode 100755 index 0000000..23cd051 --- /dev/null +++ b/Scripts/release.sh @@ -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 +# 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" <= :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