From f9f029b0e343328f8779092645db002652a87e4a Mon Sep 17 00:00:00 2001 From: ysicing Date: Tue, 29 Sep 2026 17:49:52 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=89=E7=A1=AC=E4=BB=B6=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=98=BE=E7=A4=BA=E9=A3=8E=E6=89=87=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEVELOPMENT.md | 2 + .../XStatsKit/Sources/Metrics/Models.swift | 5 +- .../Sources/Metrics/SensorSampler.swift | 9 ++- .../XStatsUI/AppKit/MenuBarController.swift | 4 +- .../XStatsUI/AppKit/MenuBarRenderer.swift | 2 +- .../XStatsUI/Panel/MainWindowView.swift | 9 ++- .../Sources/XStatsUI/Panel/OverviewPage.swift | 14 +++-- .../Sources/XStatsUI/Panel/ThermalPage.swift | 4 +- .../XStatsUI/Popover/CombinedPopover.swift | 2 +- .../XStatsUI/Popover/MetricPopovers.swift | 12 ++-- .../XStatsUI/Settings/SettingsPages.swift | 4 +- .../Sources/XStatsUI/State/AppModel.swift | 15 ++++- .../Sources/XStatsUI/State/MetricsStore.swift | 6 +- .../XStatsUITests/FanAvailabilityTests.swift | 58 +++++++++++++++++++ 14 files changed, 119 insertions(+), 27 deletions(-) create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/FanAvailabilityTests.swift diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6cfd8d1..1c09558 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -190,6 +190,8 @@ git push - `Packages/XStatsKit/Sources/XStatsUI/Rest`:本机休息计时、幕布、Mini HUD 与合成声音。 - `Packages/XStatsKit/Tests`:Swift 单元测试。 +硬件能力:风扇相关入口仅在成功读取到大于 0 的风扇数量后显示;尚未采样、数量未知或明确为 0 时隐藏。未知不等同于无风扇,仍沿既有需求采样重试;已确认的数量保留至本次运行结束,短暂读取失败与转速为 0 不导致入口消失。能力过滤不改写用户保存的展示偏好。 + 性能约束:本机 AI 用量扫描只在文件集合、文件元数据、统计日期或时区变化时重新聚合;进程内仅缓存上一轮元数据与汇总,解析检查点仍保存在 SQLite。扫描失败不复用该轮结果。清理扫描及工具清理后的大小核验必须在目录枚举中响应取消,不能发布部分计量结果。指标弹窗在同一布局轮次合并当前标题栏和滚动内容的绝对高度,下一轮更新窗口;不能同步累加内容与视口差值,也不能用新建视图的默认交互状态覆盖实际高度。定向回归见 `LocalUsageScanCacheTests`、`ScanCancellationTests` 和 `StatusPanelLayoutTests`。 更深入的模块边界、采样策略和安全约束见 [ARCHITECTURE.md](ARCHITECTURE.md)。 diff --git a/Packages/XStatsKit/Sources/Metrics/Models.swift b/Packages/XStatsKit/Sources/Metrics/Models.swift index 38d9306..2861350 100644 --- a/Packages/XStatsKit/Sources/Metrics/Models.swift +++ b/Packages/XStatsKit/Sources/Metrics/Models.swift @@ -173,10 +173,13 @@ public struct TemperatureSummary: Sendable, Equatable, Identifiable { public struct SensorReadings: Sendable, Equatable { public var temperatures: [TemperatureSummary] public var fans: [FanState] + /// nil 表示数量读取失败或未知,0 才表示确认无风扇。 + public var fanCount: Int? - public init(temperatures: [TemperatureSummary], fans: [FanState]) { + public init(temperatures: [TemperatureSummary], fans: [FanState], fanCount: Int? = nil) { self.temperatures = temperatures self.fans = fans + self.fanCount = fanCount } public func temperature(_ group: TemperatureGroup) -> TemperatureSummary? { diff --git a/Packages/XStatsKit/Sources/Metrics/SensorSampler.swift b/Packages/XStatsKit/Sources/Metrics/SensorSampler.swift index 89b09a2..41bdaad 100644 --- a/Packages/XStatsKit/Sources/Metrics/SensorSampler.swift +++ b/Packages/XStatsKit/Sources/Metrics/SensorSampler.swift @@ -5,6 +5,7 @@ import SMC public final class SensorSampler { private let smc: SMCConnection? private let fans: FanControl? + private var detectedFanCount: Int? private lazy var catalog: [TemperatureGroup: [SMCKey]] = smc.map(TemperatureCatalog.discover) ?? [:] public init() { @@ -14,6 +15,11 @@ public final class SensorSampler { public func sample(groups: Set = Set(TemperatureGroup.allCases), includeFans: Bool = true) -> SensorReadings? { guard let smc else { return nil } + // 硬件数量成功读到后复用;未知时随既有采样重试,不新增轮询。 + if detectedFanCount == nil, let raw = smc.double(SMCKey("FNum")), + let count = Int(exactly: raw), count >= 0 { + detectedFanCount = count + } var summaries: [TemperatureSummary] = [] for group in TemperatureGroup.allCases where groups.contains(group) { let values = (catalog[group] ?? []).compactMap(smc.double).filter(TemperatureCatalog.plausibleRange.contains) @@ -23,6 +29,7 @@ public final class SensorSampler { maximum: values.max() ?? 0, sensorCount: values.count)) } - return SensorReadings(temperatures: summaries, fans: includeFans ? (fans?.read() ?? []) : []) + return SensorReadings(temperatures: summaries, fans: includeFans && detectedFanCount != 0 ? (fans?.read() ?? []) : [], + fanCount: detectedFanCount) } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift index 54093cb..7eea804 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarController.swift @@ -26,7 +26,7 @@ final class MenuBarController: NSObject { /// 布局或显示项目变化时重建;否则只重绘图片 func update() { let settings = model.settings - let enabled = settings.orderedMenuBarItems + let enabled = model.visibleMenuBarItems // 没有任何项目时保留一个图标,确保仍能打开面板与主窗口 let signature: [MenuBarItem?] = settings.menuBarLayout == .separate && !enabled.isEmpty ? enabled : [nil] if signature != layoutSignature { @@ -76,7 +76,7 @@ final class MenuBarController: NSObject { let settings = model.settings let reading = MenuBarReading(model: model) for (index, entry) in items.enumerated() { - let itemsToDraw = entry.key.map { [$0] } ?? settings.orderedMenuBarItems + let itemsToDraw = entry.key.map { [$0] } ?? model.visibleMenuBarItems var entryReading = reading // 防休眠标记只画在最左侧的图标里 entryReading.keepAwake = reading.keepAwake && index == 0 diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift index 867565d..d130c10 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift @@ -220,7 +220,7 @@ enum MenuBarRenderer { static func image(for model: AppModel) -> NSImage { let settings = model.settings return image(reading: MenuBarReading(model: model), - items: settings.orderedMenuBarItems, + items: model.visibleMenuBarItems, style: { settings.style(for: $0) }, networkStyle: settings.networkStyle, colorizeHighLoad: settings.colorizeHighLoad, diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift index dc1d595..8d99d5e 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift @@ -235,7 +235,10 @@ private struct MainSidebar: View { .padding(.horizontal, DS.Space.s2) .padding(.top, DS.Space.s2) ForEach(tabs) { tab in - SidebarButton(title: tab.title, symbol: tab.symbol, isSelected: model.settings.panelTab == tab, badge: badge(for: tab)) { + let temperatureOnly = tab == .thermal && !model.store.supportsFans + SidebarButton(title: temperatureOnly ? tr("温度") : tab.title, + symbol: temperatureOnly ? "thermometer.medium" : tab.symbol, + isSelected: model.settings.panelTab == tab, badge: badge(for: tab)) { model.settings.panelTab = tab } } @@ -312,7 +315,7 @@ private struct PageHeader: View { HStack(spacing: DS.Space.s3) { // 拖动窗口的区域只覆盖标题和空白,按钮与开关不在拖动层上 HStack(spacing: 0) { - Text(tab.headerTitle) + Text(tab == .thermal && !model.store.supportsFans ? tr("温度") : tab.headerTitle) .dsFont(.base, weight: .semibold) .foregroundStyle(DS.Palette.textPrimary) Spacer(minLength: DS.Space.s3) @@ -369,7 +372,7 @@ private struct PageHeader: View { .help(tr("检查 VPN 与代理是否生效、各网站从哪个出口出去")) } // 每个监控页都能在这里开关自己的菜单栏项目;温度与风扇页有两项,各带一个小标签 - let items = tab.menuBarItems + let items = tab.menuBarItems.filter { model.availableMenuBarItems.contains($0) } if !items.isEmpty { // macOS 26 上这一组是一颗玻璃胶囊,与访达工具栏的分组一致 HStack(spacing: DS.Space.s3) { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift index 0b95a64..1e40805 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/OverviewPage.swift @@ -18,7 +18,7 @@ struct OverviewPage: View { WeightedRow { DiskTile() NetworkTile() - FanTile() + if model.store.supportsFans { FanTile() } } // 窄卡片与上方单列同宽:宽卡占两列 WeightedRow(weights: model.store.battery != nil ? [2, 1] : [1]) { @@ -521,11 +521,13 @@ private struct QuickActionsCard: View { detail: keepAwake.lidClosedActive ? tr("继续运行") : tr("合盖睡眠"), isOn: Binding(get: { keepAwake.lidClosedRequested }, set: { model.requestLidMode($0) })) - HairlineDivider() - QuickToggleRow(icon: "fan", title: tr("散热模式"), - detail: fans.mode == .automatic ? tr("系统调节") : tr("\(fans.mode.title)中"), - isOn: Binding(get: { fans.mode != .automatic }, - set: { model.requestFanMode($0 ? .cooling : .automatic) })) + if model.store.supportsFans { + HairlineDivider() + QuickToggleRow(icon: "fan", title: tr("散热模式"), + detail: fans.mode == .automatic ? tr("系统调节") : tr("\(fans.mode.title)中"), + isOn: Binding(get: { fans.mode != .automatic }, + set: { model.requestFanMode($0 ? .cooling : .automatic) })) + } } } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/ThermalPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/ThermalPage.swift index 716b682..a21354c 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/ThermalPage.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/ThermalPage.swift @@ -10,11 +10,11 @@ struct ThermalPage: View { PageScroll { HStack(alignment: .top, spacing: DS.Space.s3) { TemperatureCard().frame(maxWidth: .infinity) - FanCard().frame(maxWidth: .infinity) + if model.store.supportsFans { FanCard().frame(maxWidth: .infinity) } } .fixedSize(horizontal: false, vertical: true) PowerCard() - FanSafetySettings() + if model.store.supportsFans { FanSafetySettings() } } } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift index 807de73..3b5874d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift @@ -9,7 +9,7 @@ struct CombinedPopoverView: View { @Environment(AppModel.self) private var model var body: some View { - let items = model.settings.orderedMenuBarItems + let items = model.visibleMenuBarItems // 标签对应的项目在设置里被关掉后回到总览 let tab = model.combinedPopoverTab.flatMap { items.contains($0) ? $0 : nil } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift index 5609c13..2534961 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/MetricPopovers.swift @@ -717,15 +717,17 @@ struct ThermalPopover: View { HeroValue(value: cpu.map { Format.temperature($0.maximum, fahrenheit: settings.useFahrenheit) } ?? "—") } .frame(maxWidth: .infinity, alignment: .leading) - 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") + 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") + } + .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) } } - ForEach(item.popoverSections.filter { isDetailPage || settings.isVisible($0) }) { section in + ForEach(item.popoverSections.filter { ($0 != .thermalFans || model.store.supportsFans) && (isDetailPage || settings.isVisible($0)) }) { section in switch section { case .thermalSensors: SectionCard(title: section.title, trailing: { Text(tr("最高 / 平均")) }) { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index 7c18314..b0bf4f7 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -361,7 +361,7 @@ struct MenuBarSettings: View { InfoBanner(icon: "hand.draw", text: tr("菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。")) } - ForEach(Array(MenuBarItem.allCases.enumerated()), id: \.element) { index, item in + ForEach(Array(model.availableMenuBarItems.enumerated()), id: \.element) { index, item in let moduleEnabled = item != .aiUsage || settings.aiUsageEnabled GroupRow { VStack(alignment: .leading, spacing: DS.Space.s3) { @@ -475,7 +475,7 @@ private struct PopoverSectionPicker: View { .foregroundStyle(DS.Palette.textSecondary) .fixedSize() FlowLayout(spacing: DS.Space.s1) { - ForEach(item.popoverSections) { section in + ForEach(item.popoverSections.filter { $0 != .thermalFans || model.store.supportsFans }) { section in let visible = settings.isVisible(section) SectionToggleChip(title: section.title, isOn: visible) { settings.setVisible(section, !visible) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index 9a185d5..16452af 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -99,6 +99,15 @@ public final class AppModel { speedTest = SpeedTestController(settings: settings) } + /// 硬件能力只影响实际展示,不改写保存的菜单栏偏好。 + var availableMenuBarItems: [MenuBarItem] { + MenuBarItem.allCases.filter { $0 != .fan || store.supportsFans } + } + + var visibleMenuBarItems: [MenuBarItem] { + settings.orderedMenuBarItems.filter { $0 != .fan || store.supportsFans } + } + /// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项 var demand: MetricsDemand { var demand = MetricsDemand() @@ -136,8 +145,9 @@ public final class AppModel { demand.temperatures = groups demand.power = (window && tab == .thermal) || thermalPopover || showing(.battery, .battery) demand.cpuFrequency = showing(.cpu, .cpu) - demand.fans = (window && (tab == .thermal || tab == .overview)) || menu.contains(.fan) - || fans.mode != .automatic || thermalPopover + // 未知时隐藏入口,但保留按需探测;不能用界面可见性阻断首次识别或失败重试。 + demand.fans = store.fanCount != 0 && ((window && (tab == .thermal || tab == .overview)) || menu.contains(.fan) + || fans.mode != .automatic || thermalPopover) return demand } @@ -174,6 +184,7 @@ public final class AppModel { /// 快捷切换风扇:未安装辅助工具时打开散热页引导安装 func requestFanMode(_ mode: FanController.Mode) { + guard store.supportsFans else { return } guard helper.isReady else { settings.panelTab = .thermal if !isMainWindowVisible { openMainWindow(.thermal) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift b/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift index aea1a94..d998bdd 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift @@ -52,6 +52,9 @@ public final class MetricsStore { private(set) var diskRanking = ActivityRanking() public private(set) var systemCounts: SystemCounts? public private(set) var sensors: SensorReadings? + /// 独立观察硬件能力,避免每次传感器采样都触发菜单栏布局重建。 + public private(set) var fanCount: Int? + var supportsFans: Bool { (fanCount ?? 0) > 0 } public private(set) var power: PowerReading? public private(set) var powerHistory = History(capacity: historyCapacity) public private(set) var diskActivity: DiskActivity? @@ -117,6 +120,7 @@ public final class MetricsStore { /// 不同页面采集的温度分组不同,按组合并,避免切页时数据闪烁 private func mergeSensors(_ new: SensorReadings) { + if let count = new.fanCount, count != fanCount { fanCount = count } guard let old = sensors else { sensors = new return @@ -124,7 +128,7 @@ public final class MetricsStore { var byGroup = Dictionary(uniqueKeysWithValues: old.temperatures.map { ($0.group, $0) }) for summary in new.temperatures { byGroup[summary.group] = summary } let ordered = TemperatureGroup.allCases.compactMap { byGroup[$0] } - sensors = SensorReadings(temperatures: ordered, fans: new.fans.isEmpty ? old.fans : new.fans) + sensors = SensorReadings(temperatures: ordered, fans: fanCount == 0 ? [] : (new.fans.isEmpty ? old.fans : new.fans), fanCount: fanCount) } var fastestFan: FanState? { diff --git a/Packages/XStatsKit/Tests/XStatsUITests/FanAvailabilityTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/FanAvailabilityTests.swift new file mode 100644 index 0000000..d840c24 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/FanAvailabilityTests.swift @@ -0,0 +1,58 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation +import Metrics +import SMC +import Testing +@testable import XStatsUI + +@MainActor +struct FanAvailabilityTests { + @Test func onlyConfirmedFansAppearAndPreferencesArePreserved() throws { + let name = "FanAvailabilityTests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let settings = AppSettings(defaults: defaults) + settings.menuBarItems = [.temperature, .fan] + settings.panelTab = .thermal + let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [], aiQuotaProviders: []) + model.isMainWindowVisible = true + #expect(!model.availableMenuBarItems.contains(.fan)) + #expect(!model.visibleMenuBarItems.contains(.fan)) + #expect(model.demand.fans) + var snapshot = MetricsSnapshot() + snapshot.sensors = SensorReadings(temperatures: [], fans: [], fanCount: 0) + model.store.apply(snapshot) + #expect(!model.store.supportsFans) + #expect(!model.availableMenuBarItems.contains(.fan)) + #expect(model.visibleMenuBarItems == [.temperature]) + #expect(!model.demand.fans) + #expect(settings.menuBarItems.contains(.fan)) + // 后续仅采温度或读取失败不会让入口来回闪烁。 + snapshot.sensors = SensorReadings(temperatures: [], fans: []) + model.store.apply(snapshot) + #expect(!model.store.supportsFans) + snapshot.sensors = SensorReadings(temperatures: [], fans: [], fanCount: 2) + model.store.apply(snapshot) + #expect(model.store.supportsFans) + #expect(model.visibleMenuBarItems.contains(.fan)) + #expect(model.demand.fans) + } + + @Test func unknownFansStayHiddenAndStoppedFansRemainVisible() { + let store = MetricsStore() + var snapshot = MetricsSnapshot() + snapshot.sensors = SensorReadings(temperatures: [], fans: []) + store.apply(snapshot) + #expect(!store.supportsFans) + let stopped = FanState(id: 0, current: 0, minimum: 0, maximum: 6000, target: 0, isManual: false) + snapshot.sensors = SensorReadings(temperatures: [], fans: [stopped], fanCount: 1) + store.apply(snapshot) + #expect(store.supportsFans) + #expect(store.fastestFan?.current == 0) + snapshot.sensors = SensorReadings(temperatures: [], fans: []) + store.apply(snapshot) + #expect(store.supportsFans) + } +}