fix: 按硬件能力显示风扇入口

This commit is contained in:
ysicing committed 2026-09-29 17:49:52 +08:00
1 parent 1a222519e5
commit f9f029b0e3
14 files changed
+119 -27

No files matched your search

+2
View File
@@ -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)。
@@ -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? {
@@ -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<TemperatureGroup> = 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)
}
}
@@ -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
@@ -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,
@@ -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) {
@@ -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) }))
}
}
}
}
@@ -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() }
}
}
}
@@ -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 }
@@ -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("最高 / 平均")) }) {
@@ -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)
@@ -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) }
@@ -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<Double>(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? {
@@ -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)
}
}