From 743f3a1a8834c8a673c688f601382192bc302461 Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:44:30 +0200 Subject: [PATCH] Fix fan fallback, missing readings, Settings routing, and disk cache recovery --- Core-Monitor.xcodeproj/project.pbxproj | 20 ++++++ Core-Monitor/AlertEngine.swift | 1 + Core-Monitor/DiskStatsRefreshPolicy.swift | 17 ++++++ Core-Monitor/FanController.swift | 13 +--- Core-Monitor/MenubarController.swift | 22 ++++--- Core-Monitor/SettingsWindow.swift | 29 +++++---- Core-Monitor/SystemMonitor.swift | 19 ++---- Core-MonitorTests/AlertEngineTests.swift | 51 ++++++++++++++++ .../DiskStatsRefreshPolicyTests.swift | 61 +++++++++++++++++++ Core-MonitorTests/FanReadingTests.swift | 53 ++++++++++++++++ Core-MonitorTests/SettingsWindowTests.swift | 42 +++++++++++++ README.md | 6 ++ Shared/SMCFanDetection.swift | 18 ++++++ scripts/tests/check_helper_lifetime.sh | 1 + smc-helper/main.swift | 11 +--- 15 files changed, 309 insertions(+), 55 deletions(-) create mode 100644 Core-MonitorTests/FanReadingTests.swift create mode 100644 Core-MonitorTests/SettingsWindowTests.swift create mode 100644 Shared/SMCFanDetection.swift diff --git a/Core-Monitor.xcodeproj/project.pbxproj b/Core-Monitor.xcodeproj/project.pbxproj index f0a51780..377e9b9f 100644 --- a/Core-Monitor.xcodeproj/project.pbxproj +++ b/Core-Monitor.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + A19400012026091900000002 /* FanReadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19400012026091900000001 /* FanReadingTests.swift */; }; + A19400022026091900000002 /* DiskStatsRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19400022026091900000001 /* DiskStatsRefreshPolicyTests.swift */; }; + A19400032026091900000002 /* SettingsWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19400032026091900000001 /* SettingsWindowTests.swift */; }; 12AF073D73CA92C2A2FBF449 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33D1932E6B3C54A956AF5F6C /* Cocoa.framework */; }; 23F2064CB38FDB73EE4AB471 /* AlertEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D6F3C76B69FEDB140682676 /* AlertEngineTests.swift */; }; 7381A6E82B87236C00C0DE01 /* CustomFanPresetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7381A6E72B87236C00C0DE01 /* CustomFanPresetTests.swift */; }; @@ -48,6 +51,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A19400012026091900000001 /* FanReadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanReadingTests.swift; sourceTree = ""; }; + A19400022026091900000001 /* DiskStatsRefreshPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskStatsRefreshPolicyTests.swift; sourceTree = ""; }; + A19400032026091900000001 /* SettingsWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowTests.swift; sourceTree = ""; }; 33D1932E6B3C54A956AF5F6C /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; 353B02D12F640A0700A65F2C /* Core-Monitor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Core-Monitor.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 35F039AB2F64444D004CB9F8 /* smc-helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; name = "smc-helper"; path = "ventaphobia.smc-helper"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -63,6 +69,11 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A19300012026091900000001 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; 353B02D32F640A0700A65F2C /* Core-Monitor */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Core-Monitor"; @@ -104,6 +115,9 @@ 1A2F97F19E4BA4C0B1070411 /* Core-MonitorTests */ = { isa = PBXGroup; children = ( + A19400012026091900000001 /* FanReadingTests.swift */, + A19400022026091900000001 /* DiskStatsRefreshPolicyTests.swift */, + A19400032026091900000001 /* SettingsWindowTests.swift */, 8D6F3C76B69FEDB140682676 /* AlertEngineTests.swift */, 7381A6E72B87236C00C0DE01 /* CustomFanPresetTests.swift */, DABD10012F96000100000001 /* CoreMonitorShareKitTests.swift */, @@ -119,6 +133,7 @@ 353B02C82F640A0700A65F2C = { isa = PBXGroup; children = ( + A19300012026091900000001 /* Shared */, 353B02D32F640A0700A65F2C /* Core-Monitor */, 35F039AC2F64444D004CB9F8 /* smc-helper */, 353B02D22F640A0700A65F2C /* Products */, @@ -172,6 +187,7 @@ ); fileSystemSynchronizedGroups = ( 353B02D32F640A0700A65F2C /* Core-Monitor */, + A19300012026091900000001 /* Shared */, ); name = "Core-Monitor"; productName = "Core-Monitor"; @@ -192,6 +208,7 @@ ); fileSystemSynchronizedGroups = ( 35F039AC2F64444D004CB9F8 /* smc-helper */, + A19300012026091900000001 /* Shared */, ); name = "smc-helper"; productName = "smc-helper"; @@ -314,6 +331,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A19400012026091900000002 /* FanReadingTests.swift in Sources */, + A19400022026091900000002 /* DiskStatsRefreshPolicyTests.swift in Sources */, + A19400032026091900000002 /* SettingsWindowTests.swift in Sources */, 23F2064CB38FDB73EE4AB471 /* AlertEngineTests.swift in Sources */, 7381A6E82B87236C00C0DE01 /* CustomFanPresetTests.swift in Sources */, DABD10022F96000100000002 /* CoreMonitorShareKitTests.swift in Sources */, diff --git a/Core-Monitor/AlertEngine.swift b/Core-Monitor/AlertEngine.swift index 3cd9c3b0..8bcd2901 100644 --- a/Core-Monitor/AlertEngine.swift +++ b/Core-Monitor/AlertEngine.swift @@ -352,6 +352,7 @@ enum AlertEvaluator { let lowFanIndex = zip(input.snapshot.fanSpeeds, input.snapshot.fanMinSpeeds) .enumerated() .first { _, pair in + guard pair.0 >= 0 else { return false } let floor = max(pair.1 + 150, 1_200) return pair.0 < floor }? diff --git a/Core-Monitor/DiskStatsRefreshPolicy.swift b/Core-Monitor/DiskStatsRefreshPolicy.swift index 638155c7..f00b867c 100644 --- a/Core-Monitor/DiskStatsRefreshPolicy.swift +++ b/Core-Monitor/DiskStatsRefreshPolicy.swift @@ -12,3 +12,20 @@ enum DiskStatsRefreshPolicy { return now.timeIntervalSince(lastUpdatedAt) >= minimumInterval } } + +/// Owned by the sampling queue. Failed reads preserve both the last good +/// reading and its timestamp, allowing another attempt on the next sample. +struct DiskStatsCache { + private(set) var stats = DiskStats() + private(set) var lastUpdatedAt: Date? + + mutating func read(now: Date, load: () throws -> DiskStats?) -> DiskStats { + guard DiskStatsRefreshPolicy.shouldRefresh(lastUpdatedAt: lastUpdatedAt, now: now), + let refreshed = try? load() else { + return stats + } + stats = refreshed + lastUpdatedAt = now + return stats + } +} diff --git a/Core-Monitor/FanController.swift b/Core-Monitor/FanController.swift index d3be397e..f69365c9 100644 --- a/Core-Monitor/FanController.swift +++ b/Core-Monitor/FanController.swift @@ -1013,18 +1013,9 @@ final class FanController: ObservableObject { return directCount } - for fanID in 0..<12 { - let actualKey = String(format: "F%dAc", fanID) - let minKey = String(format: "F%dMn", fanID) - let maxKey = String(format: "F%dMx", fanID) - if helperManager.readValue(key: actualKey) != nil || - helperManager.readValue(key: minKey) != nil || - helperManager.readValue(key: maxKey) != nil { - return fanID + 1 - } + return SMCFanDetection.fallbackCount { key in + helperManager.readValue(key: key) != nil } - - return 0 } private func helperUnavailableMessage() -> String { diff --git a/Core-Monitor/MenubarController.swift b/Core-Monitor/MenubarController.swift index 695c848d..d3c378ee 100644 --- a/Core-Monitor/MenubarController.swift +++ b/Core-Monitor/MenubarController.swift @@ -119,7 +119,7 @@ final class MenuBarController: NSObject { // MARK: - SingleMenuBarItemController @MainActor final class SingleMenuBarItemController: NSObject, NSPopoverDelegate { - private enum StatusTone: Equatable { + enum StatusTone: Equatable { case normal case warning case critical @@ -241,14 +241,7 @@ final class SingleMenuBarItemController: NSObject, NSPopoverDelegate { return ("\(pct)%", tone) case .fan: - let speeds = systemMonitor.fanSpeeds.filter { $0 > 0 } - guard let highestRPM = speeds.max() else { - return ("—", .secondary) - } - - let utilization = Double(highestRPM) / Double(max(fanController.maxSpeed, 1)) - let tone: StatusTone = utilization > 0.85 ? .critical : utilization > 0.6 ? .warning : .normal - return (ReadingFormat.rpmShort(highestRPM), tone) + return Self.fanStatusLabel(speeds: systemMonitor.fanSpeeds, maximumRPM: fanController.maxSpeed) case .memory: let pct = Int(systemMonitor.memoryUsagePercent.rounded()) @@ -282,6 +275,17 @@ final class SingleMenuBarItemController: NSObject, NSPopoverDelegate { } } + static func fanStatusLabel(speeds: [Int], maximumRPM: Int) -> (text: String, tone: StatusTone) { + // Zero is a valid reading. Only negative values mean the read failed. + guard let highestRPM = speeds.filter({ $0 >= 0 }).max() else { + return ("—", .secondary) + } + + let utilization = Double(highestRPM) / Double(max(maximumRPM, 1)) + let tone: StatusTone = utilization > 0.85 ? .critical : utilization > 0.6 ? .warning : .normal + return (ReadingFormat.rpmShort(highestRPM), tone) + } + private func statusBarIcon() -> NSImage? { let configuration = NSImage.SymbolConfiguration(pointSize: 13, weight: .regular) let image = NSImage(systemSymbolName: kind.systemImageName, accessibilityDescription: kind.title)? diff --git a/Core-Monitor/SettingsWindow.swift b/Core-Monitor/SettingsWindow.swift index 4333bc96..9b101df8 100644 --- a/Core-Monitor/SettingsWindow.swift +++ b/Core-Monitor/SettingsWindow.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI // MARK: - Window manager @@ -8,11 +9,17 @@ import SwiftUI final class SettingsWindowManager: NSObject, NSWindowDelegate { static let shared = SettingsWindowManager() - private var window: NSWindow? + private(set) var window: NSWindow? + private let selection = SettingsSelection() private var systemMonitor: SystemMonitor? private var fanController: FanController? private var startupManager: StartupManager? + init(startupManager: StartupManager? = nil) { + self.startupManager = startupManager + super.init() + } + /// Called once at launch so any surface (toolbar, popovers, app menu) /// can open Settings without threading dependencies around. func configure( @@ -27,6 +34,7 @@ final class SettingsWindowManager: NSObject, NSWindowDelegate { func show(tab: SettingsTab = .general) { guard let startupManager else { return } + selection.tab = tab if let window { window.makeKeyAndOrderFront(nil) @@ -36,7 +44,7 @@ final class SettingsWindowManager: NSObject, NSWindowDelegate { let rootView = SettingsView( startupManager: startupManager, - initialTab: tab + selection: selection ) let hostingController = NSHostingController(rootView: rootView) let newWindow = NSWindow(contentViewController: hostingController) @@ -85,20 +93,17 @@ enum SettingsTab: String, CaseIterable, Identifiable { // MARK: - Root +@MainActor +final class SettingsSelection: ObservableObject { + @Published var tab: SettingsTab = .general +} + struct SettingsView: View { @ObservedObject var startupManager: StartupManager - @State private var tab: SettingsTab - - init( - startupManager: StartupManager, - initialTab: SettingsTab = .general - ) { - self.startupManager = startupManager - _tab = State(initialValue: initialTab) - } + @ObservedObject var selection: SettingsSelection var body: some View { - TabView(selection: $tab) { + TabView(selection: $selection.tab) { GeneralSettingsTab(startupManager: startupManager) .tabItem { Label(SettingsTab.general.title, systemImage: SettingsTab.general.symbolName) } .tag(SettingsTab.general) diff --git a/Core-Monitor/SystemMonitor.swift b/Core-Monitor/SystemMonitor.swift index f2a220ba..93d0f622 100644 --- a/Core-Monitor/SystemMonitor.swift +++ b/Core-Monitor/SystemMonitor.swift @@ -186,8 +186,7 @@ final class SystemMonitor: ObservableObject { var networkStats: NetworkStats { snapshot.networkStats } private var previousNetworkBytes: (sent: UInt64, received: UInt64) = (0, 0) private var previousNetworkTime: Date = Date() - private var cachedDiskStats = DiskStats() - private var lastDiskStatsRefreshAt: Date? + private var diskStatsCache = DiskStatsCache() // MARK: - History buffers (60 samples, used by menu bar popovers) private(set) var cpuHistory: [Double] = Array(repeating: 0, count: 60) @@ -697,17 +696,12 @@ final class SystemMonitor: ObservableObject { } // MARK: - Disk stats (via FileManager) private func readDiskStats(now: Date = Date()) -> DiskStats { - guard DiskStatsRefreshPolicy.shouldRefresh(lastUpdatedAt: lastDiskStatsRefreshAt, now: now) else { - return cachedDiskStats - } - - var stats = DiskStats() - do { + diskStatsCache.read(now: now) { let attrs = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()) guard let totalRaw = attrs[.systemSize] as? Int64, let freeRaw = attrs[.systemFreeSize] as? Int64 - else { return stats } + else { return nil } let totalBytes = Double(totalRaw) let freeBytes = Double(freeRaw) @@ -722,15 +716,14 @@ final class SystemMonitor: ObservableObject { let usedBytes = max(0, totalBytes - freeBytes - purgeableBytes) + var stats = DiskStats() stats.totalGB = totalBytes / 1_073_741_824 stats.usedGB = usedBytes / 1_073_741_824 stats.freeGB = freeBytes / 1_073_741_824 stats.purgeableGB = purgeableBytes / 1_073_741_824 stats.usagePercent = totalBytes > 0 ? usedBytes / totalBytes * 100 : 0 - } catch {} - cachedDiskStats = stats - lastDiskStatsRefreshAt = now - return cachedDiskStats + return stats + } } private func readFanReadings() -> (speeds: [Int], mins: [Int], maxs: [Int]) { diff --git a/Core-MonitorTests/AlertEngineTests.swift b/Core-MonitorTests/AlertEngineTests.swift index 97899af0..e55ba17d 100644 --- a/Core-MonitorTests/AlertEngineTests.swift +++ b/Core-MonitorTests/AlertEngineTests.swift @@ -394,6 +394,57 @@ final class AlertEngineTests: XCTestCase { ) } + func testMissingFanReadingsDoNotTriggerLowSpeedAlerts() { + for speeds in [[-1], [-1, -1], [-1, 3_000], [3_000, -1]] { + let outcome = evaluateHotFans(speeds: speeds, temperature: 95) + + XCTAssertNil(outcome.activeState, "Speeds: \(speeds)") + XCTAssertNil(outcome.event) + XCTAssertFalse(outcome.shouldNotify) + } + } + + func testMissingFirstFanDoesNotHideSlowSecondFan() { + let outcome = evaluateHotFans(speeds: [-1, 1_000], temperature: 85) + + XCTAssertEqual(outcome.activeState?.severity, .warning) + XCTAssertNotNil(outcome.event) + } + + func testZeroRPMStillTriggersCriticalStallAlertUnderHeat() { + let outcome = evaluateHotFans(speeds: [-1, 0], temperature: 85) + + XCTAssertEqual(outcome.activeState?.severity, .critical) + XCTAssertTrue(outcome.shouldNotify) + } + + func testSlowFanStillEscalatesAtCriticalTemperature() { + let outcome = evaluateHotFans(speeds: [1_000, -1], temperature: 95) + + XCTAssertEqual(outcome.activeState?.severity, .critical) + } + + private func evaluateHotFans(speeds: [Int], temperature: Double) -> AlertEvaluationOutcome { + let config = AlertRuleConfig( + kind: .fanTooLowUnderHeat, + isEnabled: true, + threshold: .init(warning: 80, critical: 90, hysteresis: 3), + cooldownMinutes: 10, + debounceSamples: 1, + desktopNotificationsEnabled: true + ) + return AlertEvaluator.evaluate( + config: config, + runtime: .initial(for: .fanTooLowUnderHeat), + input: makeInput { snapshot in + snapshot.cpuTemperature = temperature + snapshot.numberOfFans = speeds.count + snapshot.fanSpeeds = speeds + snapshot.fanMinSpeeds = Array(repeating: 1_500, count: speeds.count) + } + ) + } + private func makeInput( fanMode: FanControlMode = .automatic, helperInstalled: Bool = true, diff --git a/Core-MonitorTests/DiskStatsRefreshPolicyTests.swift b/Core-MonitorTests/DiskStatsRefreshPolicyTests.swift index 67235caa..446f0877 100644 --- a/Core-MonitorTests/DiskStatsRefreshPolicyTests.swift +++ b/Core-MonitorTests/DiskStatsRefreshPolicyTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import Core_Monitor +@MainActor final class DiskStatsRefreshPolicyTests: XCTestCase { func testRefreshesImmediatelyWhenNoPreviousSampleExists() { XCTAssertTrue( @@ -28,4 +29,64 @@ final class DiskStatsRefreshPolicyTests: XCTestCase { ) ) } + + func testCacheKeepsLastSuccessfulReadingWhenLoadThrowsAndRetriesNextSample() { + enum ReadFailure: Error { case unavailable } + var cache = DiskStatsCache() + let originalDate = Date(timeIntervalSince1970: 100) + let original = DiskStats(totalGB: 500, usedGB: 300, freeGB: 180, purgeableGB: 20, usagePercent: 60) + _ = cache.read(now: originalDate) { original } + + let failed = cache.read(now: originalDate.addingTimeInterval(30)) { throw ReadFailure.unavailable } + + XCTAssertEqual(failed.totalGB, 500) + XCTAssertEqual(failed.usedGB, 300) + XCTAssertEqual(failed.freeGB, 180) + XCTAssertEqual(failed.purgeableGB, 20) + XCTAssertEqual(failed.usagePercent, 60) + XCTAssertEqual(cache.lastUpdatedAt, originalDate) + + let retryDate = originalDate.addingTimeInterval(31) + let recovered = cache.read(now: retryDate) { DiskStats(totalGB: 500, usedGB: 350, usagePercent: 70) } + + XCTAssertEqual(recovered.usedGB, 350) + XCTAssertEqual(cache.lastUpdatedAt, retryDate) + } + + func testIncompleteReadPreservesCacheAndDoesNotDelayRetry() { + var cache = DiskStatsCache() + let originalDate = Date(timeIntervalSince1970: 100) + _ = cache.read(now: originalDate) { DiskStats(totalGB: 500, usedGB: 300) } + + let failed = cache.read(now: originalDate.addingTimeInterval(30)) { nil } + + XCTAssertEqual(failed.usedGB, 300) + XCTAssertEqual(cache.lastUpdatedAt, originalDate) + } + + func testFirstFailedReadLeavesCacheEligibleForImmediateRetry() { + var cache = DiskStatsCache() + let now = Date(timeIntervalSince1970: 100) + let failed = cache.read(now: now) { nil } + + XCTAssertEqual(failed.totalGB, 0) + XCTAssertNil(cache.lastUpdatedAt) + + let recovered = cache.read(now: now.addingTimeInterval(1)) { DiskStats(totalGB: 500) } + XCTAssertEqual(recovered.totalGB, 500) + } + + func testSuccessfulReadStillThrottlesSubsequentLoads() { + var cache = DiskStatsCache() + let now = Date(timeIntervalSince1970: 100) + _ = cache.read(now: now) { DiskStats(totalGB: 500) } + + let cached = cache.read(now: now.addingTimeInterval(29)) { + XCTFail("A successful sample must keep the normal refresh interval.") + return nil + } + + XCTAssertEqual(cached.totalGB, 500) + XCTAssertEqual(cache.lastUpdatedAt, now) + } } diff --git a/Core-MonitorTests/FanReadingTests.swift b/Core-MonitorTests/FanReadingTests.swift new file mode 100644 index 00000000..31089c4b --- /dev/null +++ b/Core-MonitorTests/FanReadingTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import Core_Monitor + +@MainActor +final class FanReadingTests: XCTestCase { + func testFallbackFindsBothFansWithoutFNum() { + let keys: Set = ["F0Ac", "F0Mn", "F0Mx", "F1Ac", "F1Mn", "F1Mx"] + + XCTAssertEqual(SMCFanDetection.fallbackCount(keyExists: keys.contains), 2) + } + + func testFallbackHandlesSingleFanAndNoFans() { + XCTAssertEqual(SMCFanDetection.fallbackCount { $0 == "F0Ac" }, 1) + XCTAssertEqual(SMCFanDetection.fallbackCount { _ in false }, 0) + } + + func testFallbackKeepsScanningPastMissingFanIDs() { + let keys: Set = ["F0Ac", "F3Ac"] + + XCTAssertEqual(SMCFanDetection.fallbackCount(keyExists: keys.contains), 4) + } + + func testFallbackUsesBoundaryKeysWhenActualRPMIsUnavailable() { + let keys: Set = ["F0Mn", "F1Mx"] + + XCTAssertEqual(SMCFanDetection.fallbackCount(keyExists: keys.contains), 2) + } + + func testFanLabelShowsZeroForStoppedFans() { + for speeds in [[0], [0, 0], [-1, 0]] { + let label = SingleMenuBarItemController.fanStatusLabel(speeds: speeds, maximumRPM: 5_000) + + XCTAssertEqual(label.text, "0", "Speeds: \(speeds)") + XCTAssertEqual(label.tone, .normal) + } + } + + func testFanLabelShowsUnavailableOnlyWithoutValidReadings() { + for speeds in [[], [-1], [-1, -1]] { + let label = SingleMenuBarItemController.fanStatusLabel(speeds: speeds, maximumRPM: 5_000) + + XCTAssertEqual(label.text, "—") + XCTAssertEqual(label.tone, .secondary) + } + } + + func testFanLabelKeepsHighestValidSpeedAndUtilizationTone() { + let label = SingleMenuBarItemController.fanStatusLabel(speeds: [-1, 0, 2_000, 4_500], maximumRPM: 5_000) + + XCTAssertEqual(label.text, "4.5k") + XCTAssertEqual(label.tone, .critical) + } +} diff --git a/Core-MonitorTests/SettingsWindowTests.swift b/Core-MonitorTests/SettingsWindowTests.swift new file mode 100644 index 00000000..22d600c4 --- /dev/null +++ b/Core-MonitorTests/SettingsWindowTests.swift @@ -0,0 +1,42 @@ +import AppKit +import SwiftUI +import XCTest +@testable import Core_Monitor + +@MainActor +final class SettingsWindowTests: XCTestCase { + func testRequestedTabUpdatesExistingWindowAndHostedSelection() throws { + let manager = SettingsWindowManager(startupManager: StartupManager()) + manager.show(tab: .general) + let window = try XCTUnwrap(manager.window) + defer { window.close() } + let host = try XCTUnwrap(window.contentViewController as? NSHostingController) + + XCTAssertEqual(host.rootView.selection.tab, .general) + // Simulate a user selecting a tab before another entry point opens Settings. + host.rootView.selection.tab = .menuBar + manager.show(tab: .touchBar) + + XCTAssertTrue(manager.window === window) + XCTAssertEqual(host.rootView.selection.tab, .touchBar) + + manager.show(tab: .about) + XCTAssertEqual(host.rootView.selection.tab, .about) + } + + func testReopenedWindowUsesRequestedTab() throws { + let manager = SettingsWindowManager(startupManager: StartupManager()) + manager.show(tab: .touchBar) + let firstWindow = try XCTUnwrap(manager.window) + firstWindow.close() + XCTAssertNil(manager.window) + + manager.show(tab: .menuBar) + let reopenedWindow = try XCTUnwrap(manager.window) + defer { reopenedWindow.close() } + let host = try XCTUnwrap(reopenedWindow.contentViewController as? NSHostingController) + + XCTAssertFalse(reopenedWindow === firstWindow) + XCTAssertEqual(host.rootView.selection.tab, .menuBar) + } +} diff --git a/README.md b/README.md index 6d7d2c24..b2add166 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ it runs locally, needs no account, and is built native for apple silicon rather cpu, gpu, memory, battery, thermals, power draw, fan speeds, network, and disk. readings come from the apple smc and system apis, so the numbers match what the hardware actually reports. +if a disk capacity refresh fails, core-monitor keeps the last successful reading and retries on the next sample. +

