fix: 按硬件能力显示风扇入口
This commit is contained in:
1 parent
1a222519e5
commit
f9f029b0e3
14 files changed
+119
-27
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user