diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9c09ecc..adaa530 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2ee24b2..e3c5cf8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 版本摘要与活跃度图。 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 46a30c7..18b1a93 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P 各核心负载 حمل النوى 各项指标正常 كل المؤشرات طبيعية 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 يبقى Mac مستيقظًا عند إغلاق الغطاء، فتستمر التنزيلات والتصيير والجلسات البعيدة. -合并为一个 مدمج +合并显示 مدمج 合盖后继续运行 مواصلة التشغيل مع الغطاء المغلق 合盖电量下限 حد البطارية لوضع الغطاء المغلق 合盖睡眠 إسبات الغطاء @@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 است 重置卡:{} رصيد إعادة الضبط: {} 套餐剩余时间 الوقت المتبقي للخطة Token 用量 الرموز +仅图标 الأيقونة فقط +只显示 XStats 图标,点击查看已选项目的状态总览 عرض أيقونة XStats فقط؛ انقر لعرض ملخص المؤشرات المحددة +面板项目 مؤشرات اللوحة +所选项目显示在弹出面板中,原有图标风格会保留。 تظهر المؤشرات المحددة في اللوحة المنبثقة. تُحفظ أنماط الأيقونات الحالية. +启动中… جارٍ التشغيل… +正在启动风扇… جارٍ تشغيل المراوح… +打开进程监控 فتح مراقب العمليات +IP 归属地 موقع عنوان IP diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index d3e8b11..6e41682 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index ca6c183..c99873c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 35017ee..6abb359 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -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 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index bbc6aba..207660c 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ 各核心负载 コア別負荷 各项指标正常 すべて正常です 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 蓋を閉じてもMacはスリープせず、ダウンロード、レンダリング、リモート接続が続きます。 -合并为一个 1つにまとめる +合并显示 1つにまとめる 合盖后继续运行 蓋を閉じても実行を続ける 合盖电量下限 蓋閉じモードの最低バッテリー残量 合盖睡眠 蓋を閉じるとスリープ @@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 使 重置卡:{} リセット権:{} 套餐剩余时间 プランの残り時間 Token 用量 トークン +仅图标 アイコンのみ +只显示 XStats 图标,点击查看已选项目的状态总览 XStatsのアイコンのみを表示し、クリックすると選択した項目の概要を表示します +面板项目 パネルの項目 +所选项目显示在弹出面板中,原有图标风格会保留。 選択した項目はポップオーバーに表示されます。アイコンのスタイルは保持されます。 +启动中… 起動中… +正在启动风扇… ファンを起動中… +打开进程监控 プロセスモニターを開く +IP 归属地 IPの所在地 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 762a4b7..d10ef96 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -435,7 +435,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키 各核心负载 코어별 부하 各项指标正常 모든 지표 정상 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 덮개를 닫아도 Mac이 잠자기에 들어가지 않아 다운로드, 렌더링, 원격 연결이 계속됩니다. -合并为一个 하나로 합치기 +合并显示 하나로 합치기 合盖后继续运行 덮개를 닫아도 계속 실행 合盖电量下限 덮개 모드 최소 배터리 合盖睡眠 덮개를 닫으면 잠자기 @@ -1649,3 +1649,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 사 重置卡:{} 초기화 크레딧: {} 套餐剩余时间 요금제 남은 시간 Token 用量 토큰 +仅图标 아이콘만 +只显示 XStats 图标,点击查看已选项目的状态总览 XStats 아이콘만 표시합니다. 클릭하면 선택한 항목의 상태를 볼 수 있습니다 +面板项目 패널 항목 +所选项目显示在弹出面板中,原有图标风格会保留。 선택한 항목은 팝오버에 표시됩니다. 기존 아이콘 스타일은 유지됩니다. +启动中… 시작 중… +正在启动风扇… 팬 시작 중… +打开进程监控 프로세스 모니터 열기 +IP 归属地 IP 위치 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index d7e88ee..a8c187f 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -436,7 +436,7 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python 各核心负载 各核心負載 各项指标正常 各項指標正常 合上屏幕时 Mac 不进入睡眠,下载、渲染、远程连接不中断。 合上屏幕時 Mac 不進入睡眠,下載、渲染、遠程連接不中斷。 -合并为一个 合併為一個 +合并显示 合併為一個 合盖后继续运行 合蓋後繼續運行 合盖电量下限 合蓋電量下限 合盖睡眠 合蓋睡眠 @@ -1650,3 +1650,11 @@ AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 用 重置卡:{} 重設卡:{} 套餐剩余时间 方案剩餘時間 Token 用量 Token 用量 +仅图标 僅圖示 +只显示 XStats 图标,点击查看已选项目的状态总览 只顯示 XStats 圖示,點擊查看所選項目的狀態總覽 +面板项目 面板項目 +所选项目显示在弹出面板中,原有图标风格会保留。 所選項目顯示在彈出面板中,原有圖示風格會保留。 +启动中… 啟動中… +正在启动风扇… 正在啟動風扇… +打开进程监控 開啟程序監控 +IP 归属地 IP 歸屬地 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index 08ea319..2d6405c 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -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 """# } diff --git a/Packages/XStatsKit/Sources/Metrics/MetricsHub.swift b/Packages/XStatsKit/Sources/Metrics/MetricsHub.swift index 59cd04d..395e9c8 100644 --- a/Packages/XStatsKit/Sources/Metrics/MetricsHub.swift +++ b/Packages/XStatsKit/Sources/Metrics/MetricsHub.swift @@ -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() } diff --git a/Packages/XStatsKit/Sources/SMC/FanControl.swift b/Packages/XStatsKit/Sources/SMC/FanControl.swift index dc349cb..00821b8 100644 --- a/Packages/XStatsKit/Sources/SMC/FanControl.swift +++ b/Packages/XStatsKit/Sources/SMC/FanControl.swift @@ -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.. [FanState] { - (0.. 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) diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index 471f965..25393a2 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -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) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift index adbece6..e7a02a9 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift index 680de9c..45dd6dc 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift b/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift index b3c2aba..6b7f9db 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift index a664623..f633043 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift @@ -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)) diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/ThermalPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/ThermalPage.swift index a21354c..cd64808 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/ThermalPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/ThermalPage.swift @@ -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) diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift index 2beb739..6bf49fe 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift @@ -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) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift index cbb97b6..9a49810 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift @@ -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 控制中") } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift index 3d0fcf4..a47330f 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift @@ -23,6 +23,7 @@ struct PopoverRootView: View { /// 菜单栏弹窗的外框:顶部标题栏固定,下面的内容可滚动 struct PopoverFrame: 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: 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)) diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index 5bf0d0b..e833275 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -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 查询。")) { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift index 7bfdf5d..b08d0dd 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift @@ -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 } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index 8139d92..561404c 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -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() 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 } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index 9d8c87b..bb0f78e 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -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 图标,点击查看已选项目的状态总览") } } } diff --git a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift index c1ae321..cbc6c7d 100644 --- a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift +++ b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift @@ -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", diff --git a/Packages/XStatsKit/Tests/MetricsTests/FanStartupTests.swift b/Packages/XStatsKit/Tests/MetricsTests/FanStartupTests.swift new file mode 100644 index 0000000..9beeb4a --- /dev/null +++ b/Packages/XStatsKit/Tests/MetricsTests/FanStartupTests.swift @@ -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) + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/MenuBarLayoutTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/MenuBarLayoutTests.swift new file mode 100644 index 0000000..61803cd --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/MenuBarLayoutTests.swift @@ -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) + } + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/MenuBarResourceTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/MenuBarResourceTests.swift new file mode 100644 index 0000000..8d6f466 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/MenuBarResourceTests.swift @@ -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 + } +}