core-monitor memory screen with usage history and memory breakdown core-monitor storage screen with used, purgeable, free, and total capacity @@ -50,6 +52,8 @@ cpu, gpu, memory, battery, thermals, power draw, fan speeds, network, and disk. core-monitor can take over fan speeds through a small privileged helper, then hand control back to macos when you turn it off. you can set fixed speeds or build custom curves. the controls are explicit on purpose, since this touches cooling on a machine you care about. +if the smc fan-count reading is unavailable, fan detection checks all candidate fan slots. failed RPM readings do not trigger low-speed alerts; a real 0 RPM reading can still trigger a stall alert when the mac is hot. +

core-monitor cooling screen with fan speeds, cooling mode, and custom fan curve

@@ -60,6 +64,8 @@ core-monitor can take over fan speeds through a small privileged helper, then ha - open a compact popover for a fuller summary without leaving what you are doing - put live widgets on the touch bar if your mac has one +the fan menu bar value shows `0` when the highest available reading is 0 RPM, and `—` when no fan reading is available. settings shortcuts select their requested tab even when the settings window is already open. +

core-monitor network menu bar panel core-monitor thermal menu bar panel diff --git a/Shared/SMCFanDetection.swift b/Shared/SMCFanDetection.swift new file mode 100644 index 00000000..f05573c0 --- /dev/null +++ b/Shared/SMCFanDetection.swift @@ -0,0 +1,18 @@ +import Foundation + +enum SMCFanDetection { + /// Return the span of discovered fan IDs, so callers also visit later fans + /// when an earlier fan's keys are unavailable. + nonisolated static func fallbackCount(keyExists: (String) -> Bool) -> Int { + var count = 0 + for fanID in 0..<12 { + let actualKey = String(format: "F%dAc", fanID) + let minKey = String(format: "F%dMn", fanID) + let maxKey = String(format: "F%dMx", fanID) + if keyExists(actualKey) || keyExists(minKey) || keyExists(maxKey) { + count = fanID + 1 + } + } + return count + } +} diff --git a/scripts/tests/check_helper_lifetime.sh b/scripts/tests/check_helper_lifetime.sh index 3893c26b..c14cba9f 100755 --- a/scripts/tests/check_helper_lifetime.sh +++ b/scripts/tests/check_helper_lifetime.sh @@ -19,6 +19,7 @@ else -module-name ventaphobia_smc_helper \ "${ROOT_DIR}/smc-helper/main.swift" \ "${ROOT_DIR}/smc-helper/SMCHelperXPC.swift" \ + "${ROOT_DIR}/Shared/SMCFanDetection.swift" \ -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist \ -Xlinker "${ROOT_DIR}/smc-helper/Info.plist" \ -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __launchd_plist \ diff --git a/smc-helper/main.swift b/smc-helper/main.swift index 08c41369..712ec2b2 100644 --- a/smc-helper/main.swift +++ b/smc-helper/main.swift @@ -284,16 +284,7 @@ private final class SMCController { return Int(directCount.rounded()) } - for fanID in 0..<12 { - let actualKey = String(format: "F%dAc", fanID) - let minKey = String(format: "F%dMn", fanID) - let maxKey = String(format: "F%dMx", fanID) - if keyExists(actualKey) || keyExists(minKey) || keyExists(maxKey) { - return fanID + 1 - } - } - - return 0 + return SMCFanDetection.fallbackCount(keyExists: keyExists) } private func validatedHardwareRPM(for fanID: Int, requestedRPM: Int) throws -> Int {