feat(menubar): 新增单图标模式并优化状态总览

This commit is contained in:
ysicing authored and 缘生 committed 2026-10-04 21:52:14 +08:00
1 parent 54ae9f8c88
commit cbd44993ec
29 files changed
+722 -94

No files matched your search

+13 -1
View File
@@ -72,6 +72,11 @@ Fan controls appear only after a positive fan count has been sampled. Unknown ca
sampling demand active; a confirmed count survives transient read failures for the current run.
Capability filtering never overwrites the user's saved display preferences.
Zero-RPM fans use the firmware startup state when present, falling back to a positive manual
target only when that state is unavailable. A monotonic, per-fan 10-second window bounds the
startup label; persistent zero RPM remains visible after that window. No extra timer or fan
write is added, and existing thermal safety behavior is unchanged.
## Helper
Protocol 5 removes the identifier-only ad-hoc authentication fallback. Ad-hoc apps refuse helper
@@ -153,10 +158,17 @@ pieces of read-mostly logic in `Cleaner/DiskTools.swift`, each testable without
`MenuBarController` owns the status items. In the *separate* layout every enabled metric gets
its own `NSStatusItem` (created in reverse so they read left to right) and opens a 320 pt
popover for that metric; in the *combined* layout a single item opens `CombinedPopoverView`: a status
overview with one row per enabled metric, plus tabs that switch to each metric's full popover content
overview with two-column metric cards and full-width network, battery and AI cards, plus tabs that switch to each metric's full popover content
(`PopoverDetail`, shared with the separate layout). While a detail tab is showing, `AppModel.openPopover`
is set to that metric so sampling matches the standalone popover.
The *iconOnly* layout draws only the XStats symbol (plus the keep-awake indicator when active).
It preserves selected metrics and styles; its 360 pt overview uses the same selection as combined
mode. Additional GPU, disk, battery, temperature and fan demand exists only while corresponding
cards or details are shown. The optional three-row process summary follows `processesEnabled`;
it does not request full-system process enumeration. Closing the panel releases these demands,
while baseline CPU, memory, network, history and safety requirements keep their existing behavior.
Popovers are borderless, non-activating `NSPanel`s. The SwiftUI tree is created on open and
destroyed on close, so a hidden popover costs nothing. Height comes from measuring a flat,
scroll-free copy of the same view; placeholders keep that height stable until data arrives, and
+3
View File
@@ -32,6 +32,9 @@ task version # 查看版本与构建号
task clean # 删除 build/
```
菜单栏布局走查可对当前构建运行 `XStats --snapshot <目录> --menubar-only --language en`;
仅采集相关指标,输出三种布局、总览和详情的明暗截图,不扫描清理目录或 AI 日志。
`project.yml` 是 Xcode 工程配置来源,调整 target、entitlement 或签名设置后重新生成工程。
修改 `CHANGELOG.md` 后运行 `python3 scripts/sync_changelog.py`,同步 README 版本摘要与活跃度图。
@@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P
各核心负载 حمل النوى
各项指标正常 كل المؤشرات طبيعية
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 يبقى Mac مستيقظًا عند إغلاق الغطاء، فتستمر التنزيلات والتصيير والجلسات البعيدة.
合并为一个 مدمج
合并显示 مدمج
合盖后继续运行 مواصلة التشغيل مع الغطاء المغلق
合盖电量下限 حد البطارية لوضع الغطاء المغلق
合盖睡眠 إسبات الغطاء
@@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 است
重置卡:{} رصيد إعادة الضبط: {}
套餐剩余时间 الوقت المتبقي للخطة
Token 用量 الرموز
仅图标 الأيقونة فقط
只显示 XStats 图标,点击查看已选项目的状态总览 عرض أيقونة XStats فقط؛ انقر لعرض ملخص المؤشرات المحددة
面板项目 مؤشرات اللوحة
所选项目显示在弹出面板中,原有图标风格会保留。 تظهر المؤشرات المحددة في اللوحة المنبثقة. تُحفظ أنماط الأيقونات الحالية.
启动中… جارٍ التشغيل…
正在启动风扇… جارٍ تشغيل المراوح…
打开进程监控 فتح مراقب العمليات
IP 归属地 موقع عنوان IP
1 # Copyright (C) 2026 ysicing
435 各核心负载
436 各项指标正常
437 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。
438 合并为一个 合并显示
439 合盖后继续运行
440 合盖电量下限
441 合盖睡眠
1649 重置卡:{}
1650 套餐剩余时间
1651 Token 用量
1652 仅图标
1653 只显示 XStats 图标,点击查看已选项目的状态总览
1654 面板项目
1655 所选项目显示在弹出面板中,原有图标风格会保留。
1656 启动中…
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
@@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py
各核心负载 Kernauslastung
各项指标正常 Alle Werte sind normal
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 Der Mac bleibt bei geschlossenem Deckel wach. Downloads, Renderaufgaben und Fernverbindungen laufen weiter.
合并为一个 Zusammengefasst
合并显示 Zusammengefasst
合盖后继续运行 Bei geschlossenem Deckel weiterlaufen
合盖电量下限 Mindestladung bei geschlossenem Deckel
合盖睡眠 Ruhezustand beim Zuklappen
@@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 KI-Nutz
重置卡:{} Reset-Guthaben: {}
套餐剩余时间 Verbleibende Tariflaufzeit
Token 用量 Tokens
仅图标 Nur Symbol
只显示 XStats 图标,点击查看已选项目的状态总览 Nur das XStats-Symbol anzeigen; anklicken, um die ausgewählten Messwerte zu sehen
面板项目 Panel-Messwerte
所选项目显示在弹出面板中,原有图标风格会保留。 Die ausgewählten Messwerte erscheinen im Panel. Ihre Symbolstile bleiben erhalten.
启动中… Startet…
正在启动风扇… Lüfter starten…
打开进程监控 Prozessmonitor öffnen
IP 归属地 IP-Standort
1 # Copyright (C) 2026 ysicing
435 各核心负载
436 各项指标正常
437 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。
438 合并为一个 合并显示
439 合盖后继续运行
440 合盖电量下限
441 合盖睡眠
1649 重置卡:{}
1650 套餐剩余时间
1651 Token 用量
1652 仅图标
1653 只显示 XStats 图标,点击查看已选项目的状态总览
1654 面板项目
1655 所选项目显示在弹出面板中,原有图标风格会保留。
1656 启动中…
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
@@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes
各核心负载 Carga de núcleos
各项指标正常 Todo parece correcto
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 El Mac sigue activo con la tapa cerrada para continuar descargas, renderizados y sesiones remotas.
合并为一个 Combinado
合并显示 Combinado
合盖后继续运行 Seguir funcionando con la tapa cerrada
合盖电量下限 Batería mínima con tapa cerrada
合盖睡眠 Reposo al cerrar la tapa
@@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 El uso
重置卡:{} Créditos de restablecimiento: {}
套餐剩余时间 Tiempo restante del plan
Token 用量 Tokens
仅图标 Solo icono
只显示 XStats 图标,点击查看已选项目的状态总览 Mostrar solo el icono de XStats; haz clic para ver los indicadores seleccionados
面板项目 Indicadores del panel
所选项目显示在弹出面板中,原有图标风格会保留。 Los indicadores seleccionados aparecen en el panel. Se conservan los estilos de los iconos.
启动中… Iniciando…
正在启动风扇… Iniciando ventiladores…
打开进程监控 Abrir monitor de procesos
IP 归属地 Ubicación IP
1 # Copyright (C) 2026 ysicing
435 各核心负载
436 各项指标正常
437 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。
438 合并为一个 合并显示
439 合盖后继续运行
440 合盖电量下限
441 合盖睡眠
1649 重置卡:{}
1650 套餐剩余时间
1651 Token 用量
1652 仅图标
1653 只显示 XStats 图标,点击查看已选项目的状态总览
1654 面板项目
1655 所选项目显示在弹出面板中,原有图标风格会保留。
1656 启动中…
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
@@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P
各核心负载 Charge des cœurs
各项指标正常 Tous les indicateurs sont normaux
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 Votre Mac reste éveillé lorsque l’écran est fermé ; les téléchargements, rendus et sessions à distance se poursuivent.
合并为一个 Combinés
合并显示 Combinés
合盖后继续运行 Continuer avec l’écran fermé
合盖电量下限 Seuil de batterie écran fermé
合盖睡眠 Veille à la fermeture
@@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 L’uti
重置卡:{} Crédits de réinitialisation : {}
套餐剩余时间 Durée restante du forfait
Token 用量 Tokens
仅图标 Icône seule
只显示 XStats 图标,点击查看已选项目的状态总览 Afficher uniquement l’icône XStats ; cliquer pour voir les indicateurs sélectionnés
面板项目 Indicateurs du panneau
所选项目显示在弹出面板中,原有图标风格会保留。 Les indicateurs sélectionnés apparaissent dans le panneau. Vos styles d’icônes sont conservés.
启动中… Démarrage…
正在启动风扇… Démarrage des ventilateurs…
打开进程监控 Ouvrir le moniteur de processus
IP 归属地 Localisation IP
1 # Copyright (C) 2026 ysicing
435 各核心负载
436 各项指标正常
437 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。
438 合并为一个 合并显示
439 合盖后继续运行
440 合盖电量下限
441 合盖睡眠
1649 重置卡:{}
1650 套餐剩余时间
1651 Token 用量
1652 仅图标
1653 只显示 XStats 图标,点击查看已选项目的状态总览
1654 面板项目
1655 所选项目显示在弹出面板中,原有图标风格会保留。
1656 启动中…
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
@@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ
各核心负载 コア別負荷
各项指标正常 すべて正常です
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 蓋を閉じてもMacはスリープせず、ダウンロード、レンダリング、リモート接続が続きます。
合并为一个 1つにまとめる
合并显示 1つにまとめる
合盖后继续运行 蓋を閉じても実行を続ける
合盖电量下限 蓋閉じモードの最低バッテリー残量
合盖睡眠 蓋を閉じるとスリープ
@@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 使
重置卡:{} リセット権:{}
套餐剩余时间 プランの残り時間
Token 用量 トークン
仅图标 アイコンのみ
只显示 XStats 图标,点击查看已选项目的状态总览 XStatsのアイコンのみを表示し、クリックすると選択した項目の概要を表示します
面板项目 パネルの項目
所选项目显示在弹出面板中,原有图标风格会保留。 選択した項目はポップオーバーに表示されます。アイコンのスタイルは保持されます。
启动中… 起動中…
正在启动风扇… ファンを起動中…
打开进程监控 プロセスモニターを開く
IP 归属地 IPの所在地
1 # Copyright (C) 2026 ysicing
435 各核心负载
436 各项指标正常
437 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。
438 合并为一个 合并显示
439 合盖后继续运行
440 合盖电量下限
441 合盖睡眠
1649 重置卡:{}
1650 套餐剩余时间
1651 Token 用量
1652 仅图标
1653 只显示 XStats 图标,点击查看已选项目的状态总览
1654 面板项目
1655 所选项目显示在弹出面板中,原有图标风格会保留。
1656 启动中…
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
@@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키
各核心负载 코어별 부하
各项指标正常 모든 지표 정상
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 덮개를 닫아도 Mac이 잠자기에 들어가지 않아 다운로드, 렌더링, 원격 연결이 계속됩니다.
合并为一个 하나로 합치기
合并显示 하나로 합치기
合盖后继续运行 덮개를 닫아도 계속 실행
合盖电量下限 덮개 모드 최소 배터리
合盖睡眠 덮개를 닫으면 잠자기
@@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 사
重置卡:{} 초기화 크레딧: {}
套餐剩余时间 요금제 남은 시간
Token 用量 토큰
仅图标 아이콘만
只显示 XStats 图标,点击查看已选项目的状态总览 XStats 아이콘만 표시합니다. 클릭하면 선택한 항목의 상태를 볼 수 있습니다
面板项目 패널 항목
所选项目显示在弹出面板中,原有图标风格会保留。 선택한 항목은 팝오버에 표시됩니다. 기존 아이콘 스타일은 유지됩니다.
启动中… 시작 중…
正在启动风扇… 팬 시작 중…
打开进程监控 프로세스 모니터 열기
IP 归属地 IP 위치
1 # Copyright (C) 2026 ysicing
435 各核心负载
436 各项指标正常
437 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。
438 合并为一个 合并显示
439 合盖后继续运行
440 合盖电量下限
441 合盖睡眠
1649 重置卡:{}
1650 套餐剩余时间
1651 Token 用量
1652 仅图标
1653 只显示 XStats 图标,点击查看已选项目的状态总览
1654 面板项目
1655 所选项目显示在弹出面板中,原有图标风格会保留。
1656 启动中…
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
@@ -436,7 +436,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python
各核心负载 各核心負載
各项指标正常 各項指標正常
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 合上屏幕時 Mac 不進入睡眠,下載、渲染、遠程連接不中斷。
合并为一个 合併為一個
合并显示 合併為一個
合盖后继续运行 合蓋後繼續運行
合盖电量下限 合蓋電量下限
合盖睡眠 合蓋睡眠
@@ -1650,3 +1650,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 用
重置卡:{} 重設卡:{}
套餐剩余时间 方案剩餘時間
Token 用量 Token 用量
仅图标 僅圖示
只显示 XStats 图标,点击查看已选项目的状态总览 只顯示 XStats 圖示,點擊查看所選項目的狀態總覽
面板项目 面板項目
所选项目显示在弹出面板中,原有图标风格会保留。 所選項目顯示在彈出面板中,原有圖示風格會保留。
启动中… 啟動中…
正在启动风扇… 正在啟動風扇…
打开进程监控 開啟程序監控
IP 归属地 IP 歸屬地
1 # Copyright (c) 2026 GiantAccel, LLC
436 各核心负载
437 各项指标正常
438 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。
439 合并为一个 合并显示
440 合盖后继续运行
441 合盖电量下限
442 合盖睡眠
1650 重置卡:{}
1651 套餐剩余时间
1652 Token 用量
1653 仅图标
1654 只显示 XStats 图标,点击查看已选项目的状态总览
1655 面板项目
1656 所选项目显示在弹出面板中,原有图标风格会保留。
1657 启动中…
1658 正在启动风扇…
1659 打开进程监控
1660 IP 归属地
@@ -590,7 +590,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and
各核心负载 Core Load
各项指标正常 Everything looks good
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 Your Mac stays awake with the lid closed, so downloads, renders and remote sessions continue.
合并为一个 Combined
合并显示 Combined
合盖后继续运行 Keep Running with Lid Closed
合盖电量下限 Lid Mode Battery Floor
合盖睡眠 Lid sleep
@@ -1461,5 +1461,13 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
进程功能开启时打开 XStats 进程页,关闭时打开系统活动监视器。 Opens XStats Processes when enabled, or macOS Activity Monitor when the feature is off.
不能在 XStats 内卸载或退出 XStats 自身 XStats cannot uninstall or quit itself from this tool
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 The app could not be moved. Related items moved to Trash: {} (about {}). Resolve the error and try again.
仅图标 Icon only
只显示 XStats 图标,点击查看已选项目的状态总览 Show only the XStats icon; click to view an overview of selected metrics
面板项目 Panel metrics
所选项目显示在弹出面板中,原有图标风格会保留。 Selected metrics appear in the popover. Your icon styles are preserved.
启动中… Starting…
正在启动风扇… Starting fans…
打开进程监控 Open process monitor
IP 归属地 IP location
"""#
}
@@ -48,7 +48,10 @@ public actor MetricsHub {
public init() {}
public func start(handler: @escaping @MainActor @Sendable (MetricsSnapshot) -> Void) {
/// 正常启动预采全部指标以稳定首次布局;专用快照和资源验证可仅采当前需求。
public func start(primeAllMetrics: Bool = true,
handler: @escaping @MainActor @Sendable (MetricsSnapshot) -> Void) {
if !primeAllMetrics { primed = true }
self.handler = handler
restart()
}
@@ -15,14 +15,18 @@ public struct FanState: Sendable, Equatable, Identifiable {
public var maximum: Double
public var target: Double
public var isManual: Bool
/// 固件报告起步、且尚未读到转速;展示窗口有上限,不掩盖持续停转。
public var isStarting: Bool
public init(id: Int, current: Double, minimum: Double, maximum: Double, target: Double, isManual: Bool) {
public init(id: Int, current: Double, minimum: Double, maximum: Double, target: Double, isManual: Bool,
isStarting: Bool = false) {
self.id = id
self.current = current
self.minimum = minimum
self.maximum = maximum
self.target = target
self.isManual = isManual
self.isStarting = isStarting
}
/// 当前转速在 min...max 区间的占比
@@ -32,9 +36,34 @@ public struct FanState: Sendable, Equatable, Identifiable {
}
}
/// 部分机型没有起步状态键,只能以手动目标转速推断。两种来源均最多提示 10 秒,
/// 超时后保留真实的 0 RPM,直到风扇转动或起步条件消失才允许开始新的窗口。
struct FanStartupTracker {
private var startedAt: [Int: TimeInterval] = [:]
mutating func update(id: Int, current: Double, target: Double, isManual: Bool,
status: Double?, now: TimeInterval) -> Bool {
let starting = status.map { $0 == 1 } ?? (isManual && target.isFinite && target > 0)
guard current == 0, starting else {
startedAt[id] = nil
return false
}
let start = startedAt[id] ?? now
startedAt[id] = start
return now - start < 10
}
mutating func retain(fanCount: Int?) {
// nil 是读取失败,不能当作确认无风扇,否则会重新开始已超时的提示。
guard let fanCount else { return }
startedAt = startedAt.filter { (0..<fanCount).contains($0.key) }
}
}
public final class FanControl {
private let smc: SMCConnection
private var lowercaseModeKey: Bool?
private var startup = FanStartupTracker()
public init(smc: SMCConnection) {
self.smc = smc
@@ -45,15 +74,24 @@ public final class FanControl {
}
public func read() -> [FanState] {
(0..<fanCount).compactMap { id in
let now = ProcessInfo.processInfo.systemUptime
let count = smc.double(SMCKey("FNum")).flatMap { UInt8(exactly: $0) }.map(Int.init)
startup.retain(fanCount: count)
guard let count else { return [] }
let fans = (0..<count).compactMap { id -> FanState? in
guard let current = smc.double(SMCKey("F\(id)Ac")) else { return nil }
let minimum = smc.double(SMCKey("F\(id)Mn")) ?? 0
let maximum = smc.double(SMCKey("F\(id)Mx")) ?? 0
let target = smc.double(SMCKey("F\(id)Tg")) ?? current
let mode = smc.double(modeKey(id)) ?? 0
// 仅在读数为零时多读一个状态键,运行中的风扇沿用原采样开销。
let status = current == 0 ? smc.double(SMCKey("F\(id)St")) : nil
let isStarting = startup.update(id: id, current: current, target: target,
isManual: mode == 1, status: status, now: now)
return FanState(id: id, current: current, minimum: minimum, maximum: maximum,
target: target, isManual: mode == 1)
target: target, isManual: mode == 1, isStarting: isStarting)
}
return fans
}
// MARK: 写入(需要 root)
@@ -525,7 +525,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
}
private func updateNetworkVisibility() {
model.network.setVisibility(inMenuBar: model.settings.menuBarItems.contains(.network),
model.network.setVisibility(inMenuBar: model.drawnMenuBarItems.contains(.network),
detailVisible: model.isNetworkDetailVisible)
}
@@ -29,6 +29,9 @@ final class MenuBarController: NSObject {
func update() {
let settings = model.settings
let enabled = model.visibleMenuBarItems
if let tab = model.combinedPopoverTab, !enabled.contains(tab) {
model.combinedPopoverTab = nil
}
// 没有任何项目时保留一个图标,确保仍能打开面板与主窗口
let signature: [MenuBarItem?] = settings.menuBarLayout == .separate && !enabled.isEmpty ? enabled : [nil]
if signature != layoutSignature {
@@ -77,9 +80,9 @@ final class MenuBarController: NSObject {
func refreshImages(force: Bool = false) {
let settings = model.settings
let reading = MenuBarReading(model: model, items: model.visibleMenuBarItems)
let reading = MenuBarReading(model: model, items: model.drawnMenuBarItems)
for (index, entry) in items.enumerated() {
let itemsToDraw = entry.key.map { [$0] } ?? model.visibleMenuBarItems
let itemsToDraw = entry.key.map { [$0] } ?? model.drawnMenuBarItems
var entryReading = reading
// 防休眠标记只画在最左侧的图标里
entryReading.keepAwake = reading.keepAwake && index == 0
@@ -101,7 +104,7 @@ final class MenuBarController: NSObject {
renderedReadings[entry.key] = (entryReading, appearance)
}
// 额度的重置时间、过期状态等只影响提示,不必重画相同图像。
let tooltip = reading.tooltip(items: itemsToDraw, fahrenheit: settings.useFahrenheit)
let tooltip = itemsToDraw.isEmpty ? "XStats" : reading.tooltip(items: itemsToDraw, fahrenheit: settings.useFahrenheit)
if button.toolTip != tooltip { button.toolTip = tooltip }
}
}
@@ -159,7 +162,7 @@ final class MenuBarController: NSObject {
private func makeCombinedPanel() -> StatusPanel {
let model = self.model
let panel = StatusPanel(width: DS.Size.popoverWidth, minHeight: DS.Size.panelMinHeight / 2,
let panel = StatusPanel(width: DS.Size.combinedPopoverWidth, minHeight: DS.Size.panelMinHeight / 2,
content: { CombinedPopoverView().environment(model) },
measuring: { CombinedPopoverView().environment(model).environment(\.isSnapshot, true) })
panel.onVisibilityChange = { [weak self] visible in
@@ -278,7 +278,7 @@ enum MenuBarRenderer {
static func image(for model: AppModel) -> NSImage {
let settings = model.settings
let items = model.visibleMenuBarItems
let items = model.drawnMenuBarItems
return image(reading: MenuBarReading(model: model, items: items),
items: items,
style: { settings.style(for: $0) },
@@ -299,7 +299,8 @@ enum MenuBarRenderer {
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")) }
// 即使防休眠标记存在,空项目布局也保留 XStats 的主入口图标。
if items.isEmpty { segments.append(symbolSegment("waveform.path.ecg")) }
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
@@ -168,6 +168,7 @@ extension DS {
static let panelWidth: CGFloat = 640
/// 菜单栏单项详情弹窗的宽度
static let popoverWidth: CGFloat = 320
static let combinedPopoverWidth: CGFloat = 360
static let panelMinHeight: CGFloat = 320
static let panelGap: CGFloat = 4
/// 窗口顶栏高度:标准工具栏的标题栏(52pt),红绿灯、页面标题与右上角 32pt 的按钮在同一条水平线上,按钮上下各留 10pt
@@ -297,11 +297,13 @@ private struct FanTile: View {
.foregroundStyle(DS.Palette.textSecondary)
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) {
Text(verbatim: fastest.map { Int($0.current).formatted() } ?? "—")
Text(verbatim: fans.contains(where: \.isStarting) ? tr("启动中…") : fastest.map { Int($0.current).formatted() } ?? "—")
.dsFont(.xl, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: "RPM").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
if !fans.contains(where: \.isStarting) {
Text(verbatim: "RPM").dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
}
}
Text(fanStatusText(fans: fans, mode: model.fans.mode))
@@ -158,7 +158,7 @@ private struct FanRow: View {
HStack {
Text(name).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
Spacer()
Text(Format.rpm(fan.current))
Text(fan.isStarting ? tr("启动中…") : Format.rpm(fan.current))
.dsFont(.sm, weight: .semibold)
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
@@ -4,7 +4,7 @@ import SMC
import SwiftUI
/// 菜单栏合并为一个图标时点击弹出的面板:顶部一排标签切换“总览”与菜单栏里开启的各项。
/// 总览每项一行(名称、状态、走势、主数值),点一行或点标签看该项的完整详情,与每项独立时的弹窗相同
/// 总览以两列指标卡片显示读数,网络、电池与 AI 使用整行;点卡片或标签进入现有详情。
struct CombinedPopoverView: View {
@Environment(AppModel.self) private var model
@@ -13,7 +13,7 @@ struct CombinedPopoverView: View {
// 标签对应的项目在设置里被关掉后回到总览
let tab = model.combinedPopoverTab.flatMap { items.contains($0) ? $0 : nil }
PopoverFrame {
PopoverFrame(width: DS.Size.combinedPopoverWidth) {
VStack(spacing: DS.Space.s2) {
if !items.isEmpty {
PopoverTabStrip(items: items, selection: tab) { model.combinedPopoverTab = $0 }
@@ -53,7 +53,7 @@ private struct PopoverTabStrip: View {
}
}
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous))
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: selection)
.dsSelectionAnimation(DS.Motion.quick, value: selection)
}
private func segment(_ item: MenuBarItem?, symbol: String, title: String) -> some View {
@@ -122,15 +122,41 @@ private struct OverviewPopover: View {
.buttonStyle(DSButtonStyle(kind: .secondary))
}
} else {
ForEach(items) { item in
OverviewRow(item: item) { open(item) }
let metrics = items.filter { $0 != .network && $0 != .battery && $0 != .aiUsage }
// 项目数量有上限;直接布局全部卡片,让离屏测高与真实面板得到同样的高度。
ForEach(Array(stride(from: 0, to: metrics.count, by: 2)), id: \.self) { index in
WeightedRow {
OverviewMetricCard(item: metrics[index]) { open(metrics[index]) }
if index + 1 < metrics.count {
OverviewMetricCard(item: metrics[index + 1]) { open(metrics[index + 1]) }
} else {
Color.clear
}
}
}
ForEach(items.filter { $0 == .network || $0 == .battery || $0 == .aiUsage }) { item in
OverviewMetricCard(item: item) { open(item) }
}
}
if model.showsOverviewProcesses { OverviewProcessesCard() }
HStack(spacing: DS.Space.s2) {
Button { model.openMainWindow(.keepAwake) } label: {
Label(tr("防休眠"), systemImage: model.keepAwake.isActive ? "cup.and.saucer.fill" : "cup.and.saucer")
.frame(maxWidth: .infinity)
}
.buttonStyle(DSButtonStyle(kind: .secondary))
if model.settings.cleanerEnabled {
Button { model.openMainWindow(.cleaner) } label: {
Label(tr("清理"), systemImage: "sparkles").frame(maxWidth: .infinity)
}
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
}
}
/// 总览里的一行:左边名称与一句状态,中间是走势,右边是主数值;点一下进入这一项的详情
private struct OverviewRow: View {
/// 数值、状态与走势保持固定层级,读数更新只重绘内容,不播放布局动画。
private struct OverviewMetricCard: View {
let item: MenuBarItem
let open: () -> Void
@Environment(AppModel.self) private var model
@@ -139,51 +165,78 @@ private struct OverviewRow: View {
var body: some View {
let reading = OverviewReading(item: item, model: model)
Card {
Button(action: open) {
HStack(spacing: DS.Space.s2) {
Button(action: open) {
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s1) {
Image(systemName: item.symbol)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
.foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
VStack(alignment: .leading, spacing: DS.Space.s1 / 2) {
Text(item.popoverTitle)
.dsFont(.sm, weight: .semibold)
.foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textPrimary)
Text(verbatim: reading.detail)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
OverviewChart(chart: reading.chart)
.frame(width: DS.Space.s12, height: DS.Space.s6)
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) {
Text(verbatim: reading.value)
.dsFont(.base, weight: .semibold)
.foregroundStyle(reading.tone == .neutral ? DS.Palette.textPrimary : reading.tone.color)
if let unit = reading.unit {
Text(verbatim: unit)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
}
}
.monospacedDigit()
.lineLimit(1)
.frame(minWidth: DS.Space.s12, alignment: .trailing)
Text(item.popoverTitle)
Spacer(minLength: DS.Space.s1)
Image(systemName: "chevron.right")
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
.foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textTertiary)
.foregroundStyle(DS.Palette.textTertiary)
}
.contentShape(Rectangle())
.dsFont(.xs, weight: .semibold)
.foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textSecondary)
.lineLimit(2)
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) {
Text(verbatim: reading.value)
.dsFont(.xl, weight: .semibold)
.foregroundStyle(reading.tone == .neutral ? DS.Palette.textPrimary : reading.tone.color)
if let unit = reading.unit {
Text(verbatim: unit)
.dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textSecondary)
}
}
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, alignment: .leading)
Text(verbatim: reading.detail)
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
.lineLimit(2)
.frame(maxWidth: .infinity, minHeight: DS.Space.s6, alignment: .topLeading)
OverviewChart(chart: reading.chart)
.frame(height: DS.Space.s6)
}
.environment(\.isPopover, false)
.contentShape(RoundedRectangle(cornerRadius: DS.Radius.lg))
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.help(tr("查看\(item.popoverTitle)详情"))
.accessibilityElement(children: .combine)
}
}
private struct OverviewProcessesCard: View {
@Environment(AppModel.self) private var model
private static let rowCount = 3
var body: some View {
let processes = Array(model.store.processes.prefix(Self.rowCount))
Card(padding: DS.Space.s3, spacing: DS.Space.s2) {
HStack {
Text(tr("高占用进程")).dsFont(.xs, weight: .semibold)
Spacer()
MiniIconButton(systemName: "arrow.up.right", help: tr("打开进程监控")) { model.openProcessMonitor() }
}
.foregroundStyle(DS.Palette.textSecondary)
ProcessList(count: processes.count, rowCount: Self.rowCount) { index in
let process = processes[index]
HStack(spacing: DS.Space.s2) {
ProcessNameLabel(icon: AppIconCache.shared.image(for: process), name: process.displayName)
Text(verbatim: Format.machineShare(process.cpu))
.dsFont(.xs, weight: .medium)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
Text(verbatim: Format.bytes(process.memory))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.valueColumn, alignment: .trailing)
}
.monospacedDigit()
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
.help(tr("查看\(item.popoverTitle)详情"))
}
}
}
@@ -286,8 +339,8 @@ private struct OverviewReading {
case .fan:
let fans = store.sensors?.fans ?? []
if let fastest = store.fastestFan {
value = Int(fastest.current).formatted()
unit = "RPM"
value = fans.contains(where: \.isStarting) ? tr("启动中…") : Int(fastest.current).formatted()
unit = fans.contains(where: \.isStarting) ? nil : "RPM"
let average = fans.map { $0.maximum > 0 ? $0.current / $0.maximum : 0 }.reduce(0, +) / Double(max(1, fans.count))
chart = .level(average, DS.Palette.primary)
}
@@ -720,7 +720,8 @@ struct ThermalPopover: View {
if model.store.supportsFans {
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(tr("风扇")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HeroValue(value: store.fastestFan.map { Int($0.current).formatted() } ?? "—", unit: fans.isEmpty ? nil : "RPM")
HeroValue(value: fans.contains(where: \.isStarting) ? tr("启动中…") : store.fastestFan.map { Int($0.current).formatted() } ?? "—",
unit: fans.isEmpty || fans.contains(where: \.isStarting) ? nil : "RPM")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -753,7 +754,7 @@ struct ThermalPopover: View {
} else {
ForEach(fans) { fan in
VStack(spacing: DS.Space.s1) {
InfoRow(label: tr("风扇 \(fan.id + 1)"), text: Format.rpm(fan.current))
InfoRow(label: tr("风扇 \(fan.id + 1)"), text: fan.isStarting ? tr("启动中…") : Format.rpm(fan.current))
ProgressTrack(fraction: fan.maximum > 0 ? fan.current / fan.maximum : 0, height: DS.Space.s1)
}
}
@@ -781,6 +782,7 @@ struct ThermalPopover: View {
@MainActor
func fanStatusText(fans: [FanState], mode: FanController.Mode) -> String {
if fans.isEmpty { return tr("未检测到风扇") }
if fans.contains(where: \.isStarting) { return tr("正在启动风扇…") }
guard fans.contains(where: \.isManual) else { return tr("由 macOS 调节") }
return mode == .automatic ? tr("其他程序手动控制") : tr("XStats 控制中")
}
@@ -23,6 +23,7 @@ struct PopoverRootView: View {
/// 菜单栏弹窗的外框:顶部标题栏固定,下面的内容可滚动
struct PopoverFrame<Header: View, Content: View>: View {
var width: CGFloat = DS.Size.popoverWidth
@ViewBuilder var header: Header
@ViewBuilder var content: Content
@Environment(\.isSnapshot) private var isSnapshot
@@ -46,7 +47,7 @@ struct PopoverFrame<Header: View, Content: View>: View {
.frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.environment(\.isPopover, true)
}
.frame(width: DS.Size.popoverWidth)
.frame(width: width)
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
.fixedSize(horizontal: false, vertical: isSnapshot)
.background(DS.Palette.background, in: RoundedRectangle(cornerRadius: DS.Radius.xl))
@@ -348,13 +348,10 @@ struct MenuBarSettings: View {
SettingsGroup(caption: tr("布局")) {
GroupRow(showsDivider: false) {
SettingRow(title: tr("菜单栏图标"),
subtitle: settings.menuBarLayout == .separate
? tr("每个指标一个图标,点击弹出该项详情")
: tr("所有指标合成一个图标,点击弹出状态总览,可切到各项详情")) {
VStack(alignment: .leading, spacing: DS.Space.s3) {
SettingRow(title: tr("菜单栏图标"), subtitle: settings.menuBarLayout.subtitle) { EmptyView() }
SegmentedControl(selection: $settings.menuBarLayout,
options: MenuBarLayout.allCases.map { ($0, $0.title) })
.frame(width: DS.Size.sidebarWidth + DS.Space.s6)
}
}
}
@@ -369,6 +366,7 @@ struct MenuBarSettings: View {
}
}
.padding(DS.Space.s3)
.disabled(settings.menuBarLayout == .iconOnly)
HairlineDivider()
Text(tr("整体风格套用到所有项目。想让某个项目不一样,在下面“显示项目”里给它单独选一种,比如 CPU 用圆环、风扇用数字。"))
.dsFont(.xs)
@@ -378,10 +376,12 @@ struct MenuBarSettings: View {
.padding(.vertical, DS.Space.s3)
}
SettingsGroup(caption: tr("显示项目")) {
SettingsGroup(caption: settings.menuBarLayout == .iconOnly ? tr("面板项目") : tr("显示项目")) {
GroupRow(showsDivider: false) {
InfoBanner(icon: "hand.draw",
text: tr("菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。"))
InfoBanner(icon: settings.menuBarLayout == .iconOnly ? "square.grid.2x2" : "hand.draw",
text: settings.menuBarLayout == .iconOnly
? tr("所选项目显示在弹出面板中,原有图标风格会保留。")
: tr("菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。"))
}
ForEach(Array(model.availableMenuBarItems.enumerated()), id: \.element) { index, item in
let moduleEnabled = item != .aiUsage || settings.aiUsageEnabled
@@ -401,6 +401,7 @@ struct MenuBarSettings: View {
}
if moduleEnabled, settings.isEnabled(item) {
ItemStyleRow(item: item)
.disabled(settings.menuBarLayout == .iconOnly)
if item == .network {
SettingRow(title: tr("IP 归属地"),
subtitle: tr("与网络详情中的 IP 归属地共用查询结果和刷新,不额外联网。需开启公网 IP 查询。")) {
@@ -22,6 +22,10 @@ enum SnapshotRenderer {
renderCalendar(outputDirectory: outputDirectory)
return
}
if CommandLine.arguments.contains("--menubar-only") {
await renderMenuBar(outputDirectory: outputDirectory)
return
}
let defaults = UserDefaults(suiteName: "XStats.snapshot") ?? .standard
let settings = AppSettings(defaults: defaults)
@@ -128,6 +132,57 @@ enum SnapshotRenderer {
print(tr("截图已输出到 \(outputDirectory.path)"))
}
/// 菜单栏走查只启动相关指标采样,不扫描清理目录、不读取 AI 日志或查询更新。
private static func renderMenuBar(outputDirectory: URL) async {
let suite = "XStats.menuBarSnapshot.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suite) else { return }
defer { defaults.removePersistentDomain(forName: suite) }
let settings = AppSettings(defaults: defaults)
settings.language = L10n.language
settings.menuBarItems = Set(MenuBarItem.allCases.filter { $0 != .aiUsage })
settings.processesEnabled = true
settings.cleanerEnabled = true
settings.menuBarLayout = .iconOnly
settings.panelTab = .settingsMenuBar
let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [], aiQuotaProviders: [])
model.isCombinedPopoverOpen = true
await model.hub.update(model.demand)
await model.hub.start(primeAllMetrics: false) { snapshot in model.store.apply(snapshot) }
try? await Task.sleep(for: .seconds(3))
await model.hub.stop()
model.isMainWindowVisible = true
// CPU 详情需要频率数据;显式按详情需求采一次,避免借用全量预采样的副作用。
model.combinedPopoverTab = .cpu
await model.hub.update(model.demand)
await model.hub.start(primeAllMetrics: false) { snapshot in model.store.apply(snapshot) }
// 频率首次采样只建立计数器基线;等待有效差值,硬件不支持时最多等 4 秒。
for _ in 0..<16 {
try? await Task.sleep(for: .milliseconds(250))
if model.store.power?.clusterFrequency.isEmpty == false { break }
}
await model.hub.stop()
for (name, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] {
guard let appearance = NSAppearance(named: name) else { continue }
NSApp.appearance = appearance
model.combinedPopoverTab = nil
write(CombinedPopoverView(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("overview-\(suffix).png"))
for item: MenuBarItem in [.cpu, .network] {
model.combinedPopoverTab = item
write(CombinedPopoverView(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("detail-\(item.rawValue)-\(suffix).png"))
}
model.combinedPopoverTab = nil
for layout in MenuBarLayout.allCases {
settings.menuBarLayout = layout
write(MainWindowView(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("settings-\(layout.rawValue)-\(suffix).png"))
let image = MenuBarRenderer.preview(MenuBarRenderer.image(for: model), dark: suffix == "dark")
writePNG(image, scale: 2, to: outputDirectory.appendingPathComponent("menubar-\(layout.rawValue)-\(suffix).png"))
}
}
}
private static func renderCalendar(outputDirectory: URL) {
let name = "XStats.calendarSnapshot.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else { return }
@@ -45,7 +45,7 @@ public final class AppModel {
/// 当前打开的菜单栏详情弹窗;合并模式的弹窗切到某一项时也是这一项
public var openPopover: MenuBarItem?
/// 合并模式下点击菜单栏图标弹出的面板正在显示。
/// 面板切到某一项的详情时 openPopover 就是这一项,按那一项的弹窗加采数据;总览只用菜单栏本来就在采的指标
/// 面板切到详情时按该项加采;总览仅采已选卡片所需的数据,关闭后释放面板需求。
public var isCombinedPopoverOpen = false {
didSet {
guard isCombinedPopoverOpen != oldValue else { return }
@@ -112,6 +112,19 @@ public final class AppModel {
settings.orderedMenuBarItems.filter { $0 != .fan || store.supportsFans }
}
/// 实际绘制读数的项目与面板项目分开,切换为仅图标时不改写用户的项目选择。
var drawnMenuBarItems: [MenuBarItem] {
settings.menuBarLayout == .iconOnly ? [] : visibleMenuBarItems
}
var isCombinedOverviewVisible: Bool {
isCombinedPopoverOpen && combinedPopoverTab == nil
}
var showsOverviewProcesses: Bool {
settings.processesEnabled && visibleMenuBarItems.contains { $0 == .cpu || $0 == .memory }
}
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
var demand: MetricsDemand {
var demand = MetricsDemand()
@@ -119,7 +132,9 @@ public final class AppModel {
let window = isMainWindowVisible
let processPage = settings.processesEnabled && window && tab == .processes
let popover = openPopover
let menu = settings.menuBarItems
let menu = Set(drawnMenuBarItems)
let overview = isCombinedOverviewVisible ? Set(visibleMenuBarItems) : []
let summary = menu.union(overview)
let thermalPopover = popover == .temperature || popover == .fan
// 仅实时监控页需要每秒采样;历史、工具和设置页沿用用户设置的后台间隔。
@@ -131,17 +146,19 @@ public final class AppModel {
demand.memory = true
demand.network = true
let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item }
demand.gpu = (window && [.overview, .system].contains(tab)) || menu.contains(.gpu) || showing(.gpu, .gpu)
demand.disk = (window && [.overview, .system, .cleaner, .disk].contains(tab)) || menu.contains(.disk) || popover == .disk
demand.gpu = (window && [.overview, .system].contains(tab)) || summary.contains(.gpu) || showing(.gpu, .gpu)
demand.disk = (window && [.overview, .system, .cleaner, .disk].contains(tab)) || summary.contains(.disk) || popover == .disk
demand.diskDetail = showing(.disk, .disk)
demand.battery = (window && [.overview, .system, .keepAwake].contains(tab)) || keepAwake.lidClosedActive
|| menu.contains(.battery) || showing(.battery, .battery)
|| summary.contains(.battery) || showing(.battery, .battery)
demand.processes = processPage || (window && [.overview, .disk].contains(tab)) || showing(.cpu, .cpu) || showing(.memory, .memory)
|| popover == .disk
|| popover == .disk || (isCombinedOverviewVisible && showsOverviewProcesses)
demand.systemProcesses = processPage
var groups = Set<TemperatureGroup>()
if window && tab == .overview { groups.formUnion([.cpu, .gpu]) }
if overview.contains(.cpu) || overview.contains(.temperature) { groups.insert(.cpu) }
if overview.contains(.gpu) { groups.insert(.gpu) }
if (window && tab == .thermal) || thermalPopover { groups.formUnion(TemperatureGroup.allCases) }
if menu.contains(.temperature) || fans.mode != .automatic || showing(.cpu, .cpu) { groups.insert(.cpu) }
if showing(.gpu, .gpu) { groups.insert(.gpu) }
@@ -151,8 +168,10 @@ public final class AppModel {
demand.power = (window && tab == .thermal) || thermalPopover || showing(.battery, .battery)
demand.cpuFrequency = showing(.cpu, .cpu)
// 未知时隐藏入口,但保留按需探测;不能用界面可见性阻断首次识别或失败重试。
demand.fans = store.fanCount != 0 && ((window && (tab == .thermal || tab == .overview)) || menu.contains(.fan)
|| fans.mode != .automatic || thermalPopover)
demand.fans = store.fanCount != 0 && ((window && (tab == .thermal || tab == .overview)) || summary.contains(.fan)
|| fans.mode != .automatic || thermalPopover
|| (store.fanCount == nil && settings.menuBarItems.contains(.fan)
&& (settings.menuBarLayout != .iconOnly || isCombinedOverviewVisible)))
return demand
}
@@ -160,7 +179,8 @@ public final class AppModel {
/// 菜单栏开着电池项且要做低电量提示、或这台 Mac 没有电池时每 5 分钟
var bluetoothDemand: BluetoothController.Demand {
if (isMainWindowVisible && [.battery, .system].contains(settings.panelTab)) || openPopover == .battery { return .foreground }
if settings.menuBarItems.contains(.battery) && (settings.bluetoothLowBatteryInMenuBar || store.battery == nil) { return .background }
if isCombinedOverviewVisible && visibleMenuBarItems.contains(.battery) && store.battery == nil { return .foreground }
if drawnMenuBarItems.contains(.battery) && (settings.bluetoothLowBatteryInMenuBar || store.battery == nil) { return .background }
return .off
}
@@ -172,13 +172,24 @@ public enum MenuBarLayout: String, CaseIterable, Identifiable, Sendable {
case separate
/// 所有指标合成一个图标,点击弹出状态总览,可切到各项详情
case combined
/// 只显示应用标志;保存的项目选择用于弹出面板,不持续绘制读数。
case iconOnly
public var id: String { rawValue }
var title: String {
switch self {
case .separate: tr("每项独立")
case .combined: tr("合并为一个")
case .combined: tr("合并显示")
case .iconOnly: tr("仅图标")
}
}
var subtitle: String {
switch self {
case .separate: tr("每个指标一个图标,点击弹出该项详情")
case .combined: tr("所有指标合成一个图标,点击弹出状态总览,可切到各项详情")
case .iconOnly: tr("只显示 XStats 图标,点击查看已选项目的状态总览")
}
}
}
@@ -86,6 +86,9 @@ struct LanguageSupportTests {
let catalog = try #require(Translations.catalog(for: language))
#expect(catalog.sourceKeys == Translations.shared.sourceKeys)
for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言",
"合并显示", "仅图标", "面板项目", "启动中…", "正在启动风扇…",
"只显示 XStats 图标,点击查看已选项目的状态总览",
"所选项目显示在弹出面板中,原有图标风格会保留。", "打开进程监控",
"额度显示", "数字单位", "货币", "估算费用",
"30 天", "按小时统计", "按天统计", "暂无小时明细,请刷新用量。",
"套餐:Pro", "套餐有效期至:2026-10-20", "可用重置次数:3", "重置次数最早到期:2026-10-05",
@@ -0,0 +1,73 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Testing
@testable import SMC
struct FanStartupTests {
@Test func firmwareStateDoesNotRequireAManualTarget() {
var tracker = FanStartupTracker()
let starting = tracker.update(id: 0, current: 0, target: 0, isManual: false, status: 1, now: 0)
#expect(starting)
}
@Test func firmwareStartupIsBoundedAndResetsAfterRotation() {
var tracker = FanStartupTracker()
let result1 = tracker.update(id: 0, current: 0, target: 2000, isManual: false, status: 1, now: 100)
#expect(result1)
let result2 = tracker.update(id: 0, current: 0, target: 2000, isManual: false, status: 1, now: 105)
#expect(result2)
let result3 = tracker.update(id: 0, current: 0, target: 2000, isManual: false, status: 1, now: 111)
#expect(!result3)
let result4 = tracker.update(id: 0, current: 0, target: 2500, isManual: false, status: 1, now: 112)
#expect(!result4)
let result5 = tracker.update(id: 0, current: 1500, target: 2000, isManual: false, status: 5, now: 113)
#expect(!result5)
let result6 = tracker.update(id: 0, current: 0, target: 2000, isManual: false, status: 1, now: 114)
#expect(result6)
}
@Test func fallbackRequiresManualTargetAndDoesNotHideStoppedFans() {
var tracker = FanStartupTracker()
let result7 = tracker.update(id: 0, current: 0, target: 2000, isManual: false, status: nil, now: 1)
#expect(!result7)
let result8 = tracker.update(id: 0, current: 0, target: 0, isManual: true, status: nil, now: 2)
#expect(!result8)
let result9 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: 3, now: 3)
#expect(!result9)
let result10 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 4)
#expect(result10)
let result11 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 14)
#expect(!result11)
let result12 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 50)
#expect(!result12)
}
@Test func eachFanHasAnIndependentStartupWindow() {
var tracker = FanStartupTracker()
let result13 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 10)
#expect(result13)
let result14 = tracker.update(id: 1, current: 0, target: 2000, isManual: true, status: nil, now: 15)
#expect(result14)
tracker.retain(fanCount: 2)
let result15 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 20)
#expect(!result15)
let result16 = tracker.update(id: 1, current: 0, target: 2000, isManual: true, status: nil, now: 20)
#expect(result16)
tracker.retain(fanCount: 0)
let result17 = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 21)
#expect(result17)
}
@Test func failedFanCountDoesNotRestartAnExpiredWindow() {
var tracker = FanStartupTracker()
let initiallyStarting = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 0)
#expect(initiallyStarting)
tracker.retain(fanCount: nil)
tracker.retain(fanCount: 1)
let afterReadFailure = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 11)
#expect(!afterReadFailure)
tracker.retain(fanCount: nil)
let afterRepeatedFailure = tracker.update(id: 0, current: 0, target: 2000, isManual: true, status: nil, now: 20)
#expect(!afterRepeatedFailure)
}
}
@@ -0,0 +1,180 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
@testable import Metrics
import Observation
import SwiftUI
import Testing
@testable import XStatsUI
@MainActor
struct MenuBarLayoutTests {
private func model(_ body: (AppModel, UserDefaults) throws -> Void) throws {
let suite = "MenuBarLayoutTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let model = AppModel(settings: AppSettings(defaults: defaults), historyURL: nil,
aiUsageProviders: [], aiQuotaProviders: [])
try body(model, defaults)
}
@Test func iconOnlyPreservesItemsAndRoundTripsSettings() throws {
try model { model, defaults in
let settings = model.settings
settings.menuBarItems = [.gpu, .disk, .temperature, .battery]
settings.menuBarStyle = .ring
settings.menuBarLayout = .iconOnly
#expect(model.drawnMenuBarItems.isEmpty)
#expect(Set(model.visibleMenuBarItems) == settings.menuBarItems)
let document = settings.exportDocument()
settings.menuBarLayout = .separate
settings.apply(document)
#expect(settings.menuBarLayout == .iconOnly)
#expect(settings.menuBarStyle == .ring)
#expect(settings.menuBarItems == [.gpu, .disk, .temperature, .battery])
#expect(AppSettings(defaults: defaults).menuBarLayout == .iconOnly)
settings.menuBarLayout = .combined
#expect(model.drawnMenuBarItems == model.visibleMenuBarItems)
}
}
@Test func overviewSamplingStartsOnOpenAndStopsOnClose() throws {
try model { model, _ in
model.settings.menuBarLayout = .iconOnly
model.settings.menuBarItems = [.cpu, .memory, .gpu, .disk, .temperature, .battery]
model.settings.processesEnabled = true
model.settings.refreshSeconds = 5
let idle = model.demand
#expect(!idle.gpu && !idle.disk && !idle.battery && !idle.processes)
#expect(idle.temperatures.isEmpty)
#expect(model.bluetoothDemand == .off)
for _ in 0..<10 {
model.isCombinedPopoverOpen = true
let visible = model.demand
#expect(visible.interval == .seconds(1))
#expect(visible.gpu && visible.disk && visible.battery && visible.processes)
#expect(!visible.systemProcesses && !visible.diskDetail && !visible.power && !visible.cpuFrequency)
#expect(visible.temperatures.contains(.cpu))
model.combinedPopoverTab = .disk
#expect(model.demand.diskDetail)
#expect(!model.demand.gpu)
model.isCombinedPopoverOpen = false
#expect(model.openPopover == nil)
#expect(model.demand == idle)
model.combinedPopoverTab = nil
}
}
}
@Test func disabledProcessesAndUnselectedMetricsAreNotCollectedForOverview() throws {
try model { model, _ in
model.settings.menuBarLayout = .iconOnly
model.settings.menuBarItems = [.cpu, .memory]
model.settings.processesEnabled = false
model.isCombinedPopoverOpen = true
#expect(!model.demand.processes && !model.demand.systemProcesses)
#expect(!model.demand.gpu && !model.demand.disk && !model.demand.battery)
}
}
@Test func unknownFansCanStillBeDiscoveredWithoutRewritingPreferences() throws {
try model { model, _ in
model.settings.menuBarItems = [.fan]
#expect(!model.visibleMenuBarItems.contains(.fan))
#expect(model.demand.fans)
model.settings.menuBarLayout = .iconOnly
#expect(!model.demand.fans)
model.isCombinedPopoverOpen = true
#expect(model.demand.fans)
var snapshot = MetricsSnapshot()
snapshot.sensors = SensorReadings(temperatures: [], fans: [], fanCount: 0)
model.store.apply(snapshot)
#expect(!model.demand.fans)
#expect(model.settings.menuBarItems == [.fan])
}
}
@Test func iconImageIgnoresLiveMetricsAndRetainsKeepAwakeIndicator() throws {
try model { model, _ in
model.settings.menuBarLayout = .iconOnly
let before = MenuBarRenderer.image(for: model)
var snapshot = MetricsSnapshot()
snapshot.cpu = CPULoad(total: 0.95, user: 0.5, system: 0.45, perCore: [0.95], loadAverage: [1, 1, 1])
model.store.apply(snapshot)
let after = MenuBarRenderer.image(for: model)
#expect(before.size == after.size)
#expect(MenuBarRenderer.preview(before, dark: false).tiffRepresentation
== MenuBarRenderer.preview(after, dark: false).tiffRepresentation)
var reading = MenuBarReading.sample
let plain = MenuBarRenderer.image(reading: reading, items: [], style: { _ in .ring }, networkStyle: .dots,
colorizeHighLoad: true, fahrenheit: false)
reading.keepAwake = true
let awake = MenuBarRenderer.image(reading: reading, items: [], style: { _ in .ring }, networkStyle: .dots,
colorizeHighLoad: true, fahrenheit: false)
#expect(plain.isTemplate && awake.isTemplate)
#expect(awake.size.width > plain.size.width)
}
}
@Test func iconOnlyReadingDoesNotObserveMetricChanges() async throws {
let suite = "MenuBarLayoutTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let model = AppModel(settings: AppSettings(defaults: defaults), historyURL: nil,
aiUsageProviders: [], aiQuotaProviders: [])
model.settings.menuBarLayout = .iconOnly
await confirmation(expectedCount: 0) { changed in
withObservationTracking {
_ = MenuBarReading(model: model, items: model.drawnMenuBarItems)
} onChange: { changed() }
var snapshot = MetricsSnapshot()
snapshot.cpu = CPULoad(total: 0.9, user: 0.5, system: 0.4, perCore: [0.9], loadAverage: [1, 1, 1])
snapshot.gpu = GPUUsage(name: "Fixture", utilization: 0.5, coreCount: 8)
model.store.apply(snapshot)
}
}
@Test func combinedPanelReopensAndKeepsAConsistentWidthAcrossDetails() async throws {
let screen = try #require(NSScreen.main)
let suite = "MenuBarLayoutTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let model = AppModel(settings: AppSettings(defaults: defaults), historyURL: nil,
aiUsageProviders: [], aiQuotaProviders: [])
model.settings.menuBarItems = [.cpu, .memory, .gpu, .disk, .network, .temperature, .battery]
model.settings.menuBarLayout = .iconOnly
let idle = model.demand
let panel = StatusPanel(width: DS.Size.combinedPopoverWidth, minHeight: 100, maxHeight: 500) {
CombinedPopoverView().environment(model)
} measuring: {
CombinedPopoverView().environment(model).environment(\.isSnapshot, true)
}
panel.isPinned = true
panel.onVisibilityChange = { model.isCombinedPopoverOpen = $0 }
defer { panel.close() }
let anchor = NSRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.maxY, width: 20, height: 20)
for _ in 0..<5 {
panel.isPinned = true
model.combinedPopoverTab = nil
panel.present(below: anchor, on: screen)
let mounted = try #require(panel.contentView)
for item: MenuBarItem? in [nil, .cpu, .network, .memory, nil] {
model.combinedPopoverTab = item
panel.refreshHeight()
try await Task.sleep(for: .milliseconds(40))
mounted.layoutSubtreeIfNeeded()
#expect(panel.isVisible && model.isCombinedPopoverOpen)
#expect(panel.contentView === mounted)
#expect(abs(panel.frame.width - DS.Size.combinedPopoverWidth) < 1)
#expect(panel.frame.height <= 501)
#expect(model.openPopover == item)
}
panel.isPinned = false
panel.dismiss()
#expect(!panel.isVisible && !model.isCombinedPopoverOpen)
#expect(panel.contentView == nil)
#expect(model.demand == idle)
}
}
}
@@ -0,0 +1,102 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Darwin
import Metrics
import SwiftUI
import Testing
@testable import XStatsUI
/// 显式设置输出路径才运行,避免普通测试每次启动长时间的真实硬件采样。
@MainActor
struct MenuBarResourceTests {
private struct Phase: Codable {
let name: String
let elapsedSeconds: Double
let cpuSeconds: Double
let snapshots: Int
let gpuSamples: Int
let diskSamples: Int
let processSamples: Int
let sensorSamples: Int
}
@Test(.enabled(if: ProcessInfo.processInfo.environment["XSTATS_MENU_RESOURCE_REPORT"] != nil))
func realSamplingReturnsToIdleAfterClosingOverview() async throws {
let output = try #require(ProcessInfo.processInfo.environment["XSTATS_MENU_RESOURCE_REPORT"])
let screen = try #require(NSScreen.main)
let suite = "MenuBarResourceTests.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let model = AppModel(settings: AppSettings(defaults: defaults), historyURL: nil,
aiUsageProviders: [], aiQuotaProviders: [])
model.settings.menuBarLayout = .iconOnly
model.settings.menuBarItems = [.cpu, .memory, .network, .gpu, .disk, .temperature]
model.settings.processesEnabled = true
model.settings.refreshSeconds = 2
let panel = StatusPanel(width: DS.Size.combinedPopoverWidth, minHeight: 100, maxHeight: 500) {
CombinedPopoverView().environment(model)
} measuring: {
CombinedPopoverView().environment(model).environment(\.isSnapshot, true)
}
panel.onVisibilityChange = { model.isCombinedPopoverOpen = $0 }
defer { panel.isPinned = false; panel.dismiss(); panel.close() }
var samples: [MetricsSnapshot] = []
await model.hub.update(model.demand)
await model.hub.start(primeAllMetrics: false) { snapshot in
model.store.apply(snapshot)
samples.append(snapshot)
}
var report: [Phase] = []
for (name, visible, paused) in [("idle", false, false), ("overview", true, false),
("closed", false, false), ("paused", false, true),
("resumed", false, false)] {
if visible {
panel.isPinned = true
panel.present(below: NSRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.maxY,
width: 20, height: 20), on: screen)
} else {
panel.isPinned = false
panel.dismiss()
}
await model.hub.update(model.demand)
await model.hub.setPaused(paused)
samples.removeAll(keepingCapacity: true)
let cpu = Self.cpuTime()
let started = ProcessInfo.processInfo.systemUptime
try await Task.sleep(for: .seconds(paused ? 2 : 6))
// 忽略阶段切换边界上的第一条,断言实际发布的后续快照,而非只检查需求对象。
let steady = Array(samples.dropFirst())
if paused { #expect(steady.isEmpty) }
else { #expect(steady.count >= 1) }
#expect(steady.allSatisfy { $0.power == nil && $0.diskActivity == nil && $0.diskHealth == nil })
if visible {
#expect(steady.contains { $0.processes != nil })
#expect(steady.contains { $0.sensors != nil })
} else {
#expect(steady.allSatisfy { $0.gpu == nil && $0.disk == nil && $0.processes == nil && $0.sensors == nil })
#expect(!panel.isVisible)
// NSPanel 初始化会提供一个空 NSView;只在真正打开再关闭后要求树已释放。
if name == "closed" { #expect(panel.contentView == nil) }
}
report.append(Phase(name: name, elapsedSeconds: ProcessInfo.processInfo.systemUptime - started,
cpuSeconds: Self.cpuTime() - cpu, snapshots: samples.count,
gpuSamples: samples.filter { $0.gpu != nil }.count,
diskSamples: samples.filter { $0.disk != nil }.count,
processSamples: samples.filter { $0.processes != nil }.count,
sensorSamples: samples.filter { $0.sensors != nil }.count))
}
await model.hub.stop()
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try encoder.encode(report).write(to: URL(fileURLWithPath: output), options: .atomic)
}
private static func cpuTime() -> Double {
var usage = rusage()
guard getrusage(RUSAGE_SELF, &usage) == 0 else { return 0 }
return Double(usage.ru_utime.tv_sec + usage.ru_stime.tv_sec)
+ Double(usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1_000_000
}
}