feat(menubar): 新增单图标模式并优化状态总览
This commit is contained in:
1 parent
54ae9f8c88
commit
cbd44993ec
29 files changed
+722
-94
No files matched your search
+13
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ
|
||||
各核心负载 コア別負荷
|
||||
各项指标正常 すべて正常です
|
||||
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 蓋を閉じてもMacはスリープせず、ダウンロード、レンダリング、リモート接続が続きます。
|
||||
合并为一个 1つにまとめる
|
||||
合并显示 1つにまとめる
|
||||
合盖后继续运行 蓋を閉じても実行を続ける
|
||||
合盖电量下限 蓋閉じモードの最低バッテリー残量
|
||||
合盖睡眠 蓋を閉じるとスリープ
|
||||
@@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 使
|
||||
重置卡:{} リセット権:{}
|
||||
套餐剩余时间 プランの残り時間
|
||||
Token 用量 トークン
|
||||
仅图标 アイコンのみ
|
||||
只显示 XStats 图标,点击查看已选项目的状态总览 XStatsのアイコンのみを表示し、クリックすると選択した項目の概要を表示します
|
||||
面板项目 パネルの項目
|
||||
所选项目显示在弹出面板中,原有图标风格会保留。 選択した項目はポップオーバーに表示されます。アイコンのスタイルは保持されます。
|
||||
启动中… 起動中…
|
||||
正在启动风扇… ファンを起動中…
|
||||
打开进程监控 プロセスモニターを開く
|
||||
IP 归属地 IPの所在地
|
||||
|
@@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키
|
||||
各核心负载 코어별 부하
|
||||
各项指标正常 모든 지표 정상
|
||||
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 덮개를 닫아도 Mac이 잠자기에 들어가지 않아 다운로드, 렌더링, 원격 연결이 계속됩니다.
|
||||
合并为一个 하나로 합치기
|
||||
合并显示 하나로 합치기
|
||||
合盖后继续运行 덮개를 닫아도 계속 실행
|
||||
合盖电量下限 덮개 모드 최소 배터리
|
||||
合盖睡眠 덮개를 닫으면 잠자기
|
||||
@@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 사
|
||||
重置卡:{} 초기화 크레딧: {}
|
||||
套餐剩余时间 요금제 남은 시간
|
||||
Token 用量 토큰
|
||||
仅图标 아이콘만
|
||||
只显示 XStats 图标,点击查看已选项目的状态总览 XStats 아이콘만 표시합니다. 클릭하면 선택한 항목의 상태를 볼 수 있습니다
|
||||
面板项目 패널 항목
|
||||
所选项目显示在弹出面板中,原有图标风格会保留。 선택한 항목은 팝오버에 표시됩니다. 기존 아이콘 스타일은 유지됩니다.
|
||||
启动中… 시작 중…
|
||||
正在启动风扇… 팬 시작 중…
|
||||
打开进程监控 프로세스 모니터 열기
|
||||
IP 归属地 IP 위치
|
||||
|
@@ -436,7 +436,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python
|
||||
各核心负载 各核心負載
|
||||
各项指标正常 各項指標正常
|
||||
合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 合上屏幕時 Mac 不進入睡眠,下載、渲染、遠程連接不中斷。
|
||||
合并为一个 合併為一個
|
||||
合并显示 合併為一個
|
||||
合盖后继续运行 合蓋後繼續運行
|
||||
合盖电量下限 合蓋電量下限
|
||||
合盖睡眠 合蓋睡眠
|
||||
@@ -1650,3 +1650,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 用
|
||||
重置卡:{} 重設卡:{}
|
||||
套餐剩余时间 方案剩餘時間
|
||||
Token 用量 Token 用量
|
||||
仅图标 僅圖示
|
||||
只显示 XStats 图标,点击查看已选项目的状态总览 只顯示 XStats 圖示,點擊查看所選項目的狀態總覽
|
||||
面板项目 面板項目
|
||||
所选项目显示在弹出面板中,原有图标风格会保留。 所選項目顯示在彈出面板中,原有圖示風格會保留。
|
||||
启动中… 啟動中…
|
||||
正在启动风扇… 正在啟動風扇…
|
||||
打开进程监控 開啟程序監控
|
||||
IP 归属地 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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user