From 8244dd15c19b1da6ed8d284631d732b24cb14248 Mon Sep 17 00:00:00 2001 From: ysicing Date: Sun, 4 Oct 2026 21:38:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=87=AA=E5=B8=A6=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E5=BC=B9=E7=AA=97=E4=B8=8D=E6=8F=90=E9=AB=98=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E9=A2=91=E7=8E=87=EF=BC=8C=E8=BF=87=E6=9C=9F=E9=A3=8E?= =?UTF-8?q?=E6=89=87=E6=95=B0=E6=8D=AE=E4=B8=8D=E6=98=BE=E7=A4=BA=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 用量、显示器弹窗及仅含这两项的合并面板沿用后台采样间隔 - 风扇读取失败沿用旧数据时清除 isStarting,避免“启动中”超出 10 秒窗口 Co-Authored-By: Claude --- ARCHITECTURE.md | 6 +- .../Sources/XStatsUI/State/AppModel.swift | 9 ++- .../Sources/XStatsUI/State/MetricsStore.swift | 8 ++- .../XStatsUITests/FanAvailabilityTests.swift | 16 +++++ .../XStatsUITests/MenuBarLayoutTests.swift | 68 +++++++++++++++++++ .../Tests/XStatsUITests/XStatsUITests.swift | 14 ++++ 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3d34c27..59bcb8e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -167,7 +167,11 @@ its own `NSStatusItem` (created in reverse so they read left to right) and opens popover for that metric; in the *combined* layout a single item opens `CombinedPopoverView`: a status overview of compact metric rows. Clicking a row expands its existing `PopoverDetail` inline; only one row expands at a time. Summary rows remain visible, so their sampling demand is retained -alongside the expanded detail demand. The header keeps the main-window and settings +alongside the expanded detail demand. Only visible system-metric rows raise the overview's +sampling rate; an empty overview or one containing only AI usage and displays retains the +configured background interval unless a live window or system-metric detail requires more. +Standalone AI usage and display popovers likewise keep the background interval. +The header keeps the main-window and settings actions outside the scroll area; no bottom action bar is shown. Header and content contribute to the same measured panel height. Collapsing or switching a row releases its detail-only demand without rebuilding the panel. No timer or continuous animation is added. diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index ace7d6c..975f763 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -146,9 +146,14 @@ public final class AppModel { // 仅实时监控页需要每秒采样;历史、工具和设置页沿用用户设置的后台间隔。 let liveWindow = window && [.overview, .system, .cpu, .gpu, .memory, .disk, .network, .thermal, .battery].contains(tab) + // AI 用量与显示器有各自的刷新机制,不读取采样结果,不应提高全局采样频率。 + let selfRefreshing: Set = [.aiUsage, .display] + let livePopover = popover.map { !selfRefreshing.contains($0) } ?? false + // 合并面板的摘要始终可见;只有其中的系统指标需要提高全局采样频率。 + let liveOverview = !overview.subtracting(selfRefreshing).isEmpty // 进程页要读全系统进程(启动 ps),每 2 秒刷新一次足够,也更省电 - demand.interval = processPage && popover == nil ? .seconds(2) - : liveWindow || popover != nil || isCombinedPopoverOpen ? .seconds(1) : .seconds(settings.refreshSeconds) + demand.interval = processPage && !livePopover ? .seconds(2) + : liveWindow || livePopover || liveOverview ? .seconds(1) : .seconds(settings.refreshSeconds) demand.memory = true demand.network = true let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item } diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift b/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift index 89e94c7..9e2393d 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/MetricsStore.swift @@ -136,7 +136,13 @@ 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: fanCount == 0 ? [] : (new.fans.isEmpty ? old.fans : new.fans), fanCount: fanCount) + // 读取失败时沿用的旧风扇数据不再推进起步窗口,去掉“启动中”以免提示超出时限。 + let fans = new.fans.isEmpty ? old.fans.map { fan in + var fan = fan + fan.isStarting = false + return fan + } : new.fans + sensors = SensorReadings(temperatures: ordered, fans: fanCount == 0 ? [] : fans, fanCount: fanCount) } var fastestFan: FanState? { diff --git a/Packages/XStatsKit/Tests/XStatsUITests/FanAvailabilityTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/FanAvailabilityTests.swift index d840c24..78a4e4c 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/FanAvailabilityTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/FanAvailabilityTests.swift @@ -55,4 +55,20 @@ struct FanAvailabilityTests { store.apply(snapshot) #expect(store.supportsFans) } + + @Test func staleFansReusedAfterReadFailureDropStartingState() { + let store = MetricsStore() + var snapshot = MetricsSnapshot() + let starting = FanState(id: 0, current: 0, minimum: 0, maximum: 6000, target: 2000, isManual: true, + isStarting: true) + snapshot.sensors = SensorReadings(temperatures: [], fans: [starting], fanCount: 1) + store.apply(snapshot) + #expect(store.sensors?.fans.isStartingUp == true) + // 读取失败返回空数组:沿用旧读数,但不再显示“启动中”。 + snapshot.sensors = SensorReadings(temperatures: [], fans: []) + store.apply(snapshot) + #expect(store.sensors?.fans.count == 1) + #expect(store.sensors?.fans.first?.target == 2000) + #expect(store.sensors?.fans.isStartingUp == false) + } } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/MenuBarLayoutTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/MenuBarLayoutTests.swift index 65fbaf7..ca0bfc4 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/MenuBarLayoutTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/MenuBarLayoutTests.swift @@ -84,6 +84,74 @@ struct MenuBarLayoutTests { } } + @Test(arguments: [MenuBarLayout.combined, .iconOnly]) + func independentRefreshItemsPreserveBackgroundSampling(layout: MenuBarLayout) throws { + try model { model, _ in + model.settings.menuBarLayout = layout + model.settings.refreshSeconds = 5 + model.settings.aiUsageEnabled = true + let selections: [Set] = [[], [.display], [.aiUsage], [.display, .aiUsage]] + for items in selections { + model.settings.menuBarItems = items + let idle = model.demand + #expect(idle.interval == .seconds(5)) + model.isCombinedPopoverOpen = true + #expect(model.demand == idle) + for item in items { + model.combinedPopoverTab = item + #expect(model.demand == idle) + if item == .display { #expect(model.displayControlsVisible) } + } + model.combinedPopoverTab = nil + #expect(model.demand == idle) + model.isCombinedPopoverOpen = false + #expect(model.demand == idle) + #expect(!model.displayControlsVisible) + } + } + } + + @Test(arguments: [MenuBarLayout.combined, .iconOnly]) + func systemMetricsStillRefreshWhileDisplayDetailsAreExpanded(layout: MenuBarLayout) throws { + try model { model, _ in + model.settings.menuBarLayout = layout + model.settings.refreshSeconds = 5 + var snapshot = MetricsSnapshot() + snapshot.sensors = SensorReadings(temperatures: [], fans: [], fanCount: 1) + model.store.apply(snapshot) + for item: MenuBarItem in [.cpu, .memory, .network, .gpu, .disk, .temperature, .fan, .battery] { + model.settings.menuBarItems = [.display, item] + let idle = model.demand + model.isCombinedPopoverOpen = true + #expect(model.demand.interval == .seconds(1)) + model.combinedPopoverTab = .display + #expect(model.demand.interval == .seconds(1)) + #expect(model.displayControlsVisible) + model.isCombinedPopoverOpen = false + model.combinedPopoverTab = nil + #expect(model.demand == idle) + } + } + } + + @Test func mainWindowDemandStillOverridesIndependentPanelRefresh() throws { + try model { model, _ in + model.settings.menuBarLayout = .iconOnly + model.settings.menuBarItems = [.display] + model.settings.refreshSeconds = 5 + model.isCombinedPopoverOpen = true + model.combinedPopoverTab = .display + model.isMainWindowVisible = true + model.settings.panelTab = .cpu + #expect(model.demand.interval == .seconds(1)) + model.settings.panelTab = .settingsGeneral + #expect(model.demand.interval == .seconds(5)) + model.settings.processesEnabled = true + model.settings.panelTab = .processes + #expect(model.demand.interval == .seconds(2)) + } + } + @Test func compactOverviewHasNoHalfEmptyRowsAndExpandsInline() throws { try model { model, _ in model.settings.menuBarItems = [.cpu, .memory, .network, .disk, .battery] diff --git a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift index 4b9ab2f..37c315b 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/XStatsUITests.swift @@ -522,6 +522,20 @@ private func isolatedDefaults() -> UserDefaults { #expect(model.demand.interval == .seconds(1)) } + @Test func popoversWithoutLiveMetricsKeepSamplingInterval() { + let model = model() + model.settings.refreshSeconds = 5 + for item: MenuBarItem in [.aiUsage, .display] { + model.openPopover = item + #expect(model.demand.interval == .seconds(5), "\(item) popover should not boost sampling") + } + + model.settings.processesEnabled = true + model.isMainWindowVisible = true + model.settings.panelTab = .processes + #expect(model.demand.interval == .seconds(2)) + } + @Test func networkDetailVisibility() { let model = model() #expect(!model.isNetworkDetailVisible)