Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Core-Monitor.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -48,6 +51,9 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
A19400012026091900000001 /* FanReadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanReadingTests.swift; sourceTree = "<group>"; };
A19400022026091900000001 /* DiskStatsRefreshPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskStatsRefreshPolicyTests.swift; sourceTree = "<group>"; };
A19400032026091900000001 /* SettingsWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowTests.swift; sourceTree = "<group>"; };
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; };
Expand All @@ -63,6 +69,11 @@
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
A19300012026091900000001 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Shared;
sourceTree = "<group>";
};
353B02D32F640A0700A65F2C /* Core-Monitor */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "Core-Monitor";
Expand Down Expand Up @@ -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 */,
Expand All @@ -119,6 +133,7 @@
353B02C82F640A0700A65F2C = {
isa = PBXGroup;
children = (
A19300012026091900000001 /* Shared */,
353B02D32F640A0700A65F2C /* Core-Monitor */,
35F039AC2F64444D004CB9F8 /* smc-helper */,
353B02D22F640A0700A65F2C /* Products */,
Expand Down Expand Up @@ -172,6 +187,7 @@
);
fileSystemSynchronizedGroups = (
353B02D32F640A0700A65F2C /* Core-Monitor */,
A19300012026091900000001 /* Shared */,
);
name = "Core-Monitor";
productName = "Core-Monitor";
Expand All @@ -192,6 +208,7 @@
);
fileSystemSynchronizedGroups = (
35F039AC2F64444D004CB9F8 /* smc-helper */,
A19300012026091900000001 /* Shared */,
);
name = "smc-helper";
productName = "smc-helper";
Expand Down Expand Up @@ -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 */,
Expand Down
1 change: 1 addition & 0 deletions Core-Monitor/AlertEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}?
Expand Down
17 changes: 17 additions & 0 deletions Core-Monitor/DiskStatsRefreshPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
13 changes: 2 additions & 11 deletions Core-Monitor/FanController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 13 additions & 9 deletions Core-Monitor/MenubarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)?
Expand Down
29 changes: 17 additions & 12 deletions Core-Monitor/SettingsWindow.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Combine
import SwiftUI

// MARK: - Window manager
Expand All @@ -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(
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 6 additions & 13 deletions Core-Monitor/SystemMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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]) {
Expand Down
51 changes: 51 additions & 0 deletions Core-MonitorTests/AlertEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading