diff --git a/Core-Monitor.xcodeproj/project.pbxproj b/Core-Monitor.xcodeproj/project.pbxproj index 377e9b9..c77cae8 100644 --- a/Core-Monitor.xcodeproj/project.pbxproj +++ b/Core-Monitor.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + A19600032026091900000002 /* BatteryDetailFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19600032026091900000001 /* BatteryDetailFormatterTests.swift */; }; + A19600022026091900000002 /* HelperXPCClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19600022026091900000001 /* HelperXPCClientTests.swift */; }; + A19600012026091900000002 /* RuntimeSafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A19600012026091900000001 /* RuntimeSafetyTests.swift */; }; 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 */; }; @@ -51,6 +54,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A19600032026091900000001 /* BatteryDetailFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryDetailFormatterTests.swift; sourceTree = ""; }; + A19600022026091900000001 /* HelperXPCClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperXPCClientTests.swift; sourceTree = ""; }; + A19600012026091900000001 /* RuntimeSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeSafetyTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -115,6 +121,9 @@ 1A2F97F19E4BA4C0B1070411 /* Core-MonitorTests */ = { isa = PBXGroup; children = ( + A19600032026091900000001 /* BatteryDetailFormatterTests.swift */, + A19600022026091900000001 /* HelperXPCClientTests.swift */, + A19600012026091900000001 /* RuntimeSafetyTests.swift */, A19400012026091900000001 /* FanReadingTests.swift */, A19400022026091900000001 /* DiskStatsRefreshPolicyTests.swift */, A19400032026091900000001 /* SettingsWindowTests.swift */, @@ -331,6 +340,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A19600032026091900000002 /* BatteryDetailFormatterTests.swift in Sources */, + A19600022026091900000002 /* HelperXPCClientTests.swift in Sources */, + A19600012026091900000002 /* RuntimeSafetyTests.swift in Sources */, A19400012026091900000002 /* FanReadingTests.swift in Sources */, A19400022026091900000002 /* DiskStatsRefreshPolicyTests.swift in Sources */, A19400032026091900000002 /* SettingsWindowTests.swift in Sources */, diff --git a/Core-Monitor/AlertManager.swift b/Core-Monitor/AlertManager.swift index 87c1fab..811461c 100644 --- a/Core-Monitor/AlertManager.swift +++ b/Core-Monitor/AlertManager.swift @@ -304,6 +304,10 @@ final class AlertManager: NSObject, ObservableObject { } } + static func notificationIdentifier(for kind: AlertRuleKind) -> String { + "coremonitor.alert.\(kind.rawValue)" + } + private func deliverDesktopNotification(for event: AlertEvent) { guard authorizationStatus == .authorized || authorizationStatus == .provisional else { return } @@ -317,7 +321,7 @@ final class AlertManager: NSObject, ObservableObject { ] let request = UNNotificationRequest( - identifier: "coremonitor.alert.\(event.id.uuidString)", + identifier: Self.notificationIdentifier(for: event.kind), content: content, trigger: nil ) diff --git a/Core-Monitor/BatteryDetailFormatter.swift b/Core-Monitor/BatteryDetailFormatter.swift index 803c8c4..bcafe01 100644 --- a/Core-Monitor/BatteryDetailFormatter.swift +++ b/Core-Monitor/BatteryDetailFormatter.swift @@ -1,71 +1,76 @@ import Foundation enum BatteryDetailFormatter { - static func powerStateDescription(for info: BatteryInfo) -> String { + static func powerStateDescription(for info: BatteryInfo, locale: Locale = AppLocaleStore.currentLocale) -> String { if info.isCharging { - return "Charging" + return localized("Charging", locale: locale) } if info.isPluggedIn { - return "AC Power" + return localized("AC Power", locale: locale) } - return "Battery Power" + return localized("Battery Power", locale: locale) } - static func sourceDescription(for info: BatteryInfo) -> String? { + static func sourceDescription(for info: BatteryInfo, locale: Locale = AppLocaleStore.currentLocale) -> String? { if let source = info.source?.trimmingCharacters(in: .whitespacesAndNewlines), !source.isEmpty { switch source { case "AC Power": - return "Power Adapter" + return localized("Power Adapter", locale: locale) case "Battery Power": - return "Internal Battery" + return localized("Internal Battery", locale: locale) default: return source } } guard info.hasBattery else { return nil } - return info.isPluggedIn ? "Power Adapter" : "Internal Battery" + return localized(info.isPluggedIn ? "Power Adapter" : "Internal Battery", locale: locale) } - static func runtimeDescription(for info: BatteryInfo) -> String? { + static func runtimeDescription(for info: BatteryInfo, locale: Locale = AppLocaleStore.currentLocale) -> String? { guard let minutes = info.timeRemainingMinutes, minutes >= 0 else { return nil } if minutes == 0 { - return info.isCharging ? "Finishing soon" : "Less than 1m remaining" + return localized(info.isCharging ? "Finishing soon" : "Less than 1m remaining", locale: locale) } - let formattedDuration = durationDescription(minutes: minutes) + let formattedDuration = durationDescription(minutes: minutes, locale: locale) if info.isCharging { - return "\(formattedDuration) until full" + return String(format: localized("%@ until full", locale: locale), locale: locale, formattedDuration) } - return "\(formattedDuration) remaining" + return String(format: localized("%@ remaining", locale: locale), locale: locale, formattedDuration) } - static func durationDescription(minutes: Int) -> String { - let clampedMinutes = max(minutes, 0) - if clampedMinutes < 60 { - return "\(clampedMinutes)m" - } - - let hours = clampedMinutes / 60 - let remainingMinutes = clampedMinutes % 60 - if remainingMinutes == 0 { - return "\(hours)h" - } - return "\(hours)h \(remainingMinutes)m" + static func durationDescription(minutes: Int, locale: Locale = AppLocaleStore.currentLocale) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .abbreviated + formatter.zeroFormattingBehavior = .dropLeading + var calendar = Calendar(identifier: .gregorian) + calendar.locale = locale + formatter.calendar = calendar + return formatter.string(from: TimeInterval(max(0, minutes)) * 60) ?? "0" } - static func temperatureDescription(_ temperature: Double?) -> String? { + static func temperatureDescription(_ temperature: Double?, locale: Locale = AppLocaleStore.currentLocale) -> String? { guard let temperature else { return nil } - return String(format: "%.1f °C", temperature) + return String(format: "%.1f °C", locale: locale, temperature) } - static func voltageDescription(_ voltage: Double?) -> String? { + static func voltageDescription(_ voltage: Double?, locale: Locale = AppLocaleStore.currentLocale) -> String? { guard let voltage else { return nil } - return String(format: "%.2f V", voltage) + return String(format: "%.2f V", locale: locale, voltage) } - static func amperageDescription(_ amperage: Double?) -> String? { + static func amperageDescription(_ amperage: Double?, locale: Locale = AppLocaleStore.currentLocale) -> String? { guard let amperage else { return nil } - return String(format: "%.2f A", amperage) + return String(format: "%.2f A", locale: locale, amperage) + } + + private static func localized(_ key: String, locale: Locale) -> String { + let language = Bundle.preferredLocalizations(from: Bundle.main.localizations, forPreferences: [locale.identifier]).first ?? "en" + guard let path = Bundle.main.path(forResource: language, ofType: "lproj"), let bundle = Bundle(path: path) else { return key } + let existing = bundle.localizedString(forKey: key, value: key, table: nil) + if existing != key { return existing } + return bundle.localizedString(forKey: key, value: key, table: "BatteryDetails") } } diff --git a/Core-Monitor/BatteryDetails.xcstrings b/Core-Monitor/BatteryDetails.xcstrings new file mode 100644 index 0000000..a4cd26c --- /dev/null +++ b/Core-Monitor/BatteryDetails.xcstrings @@ -0,0 +1,251 @@ +{ + "sourceLanguage": "en", + "strings": { + "Battery Power": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery Power" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Batteridrift" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Batteriebetrieb" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Alimentation sur batterie" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alimentación por batería" + } + } + } + }, + "Power Adapter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Power Adapter" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Nätadapter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzteil" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Adaptateur secteur" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Adaptador de corriente" + } + } + } + }, + "Internal Battery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Internal Battery" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Internt batteri" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Interne Batterie" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Batterie interne" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Batería interna" + } + } + } + }, + "Finishing soon": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finishing soon" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Snart klart" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bald abgeschlossen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bientôt terminé" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Finaliza pronto" + } + } + } + }, + "Less than 1m remaining": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Less than 1m remaining" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "Mindre än 1 min kvar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Weniger als 1 Min. verbleibend" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moins de 1 min restante" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Menos de 1 min restante" + } + } + } + }, + "%@ until full": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ until full" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "%@ tills fulladdat" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ bis vollständig geladen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ avant la charge complète" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ hasta la carga completa" + } + } + } + }, + "%@ remaining": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ remaining" + } + }, + "sv": { + "stringUnit": { + "state": "translated", + "value": "%@ kvar" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ verbleibend" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ restantes" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ restantes" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Core-Monitor/Core_MonitorApp.swift b/Core-Monitor/Core_MonitorApp.swift index bb1bd27..a902d99 100644 --- a/Core-Monitor/Core_MonitorApp.swift +++ b/Core-Monitor/Core_MonitorApp.swift @@ -485,8 +485,10 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { coordinator: coordinator, startupManager: startupManager ) { [weak self] in - self?.dashboardController = nil - self?.restoreAccessoryActivationPolicyIfNeeded() + // windowWillClose arrives before AppKit finishes hiding the window. + DispatchQueue.main.async { [weak self] in + self?.restoreAccessoryActivationPolicyIfNeeded() + } } debugLaunch("dashboardController created") dashboardController = controller diff --git a/Core-Monitor/DiskProcessSampler.swift b/Core-Monitor/DiskProcessSampler.swift index 200c9ac..9a9cac6 100644 --- a/Core-Monitor/DiskProcessSampler.swift +++ b/Core-Monitor/DiskProcessSampler.swift @@ -153,7 +153,7 @@ final class DiskProcessSampler: ObservableObject { private var timer: Timer? private var isRunning = false private var previousCountersByPID: [pid_t: DiskProcessCounter] = [:] - private var isSampling = false + private var samplingSession = SamplingSession() init(interval: TimeInterval = 5.0, limit: Int = 4) { self.interval = interval @@ -174,6 +174,7 @@ final class DiskProcessSampler: ObservableObject { timer?.invalidate() timer = nil isRunning = true + samplingSession.start() sample() @@ -192,8 +193,8 @@ final class DiskProcessSampler: ObservableObject { timer?.invalidate() timer = nil isRunning = false + samplingSession.stop() previousCountersByPID = [:] - isSampling = false guard clear else { return } processes = [] @@ -210,8 +211,7 @@ final class DiskProcessSampler: ObservableObject { } private func sample() { - guard isRunning, !isSampling else { return } - isSampling = true + guard let ticket = samplingSession.begin() else { return } let previousCountersByPID = self.previousCountersByPID let limit = self.limit @@ -226,9 +226,7 @@ final class DiskProcessSampler: ObservableObject { let nextCountersByPID = Dictionary(counters.map { ($0.pid, $0) }, uniquingKeysWith: { first, _ in first }) DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.isSampling = false - guard self.isRunning else { return } + guard let self, self.samplingSession.complete(ticket) else { return } self.previousCountersByPID = nextCountersByPID self.processes = activities self.hasSample = true diff --git a/Core-Monitor/FanController.swift b/Core-Monitor/FanController.swift index f69365c..cd7159f 100644 --- a/Core-Monitor/FanController.swift +++ b/Core-Monitor/FanController.swift @@ -396,6 +396,36 @@ final class FanController: ObservableObject { private weak var systemMonitor: SystemMonitor? private var controlTimer: Timer? + private var leaseTimer: Timer? + private var leaseTask: Task? + private var controlTask: Task? + private var controlOperations: [@MainActor () async -> Void] = [] + private var updateQueued = false + private var isTerminating = false + + private func enqueueControl(_ operation: @escaping @MainActor () async -> Void) { + guard !isTerminating else { return } + controlOperations.append(operation) + guard controlTask == nil else { return } + controlTask = Task { @MainActor [weak self] in + guard let self else { return } + while !isTerminating, !Task.isCancelled, !controlOperations.isEmpty { + let operation = controlOperations.removeFirst() + await operation() + } + controlTask = nil + } + } + + private func queueManagedUpdate() { + guard !updateQueued else { return } + updateQueued = true + enqueueControl { [weak self] in + guard let self else { return } + updateQueued = false + await updateManagedControl() + } + } private var lastAppliedSpeed: Int = 0 private let helperManager = SMCHelperManager.shared private var workspaceObservers: [NSObjectProtocol] = [] @@ -413,6 +443,9 @@ final class FanController: ObservableObject { deinit { controlTimer?.invalidate() + leaseTimer?.invalidate() + leaseTask?.cancel() + controlTask?.cancel() controlTimer = nil for observer in workspaceObservers { NSWorkspace.shared.notificationCenter.removeObserver(observer) @@ -434,27 +467,28 @@ final class FanController: ObservableObject { // MARK: - Public API - /// Best-effort shutdown cleanup so managed fan targets do not outlive the app process. + /// Disconnecting the persistent session makes the helper restore owned fans. + /// Its watchdog also restores them after an app crash or stalled heartbeat. func restoreSystemAutomaticOnTermination() { + isTerminating = true stopControlLoop() - - guard mode != .automatic else { return } - guard helperManager.isInstalled else { return } - - let fanCount = resolvedFanCount() - guard fanCount > 0 else { return } - - for fanID in 0.. 0 else { statusMessage = helperUnavailableMessage() return } var allSuccess = true for fanID in 0.. TimeInterval { @@ -732,7 +796,7 @@ final class FanController: ObservableObject { return customPreset?.resolvedUpdateInterval ?? 2.0 } - private func applyCurrentMode(force: Bool = false) { + private func applyCurrentMode(force: Bool = false) async { if force { stopControlLoop() } switch mode { @@ -742,60 +806,58 @@ final class FanController: ObservableObject { // process: after a relaunch, a crash, or an earlier handoff it // reads 0 or -1 while the fans may still be pinned from before. if force || Self.shouldRequestSystemAutomaticHandoff(lastAppliedSpeed: lastAppliedSpeed) { - requestSystemAutomaticHandoff() + await requestSystemAutomaticHandoff() } else { statusMessage = passiveStatusMessage(for: mode) } lastAppliedSpeed = -1 case .manual: - applyFanSpeed(manualSpeed) - lastAppliedSpeed = manualSpeed - statusMessage = "Manual: \(manualSpeed) RPM" - startControlLoop() + await startControlLoop() case .smart, .balanced, .performance, .max, .custom: - startControlLoop() + await startControlLoop() } } - private func updateManagedControl() { - guard systemMonitor != nil else { return } + private func updateManagedControl() async { + guard !isTerminating, !Task.isCancelled, systemMonitor != nil else { return } switch mode { case .manual: if abs(manualSpeed - lastAppliedSpeed) >= 50 || lastAppliedSpeed == 0 { - applyFanSpeed(manualSpeed) - lastAppliedSpeed = manualSpeed + let target = manualSpeed + guard await applyFanSpeed(target) else { return } + lastAppliedSpeed = target } statusMessage = "Manual: \(manualSpeed) RPM" case .automatic, .silent: if Self.shouldRequestSystemAutomaticHandoff(lastAppliedSpeed: lastAppliedSpeed) { - requestSystemAutomaticHandoff() + await requestSystemAutomaticHandoff() } else { statusMessage = passiveStatusMessage(for: mode) } lastAppliedSpeed = -1 case .balanced: - applyFixedPercentProfile(0.60, label: "Balanced") + await applyFixedPercentProfile(0.60, label: "Balanced") case .performance: - applyFixedPercentProfile(0.85, label: "Performance") + await applyFixedPercentProfile(0.85, label: "Performance") case .max: - applyFixedPercentProfile(1.0, label: "Max") + await applyFixedPercentProfile(1.0, label: "Max") case .smart: - updateSmartProfile() + await updateSmartProfile() case .custom: - updateCustomProfile() + await updateCustomProfile() } } // MARK: - Smart Profile (temperature + power aware) - private func updateSmartProfile() { + private func updateSmartProfile() async { guard let monitor = systemMonitor else { return } let cpuTemp = monitor.cpuSafetyTemperature ?? 0 @@ -826,7 +888,7 @@ final class FanController: ObservableObject { let finalSpeed = Int(max(Double(minSpeed), min(Double(autoMaxSpeed), target))) if abs(finalSpeed - lastAppliedSpeed) >= 50 || lastAppliedSpeed == 0 { - applyFanSpeed(finalSpeed) + guard await applyFanSpeed(finalSpeed) else { return } lastAppliedSpeed = finalSpeed let tempStr = gpuTemp > cpuTemp ? String(format: "GPU %.0f°C", gpuTemp) @@ -837,7 +899,7 @@ final class FanController: ObservableObject { // MARK: - Custom Profile - private func updateCustomProfile() { + private func updateCustomProfile() async { guard let monitor = systemMonitor else { return } guard let preset = customPreset else { let message = customPresetLastError ?? "No custom preset has been saved yet." @@ -872,7 +934,7 @@ final class FanController: ObservableObject { let effectiveTemperature = min(baseTemperature + powerBoost, 120) let percent = max(0, min(100, preset.interpolatedSpeedPercent(for: effectiveTemperature))) - let fanCount = max(resolvedFanCount(), 1) + let fanCount = max(await resolvedFanCount(), 1) let fallbackMin = monitor.fanMinSpeeds.first ?? minSpeed let fallbackMax = monitor.fanMaxSpeeds.first ?? maxSpeed let presetMin = max(preset.minimumRPM ?? fallbackMin, minSpeed) @@ -900,7 +962,7 @@ final class FanController: ObservableObject { } if abs(smoothedTarget - lastAppliedSpeed) >= 50 || lastAppliedSpeed == 0 { - _ = applyPerFanSpeeds(requestedSpeeds, successMessage: "Custom: \(preset.name)") + guard await applyPerFanSpeeds(requestedSpeeds, successMessage: "Custom: \(preset.name)") else { return } lastAppliedSpeed = smoothedTarget } @@ -922,9 +984,9 @@ final class FanController: ObservableObject { // MARK: - Fixed Percent Profile - private func applyFixedPercentProfile(_ percent: Double, label: String) { + private func applyFixedPercentProfile(_ percent: Double, label: String) async { guard let monitor = systemMonitor else { return } - let fanCount = resolvedFanCount() + let fanCount = await resolvedFanCount() guard fanCount > 0 else { statusMessage = helperUnavailableMessage() return @@ -933,7 +995,7 @@ final class FanController: ObservableObject { let firstMax = monitor.fanMaxSpeeds.first ?? maxSpeed let target = Int((Double(firstMax) * percent).rounded()) if abs(target - lastAppliedSpeed) >= 50 || lastAppliedSpeed == 0 { - applyFanSpeed(target) + guard await applyFanSpeed(target) else { return } lastAppliedSpeed = target } statusMessage = "\(label): \(target) RPM" @@ -941,16 +1003,17 @@ final class FanController: ObservableObject { // MARK: - Speed Application - private func applyFanSpeed(_ speed: Int) { - let fanCount = max(resolvedFanCount(), 1) + @discardableResult + private func applyFanSpeed(_ speed: Int) async -> Bool { + let fanCount = max(await resolvedFanCount(), 1) let speeds = Array(repeating: speed, count: fanCount) - _ = applyPerFanSpeeds(speeds, successMessage: "Applied \(speed) RPM") + return await applyPerFanSpeeds(speeds, successMessage: "Applied \(speed) RPM") } @discardableResult - private func applyPerFanSpeeds(_ requestedSpeeds: [Int], successMessage: String?) -> Bool { - guard let monitor = systemMonitor else { return false } - let fanCount = resolvedFanCount() + private func applyPerFanSpeeds(_ requestedSpeeds: [Int], successMessage: String?) async -> Bool { + guard !isTerminating, !Task.isCancelled, let monitor = systemMonitor else { return false } + let fanCount = await resolvedFanCount() guard fanCount > 0 else { statusMessage = helperUnavailableMessage() return false @@ -963,7 +1026,7 @@ final class FanController: ObservableObject { let perFanMax = fanID < monitor.fanMaxSpeeds.count ? monitor.fanMaxSpeeds[fanID] : maxSpeed let requested = fanID < requestedSpeeds.count ? requestedSpeeds[fanID] : (requestedSpeeds.last ?? requestedSpeeds.first ?? minSpeed) let clamped = max(perFanMin, min(perFanMax, requested)) - if !runSmcHelper(arguments: ["set", "\(fanID)", "\(clamped)"]) { + if await runSmcHelper(arguments: ["set", "\(fanID)", "\(clamped)"]) == false { allSuccess = false } } @@ -977,9 +1040,9 @@ final class FanController: ObservableObject { return allSuccess } - private func canActivatePrivilegedMode() -> Bool { + private func canActivatePrivilegedMode() async -> Bool { guard ensureHelperInstalledIfNeeded() else { return false } - guard resolvedFanCount() > 0 else { + guard await resolvedFanCount() > 0 else { statusMessage = helperUnavailableMessage() return false } @@ -988,8 +1051,8 @@ final class FanController: ObservableObject { // MARK: - Helper Execution - private func runSmcHelper(arguments: [String]) -> Bool { - let ok = helperManager.execute(arguments: arguments) + private func runSmcHelper(arguments: [String]) async -> Bool { + let ok = await helperManager.execute(arguments: arguments) if !ok, let message = helperManager.statusMessage { statusMessage = message } @@ -1004,18 +1067,24 @@ final class FanController: ObservableObject { return ok } - private func resolvedFanCount() -> Int { + private func resolvedFanCount() async -> Int { if let monitor = systemMonitor, monitor.numberOfFans > 0 { - return monitor.numberOfFans - } - - if let directCount = helperManager.readValue(key: "FNum").map(Int.init), directCount > 0 { - return directCount - } - - return SMCFanDetection.fallbackCount { key in - helperManager.readValue(key: key) != nil + return min(monitor.numberOfFans, SMCFanDetection.maximumFanCount) + } + if let count = SMCFanDetection.validatedCount(await helperManager.readValue(key: "FNum")) { + return count + } + var count = 0 + for id in 0.. String { @@ -1077,7 +1146,7 @@ final class FanController: ObservableObject { guard let self else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { self.lastAppliedSpeed = 0 - self.applyCurrentMode(force: true) + self.enqueueControl { [weak self] in await self?.applyCurrentMode(force: true) } if self.mode != .automatic { self.statusMessage = "Re-applied \(self.mode.title.lowercased()) after wake" } diff --git a/Core-Monitor/HardwareRescueDiagnostics.swift b/Core-Monitor/HardwareRescueDiagnostics.swift index f2005a1..7aa6eb4 100644 --- a/Core-Monitor/HardwareRescueDiagnostics.swift +++ b/Core-Monitor/HardwareRescueDiagnostics.swift @@ -493,7 +493,7 @@ enum HardwareRescueTextParser { } enum HardwareRescueFormatters { - static func date(_ date: Date) -> String { - date.formatted(date: .abbreviated, time: .standard) + static func date(_ date: Date, locale: Locale = AppLocaleStore.currentLocale) -> String { + date.formatted(Date.FormatStyle(date: .abbreviated, time: .standard).locale(locale)) } } diff --git a/Core-Monitor/HelperDiagnosticsExporter.swift b/Core-Monitor/HelperDiagnosticsExporter.swift index a75d167..c87d2bc 100644 --- a/Core-Monitor/HelperDiagnosticsExporter.swift +++ b/Core-Monitor/HelperDiagnosticsExporter.swift @@ -98,8 +98,8 @@ enum HelperDiagnosticsExporter { helperManager: SMCHelperManager, startupManager: StartupManager, menuBarSettings: MenuBarSettings - ) throws -> URL? { - let context = makeContext( + ) async throws -> URL? { + let context = await makeContext( helperManager: helperManager, startupManager: startupManager, menuBarSettings: menuBarSettings @@ -132,14 +132,14 @@ enum HelperDiagnosticsExporter { helperManager: SMCHelperManager, startupManager: StartupManager, menuBarSettings: MenuBarSettings - ) -> HelperDiagnosticsContext { + ) async -> HelperDiagnosticsContext { let helperLabel = HelperConfiguration.label let bundledHelperURL = Bundle.main.bundleURL .appendingPathComponent("Contents/Library/LaunchServices/\(helperLabel)") let installedHelperPath = "/Library/PrivilegedHelperTools/\(helperLabel)" let fileManager = FileManager.default let hostModelIdentifier = SystemMonitor.hostModelIdentifier() - let controlMetadata = helperManager.readControlMetadata() + let controlMetadata = await helperManager.readControlMetadata() return HelperDiagnosticsContext( generatedAt: Date(), diff --git a/Core-Monitor/HelperXPCClient.swift b/Core-Monitor/HelperXPCClient.swift new file mode 100644 index 0000000..83a9b4a --- /dev/null +++ b/Core-Monitor/HelperXPCClient.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Reuses one connection so the helper can associate fan ownership with this +/// app session. Replies, timeouts, and disconnects complete on the main actor. +@MainActor +final class HelperXPCClient { + struct Failure: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + private let makeConnection: () -> NSXPCConnection + private var connection: NSXPCConnection? + private var connectionID: UUID? + private var pending: [UUID: (String) -> Void] = [:] + private var timeouts: [UUID: Task] = [:] + + var isConnected: Bool { connection != nil } + + init(makeConnection: @escaping () -> NSXPCConnection) { + self.makeConnection = makeConnection + } + + func disconnect(reason: String = "Helper connection ended.") { + let old = connection + connection = nil + connectionID = nil + let completions = Array(pending.values) + pending.removeAll() + for timeout in timeouts.values { timeout.cancel() } + timeouts.removeAll() + old?.invalidate() + for finish in completions { finish(reason) } + } + + private func connected() -> NSXPCConnection { + if let connection { return connection } + let next = makeConnection() + let id = UUID() + next.remoteObjectInterface = NSXPCInterface(with: SMCHelperXPCProtocol.self) + let interrupted: () -> Void = { [weak self] in + Task { @MainActor in + guard self?.connectionID == id else { return } + self?.disconnect(reason: "Helper connection was interrupted.") + } + } + next.invalidationHandler = interrupted + next.interruptionHandler = interrupted + connection = next + connectionID = id + next.resume() + return next + } + + func request( + timeout: TimeInterval, + perform: (SMCHelperXPCProtocol, @escaping (Value?, String?) -> Void) -> Void + ) async throws -> Value { + try Task.checkCancellation() + let connection = connected() + let sessionID = connectionID + let requestID = UUID() + return try await withCheckedThrowingContinuation { continuation in + var completed = false + let finish: @MainActor (Value?, String?) -> Void = { [weak self] value, error in + guard !completed else { return } + completed = true + self?.pending.removeValue(forKey: requestID) + self?.timeouts.removeValue(forKey: requestID)?.cancel() + if let error { continuation.resume(throwing: Failure(message: error)) } + else if let value { continuation.resume(returning: value) } + else { continuation.resume(throwing: Failure(message: "Helper returned no result.")) } + } + pending[requestID] = { finish(nil, $0) } + timeouts[requestID] = Task { [weak self] in + do { try await Task.sleep(nanoseconds: UInt64(max(0.001, timeout) * 1_000_000_000)) } + catch { return } + guard self?.connectionID == sessionID, self?.pending[requestID] != nil else { return } + // Invalidating the session also makes the helper release its + // fans, including a write whose reply did not arrive in time. + self?.disconnect(reason: "Timed out while waiting for privileged helper.") + } + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ [weak self] error in + let message = error.localizedDescription + Task { @MainActor in + guard self?.connectionID == sessionID else { return } + self?.disconnect(reason: message) + } + }) as? SMCHelperXPCProtocol else { + disconnect(reason: "Failed to create helper connection.") + return + } + perform(proxy) { value, error in + Task { @MainActor in finish(value, error) } + } + } + } +} diff --git a/Core-Monitor/NetworkCounterSampler.swift b/Core-Monitor/NetworkCounterSampler.swift new file mode 100644 index 0000000..e53f1bc --- /dev/null +++ b/Core-Monitor/NetworkCounterSampler.swift @@ -0,0 +1,79 @@ +import Darwin +import Foundation + +struct NetworkCounter { + let sent: UInt64 + let received: UInt64 +} + +struct NetworkCounterTracker { + private var previous: [UInt16: NetworkCounter] = [:] + private var previousTime: TimeInterval? + + mutating func sample(_ counters: [UInt16: NetworkCounter], at time: TimeInterval) -> (sent: Double, received: Double) { + defer { + previous = counters + previousTime = time + } + guard let previousTime, time > previousTime else { return (0, 0) } + var sent: Double = 0 + var received: Double = 0 + for (id, value) in counters { + guard let old = previous[id] else { continue } + // A newly created/reset interface needs its own baseline. Its + // lifetime counters must not be charged to this sampling interval. + if value.sent >= old.sent { sent += Double(value.sent - old.sent) } + if value.received >= old.received { received += Double(value.received - old.received) } + } + let elapsed = time - previousTime + return (sent / elapsed, received / elapsed) + } +} + +enum NetworkCounterReader { + /// NET_RT_IFLIST2 supplies 64-bit counters; getifaddrs' if_data truncates + /// byte counts to 32 bits and can wrap repeatedly during a background sample. + static func read() -> [UInt16: NetworkCounter]? { + var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0] + for _ in 0..<3 { + var length = 0 + guard sysctl(&mib, u_int(mib.count), nil, &length, nil, 0) == 0 else { return nil } + var data = [UInt8](repeating: 0, count: length) + let result = data.withUnsafeMutableBytes { buffer in + sysctl(&mib, u_int(mib.count), buffer.baseAddress, &length, nil, 0) + } + if result != 0 { + if errno == ENOMEM { continue } + return nil + } + guard length <= data.count else { continue } + return data.withUnsafeBytes { buffer in + decode(UnsafeRawBufferPointer(rebasing: buffer.prefix(length))) + } + } + return nil + } + + static func decode(_ buffer: UnsafeRawBufferPointer) -> [UInt16: NetworkCounter]? { + var counters: [UInt16: NetworkCounter] = [:] + var offset = 0 + while offset < buffer.count { + guard buffer.count - offset >= 4 else { return nil } + let length = Int(buffer.loadUnaligned(fromByteOffset: offset, as: UInt16.self)) + guard length >= 4, length <= buffer.count - offset else { return nil } + let messageType = buffer[offset + 3] + if messageType == RTM_IFINFO2 { + guard length >= MemoryLayout.size else { return nil } + let header = buffer.loadUnaligned(fromByteOffset: offset, as: if_msghdr2.self) + if header.ifm_flags & IFF_LOOPBACK == 0 { + counters[header.ifm_index] = NetworkCounter( + sent: header.ifm_data.ifi_obytes, + received: header.ifm_data.ifi_ibytes + ) + } + } + offset += length + } + return counters + } +} diff --git a/Core-Monitor/OverviewPage.swift b/Core-Monitor/OverviewPage.swift index bd28b56..ec716a6 100644 --- a/Core-Monitor/OverviewPage.swift +++ b/Core-Monitor/OverviewPage.swift @@ -4,6 +4,7 @@ import SwiftUI /// cards. Every card opens its section. struct OverviewPage: View { @ObservedObject var systemMonitor: SystemMonitor + @Environment(\.locale) private var locale let openSection: (MonitorSection) -> Void private let columns = [GridItem(.adaptive(minimum: 210), spacing: 12)] @@ -120,7 +121,7 @@ struct OverviewPage: View { let caption: String if let percent = battery.chargePercent, battery.hasBattery { reading = "\(percent)%" - caption = BatteryDetailFormatter.powerStateDescription(for: battery) + caption = BatteryDetailFormatter.powerStateDescription(for: battery, locale: locale) } else { reading = ReadingFormat.watts(snapshot.totalSystemWatts) caption = "System power draw" diff --git a/Core-Monitor/PowerNetworkStoragePages.swift b/Core-Monitor/PowerNetworkStoragePages.swift index 483c25e..442312a 100644 --- a/Core-Monitor/PowerNetworkStoragePages.swift +++ b/Core-Monitor/PowerNetworkStoragePages.swift @@ -5,6 +5,7 @@ import SwiftUI struct PowerPage: View { @ObservedObject var systemMonitor: SystemMonitor + @Environment(\.locale) private var locale @State private var range: MonitoringTrendRange = .fiveMinutes var body: some View { @@ -55,10 +56,10 @@ struct PowerPage: View { .frame(width: 64, height: 64) VStack(alignment: .leading, spacing: 4) { - Text(BatteryDetailFormatter.powerStateDescription(for: battery)) + Text(BatteryDetailFormatter.powerStateDescription(for: battery, locale: locale)) .font(.body.weight(.medium)) - if let runtime = BatteryDetailFormatter.runtimeDescription(for: battery) { - Text(battery.isCharging ? "Full in about \(runtime)" : "About \(runtime) remaining") + if let runtime = BatteryDetailFormatter.runtimeDescription(for: battery, locale: locale) { + Text(runtime) .font(.callout) .foregroundStyle(.secondary) } @@ -72,15 +73,15 @@ struct PowerPage: View { ReadingRow("Cycle count", value: battery.cycleCount.map(String.init) ?? "Unavailable") ReadingRow( "Temperature", - value: BatteryDetailFormatter.temperatureDescription(battery.temperatureC) ?? "Unavailable" + value: BatteryDetailFormatter.temperatureDescription(battery.temperatureC, locale: locale) ?? "Unavailable" ) ReadingRow( "Voltage", - value: BatteryDetailFormatter.voltageDescription(battery.voltageV) ?? "Unavailable" + value: BatteryDetailFormatter.voltageDescription(battery.voltageV, locale: locale) ?? "Unavailable" ) ReadingRow( "Current", - value: BatteryDetailFormatter.amperageDescription(battery.amperageA) ?? "Unavailable" + value: BatteryDetailFormatter.amperageDescription(battery.amperageA, locale: locale) ?? "Unavailable" ) } } else { diff --git a/Core-Monitor/SMCHelperManager.swift b/Core-Monitor/SMCHelperManager.swift index 89ec32f..511ff93 100644 --- a/Core-Monitor/SMCHelperManager.swift +++ b/Core-Monitor/SMCHelperManager.swift @@ -15,7 +15,7 @@ import Darwin @MainActor final class SMCHelperManager: ObservableObject { - struct ControlMetadata: Equatable { + struct ControlMetadata: Equatable, Sendable { let modeKeyFormat: String let forceTestAvailable: Bool } @@ -102,6 +102,20 @@ final class SMCHelperManager: ObservableObject { private let fileManager = FileManager.default private var diagnosticsTask: Task? + private lazy var client = HelperXPCClient { [helperLabel] in + NSXPCConnection(machServiceName: helperLabel, options: .privileged) + } + + func endControlSession() { client.disconnect() } + + func renewControlLease() async -> Bool { + guard client.isConnected else { return false } + let result: ConnectionResult = await withHelperConnection(timeout: 3) { proxy, finish in + proxy.renewControlLease { renewed, error in finish(renewed.boolValue, error as String?) } + } + if case .success(let renewed) = result { return renewed } + return false + } private init() { refreshStatus() @@ -162,13 +176,16 @@ final class SMCHelperManager: ObservableObject { diagnosticsTask?.cancel() connectionState = .checking - let helperLabel = helperLabel - diagnosticsTask = Task.detached(priority: .utility) { - let outcome = Self.probeConnection(label: helperLabel) - guard Task.isCancelled == false else { return } - - await MainActor.run { - SMCHelperManager.shared.applyProbeOutcome(outcome) + diagnosticsTask = Task { @MainActor [weak self] in + guard let self else { return } + let result: ConnectionResult = await withHelperConnection(timeout: 1.5) { proxy, finish in + // Any reply establishes reachability; a fanless Mac may lack FNum. + proxy.readValue("FNum") { _, _ in finish(true, nil) } + } + guard !Task.isCancelled else { return } + switch result { + case .success: applyProbeOutcome(.reachable) + case .failure(let message): applyProbeOutcome(.failure(message)) } } } @@ -200,15 +217,16 @@ final class SMCHelperManager: ObservableObject { /// Executes the helper with the given arguments. /// Returns true on success. - func execute(arguments: [String]) -> Bool { - execute(arguments: arguments, allowInstall: true, timeout: 5) + func execute(arguments: [String]) async -> Bool { + await execute(arguments: arguments, allowInstall: true, timeout: 5) } - func executeIfInstalled(arguments: [String], timeout: TimeInterval = 5) -> Bool { - execute(arguments: arguments, allowInstall: false, timeout: timeout) + func executeIfInstalled(arguments: [String], timeout: TimeInterval = 5) async -> Bool { + await execute(arguments: arguments, allowInstall: false, timeout: timeout) } - private func execute(arguments: [String], allowInstall: Bool, timeout: TimeInterval) -> Bool { + private func execute(arguments: [String], allowInstall: Bool, timeout: TimeInterval) async -> Bool { + guard !Task.isCancelled else { return false } refreshStatus() if allowInstall { @@ -224,7 +242,8 @@ final class SMCHelperManager: ObservableObject { } } - let didExecute = executeViaBlessedXPC(arguments: arguments, timeout: timeout) + let didExecute = await executeViaBlessedXPC(arguments: arguments, timeout: timeout) + guard !Task.isCancelled else { return false } guard didExecute == false, allowInstall, shouldAttemptHelperRepair(afterFailureMessage: statusMessage) else { return didExecute } @@ -233,10 +252,11 @@ final class SMCHelperManager: ObservableObject { return false } - return executeViaBlessedXPC(arguments: arguments, timeout: timeout) + return await executeViaBlessedXPC(arguments: arguments, timeout: timeout) } - func readValue(key: String) -> Double? { + func readValue(key: String) async -> Double? { + guard !Task.isCancelled else { return nil } refreshStatus() guard fileManager.fileExists(atPath: installedHelperPath) else { @@ -244,7 +264,7 @@ final class SMCHelperManager: ObservableObject { return nil } - let result = readValueViaHelper(key: key, timeout: 5) + let result = await readValueViaHelper(key: key, timeout: 5) switch result { case .success(let value): statusMessage = nil @@ -254,11 +274,11 @@ final class SMCHelperManager: ObservableObject { statusMessage = message connectionState = .unreachable - guard shouldAttemptHelperRepair(afterFailureMessage: message), attemptRepairingStaleHelper() else { + guard !Task.isCancelled, shouldAttemptHelperRepair(afterFailureMessage: message), attemptRepairingStaleHelper() else { return nil } - switch readValueViaHelper(key: key, timeout: 5) { + switch await readValueViaHelper(key: key, timeout: 5) { case .success(let value): statusMessage = nil connectionState = .reachable @@ -271,14 +291,14 @@ final class SMCHelperManager: ObservableObject { } } - func readControlMetadata(timeout: TimeInterval = 1.0) -> ControlMetadata? { + func readControlMetadata(timeout: TimeInterval = 1.0) async -> ControlMetadata? { refreshStatus() guard fileManager.fileExists(atPath: installedHelperPath) else { return nil } - let result: ConnectionResult = withHelperConnection(timeout: timeout) { proxy, finish in + let result: ConnectionResult = await withHelperConnection(timeout: timeout) { proxy, finish in proxy.readControlMetadata { modeKeyFormat, forceTestAvailable, errorMessage in guard let modeKeyFormat else { finish(nil, errorMessage as String?) @@ -473,15 +493,15 @@ final class SMCHelperManager: ObservableObject { return "'\(escaped)'" } - private func readValueViaHelper(key: String, timeout: TimeInterval) -> ConnectionResult { - withHelperConnection(timeout: timeout, perform: { proxy, finish in + private func readValueViaHelper(key: String, timeout: TimeInterval) async -> ConnectionResult { + await withHelperConnection(timeout: timeout, perform: { proxy, finish in proxy.readValue(key) { value, errorMessage in finish(value?.doubleValue, errorMessage as String?) } }) } - private func executeViaBlessedXPC(arguments: [String], timeout: TimeInterval) -> Bool { + private func executeViaBlessedXPC(arguments: [String], timeout: TimeInterval) async -> Bool { guard !arguments.isEmpty else { statusMessage = "Helper command missing." return false @@ -497,9 +517,16 @@ final class SMCHelperManager: ObservableObject { statusMessage = "Invalid helper arguments." return false } - result = withHelperConnection(timeout: timeout) { proxy, finish in - proxy.setFanManual(fanID, rpm: rpm) { errorMessage in - finish(true, errorMessage as String?) + result = await withHelperConnection(timeout: timeout) { proxy, finish in + // Never issue manual writes to an older helper without a watchdog. + proxy.readSafetyVersion { version in + guard version.intValue >= 1 else { + finish(nil, "Reinstall the privileged helper to enable supervised fan control.") + return + } + proxy.setFanManual(fanID, rpm: rpm) { errorMessage in + finish(true, errorMessage as String?) + } } } @@ -509,7 +536,7 @@ final class SMCHelperManager: ObservableObject { statusMessage = "Invalid helper arguments." return false } - result = withHelperConnection(timeout: timeout) { proxy, finish in + result = await withHelperConnection(timeout: timeout) { proxy, finish in proxy.setFanAuto(fanID) { errorMessage in finish(true, errorMessage as String?) } @@ -520,7 +547,7 @@ final class SMCHelperManager: ObservableObject { statusMessage = "Invalid helper arguments." return false } - result = withHelperConnection(timeout: timeout) { proxy, finish in + result = await withHelperConnection(timeout: timeout) { proxy, finish in proxy.readValue(arguments[1]) { _, errorMessage in finish(true, errorMessage as String?) } @@ -696,103 +723,15 @@ final class SMCHelperManager: ObservableObject { } } - private func withHelperConnection( + private func withHelperConnection( timeout: TimeInterval, perform: (SMCHelperXPCProtocol, @escaping (Value?, String?) -> Void) -> Void - ) -> ConnectionResult { - let connection = NSXPCConnection(machServiceName: helperLabel, options: .privileged) - connection.remoteObjectInterface = NSXPCInterface(with: SMCHelperXPCProtocol.self) - - var remoteValue: Value? - var remoteError: String? - let semaphore = DispatchSemaphore(value: 0) - - connection.invalidationHandler = { - semaphore.signal() - } - connection.interruptionHandler = { - semaphore.signal() - } - connection.resume() - - guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in - remoteError = error.localizedDescription - semaphore.signal() - }) as? SMCHelperXPCProtocol else { - connection.invalidate() - return .failure("Failed to create helper connection.") - } - - perform(proxy) { value, errorMessage in - remoteValue = value - remoteError = errorMessage - semaphore.signal() - } - - let waitResult = semaphore.wait(timeout: .now() + timeout) - connection.invalidate() - - if waitResult == .timedOut { - return .failure("Timed out while waiting for privileged helper.") - } - - if let remoteError { - return .failure(Self.decorateConnectionFailure(remoteError)) - } - - guard let remoteValue else { - return .failure(Self.decorateConnectionFailure(nil)) - } - - return .success(remoteValue) - } - - private nonisolated static func probeConnection(label: String) -> ProbeOutcome { - let connection = NSXPCConnection(machServiceName: label, options: .privileged) - connection.remoteObjectInterface = NSXPCInterface(with: SMCHelperXPCProtocol.self) - - var remoteError: String? - var didReceiveReply = false - let semaphore = DispatchSemaphore(value: 0) - - connection.invalidationHandler = { - semaphore.signal() - } - connection.interruptionHandler = { - semaphore.signal() - } - connection.resume() - - guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in - remoteError = error.localizedDescription - semaphore.signal() - }) as? SMCHelperXPCProtocol else { - connection.invalidate() - return .failure("Failed to create helper connection.") - } - - proxy.readValue("FNum") { _, errorMessage in - didReceiveReply = true - remoteError = errorMessage as String? - semaphore.signal() - } - - let waitResult = semaphore.wait(timeout: .now() + 1.5) - connection.invalidate() - - if waitResult == .timedOut { - return .failure("Timed out while waiting for privileged helper.") - } - - if didReceiveReply { - return .reachable - } - - if let remoteError { - return .failure(Self.decorateConnectionFailure(remoteError)) + ) async -> ConnectionResult { + do { + return .success(try await client.request(timeout: timeout, perform: perform)) + } catch { + return .failure(Self.decorateConnectionFailure(error.localizedDescription)) } - - return .failure(Self.decorateConnectionFailure(nil)) } private nonisolated static func decorateConnectionFailure(_ rawMessage: String?) -> String { diff --git a/Core-Monitor/SMCHelperXPC.swift b/Core-Monitor/SMCHelperXPC.swift index bba50ee..52a4f69 100644 --- a/Core-Monitor/SMCHelperXPC.swift +++ b/Core-Monitor/SMCHelperXPC.swift @@ -1,6 +1,8 @@ import Foundation @objc protocol SMCHelperXPCProtocol { + nonisolated func readSafetyVersion(withReply reply: @escaping (NSNumber) -> Void) + nonisolated func renewControlLease(withReply reply: @escaping (NSNumber, NSString?) -> Void) nonisolated func setFanManual(_ fanID: Int, rpm: Int, withReply reply: @escaping (NSString?) -> Void) nonisolated func setFanAuto(_ fanID: Int, withReply reply: @escaping (NSString?) -> Void) nonisolated func readValue(_ key: String, withReply reply: @escaping (NSNumber?, NSString?) -> Void) diff --git a/Core-Monitor/SamplingSession.swift b/Core-Monitor/SamplingSession.swift new file mode 100644 index 0000000..c8d480e --- /dev/null +++ b/Core-Monitor/SamplingSession.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Main-thread lifecycle gate for work completed on a background queue. +struct SamplingSession { + private var generation: UInt64 = 0 + private var active = false + private var inFlight = false + + mutating func start() { + generation &+= 1 + active = true + inFlight = false + } + + mutating func stop() { + generation &+= 1 + active = false + inFlight = false + } + + mutating func begin() -> UInt64? { + guard active, !inFlight else { return nil } + generation &+= 1 + inFlight = true + return generation + } + + mutating func complete(_ ticket: UInt64) -> Bool { + guard active, inFlight, ticket == generation else { return false } + inFlight = false + return true + } +} diff --git a/Core-Monitor/SystemMonitor.swift b/Core-Monitor/SystemMonitor.swift index 93d0f62..8197c7d 100644 --- a/Core-Monitor/SystemMonitor.swift +++ b/Core-Monitor/SystemMonitor.swift @@ -184,8 +184,9 @@ final class SystemMonitor: ObservableObject { var downloadBytesPerSec: Double = 0 } var networkStats: NetworkStats { snapshot.networkStats } - private var previousNetworkBytes: (sent: UInt64, received: UInt64) = (0, 0) - private var previousNetworkTime: Date = Date() + private var networkCounterTracker = NetworkCounterTracker() + private var cachedNetworkStats = NetworkStats() + private var cachedCPUStats = CPUStats(usagePercent: 0, performanceCoreUsagePercent: nil, efficiencyCoreUsagePercent: nil) private var diskStatsCache = DiskStatsCache() // MARK: - History buffers (60 samples, used by menu bar popovers) @@ -254,7 +255,7 @@ final class SystemMonitor: ObservableObject { private var timer: Timer? private var keyInfoCache: [UInt32: SMCKeyData_keyInfo_t] = [:] private let samplingQueue = DispatchQueue(label: "CoreMonitor.SystemMonitorSampling", qos: .utility) - private var isSampling = false + private var samplingSession = SamplingSession() // SMC connection state is owned exclusively by the sampling queue and only // mirrored back into the @Published snapshot on the main thread. These plain // vars must never be read or written off that queue. @@ -297,7 +298,7 @@ final class SystemMonitor: ObservableObject { private let smcReadBytes: UInt8 = 5 private let smcReadKeyInfo: UInt8 = 9 private let kernelIndexSmc: UInt32 = 2 - private let maxFanProbeCount = 12 + private let maxFanProbeCount = SMCFanDetection.maximumFanCount init(privacySettings: PrivacySettings? = nil) { self.privacySettings = privacySettings ?? .shared @@ -325,7 +326,9 @@ final class SystemMonitor: ObservableObject { } func startMonitoring() { + guard !isMonitoringActive else { return } isMonitoringActive = true + samplingSession.start() supplementalSamplingState.reset() // SMC open + fan detection run inside the first sample on the sampling // queue, keeping all SMC access confined to that queue. @@ -345,6 +348,7 @@ final class SystemMonitor: ObservableObject { func stopMonitoring() { isMonitoringActive = false + samplingSession.stop() timer?.invalidate() timer = nil activitySampler.stop() @@ -435,7 +439,7 @@ final class SystemMonitor: ObservableObject { /// Detects the fan count over SMC. Must run on the sampling queue; writes /// only the queue-owned fan count, which is mirrored into the snapshot later. private func detectFans() { - if let directCount = readSMCValue(key: "FNum").map(Int.init), directCount > 0 { + if let directCount = SMCFanDetection.validatedCount(readSMCValue(key: "FNum")) { detectedFanCountOnQueue = directCount return } @@ -455,12 +459,8 @@ final class SystemMonitor: ObservableObject { } private func updateReadings() { - guard !isSampling else { return } - isSampling = true + guard let ticket = samplingSession.begin() else { return } let activeMonitoringInterval = monitoringInterval - // Captured on the main thread; the sampling queue must not read the - // @Published snapshot directly. - let carriedTopProcesses = snapshot.topProcesses samplingQueue.async { [weak self] in guard let self else { return } @@ -488,7 +488,7 @@ final class SystemMonitor: ObservableObject { let networkStats = self.readNetworkStats() let thermalState = ProcessInfo.processInfo.thermalState - var snapshot = SystemMonitorSnapshot( + let snapshot = SystemMonitorSnapshot( sampledAt: sampledAt, cpuTemperature: cpuTemperature?.average, gpuTemperature: gpuTemperature?.average, @@ -529,13 +529,19 @@ final class SystemMonitor: ObservableObject { }, networkStats: networkStats, thermalState: thermalState, - topProcesses: carriedTopProcesses, + topProcesses: .empty, hasSMCAccess: self.smcAccessibleOnQueue, lastError: self.smcLastErrorOnQueue ) DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self, self.samplingSession.complete(ticket) else { return } + var snapshot = snapshot + + // Process sampling has its own cadence and privacy lifecycle. + // Carry its current main-thread value, never an older capture. + snapshot.topProcesses = self.privacySettings.processInsightsEnabled + ? self.snapshot.topProcesses : .empty self.cpuHistory.removeFirst() self.cpuHistory.append(snapshot.cpuUsagePercent) @@ -555,7 +561,6 @@ final class SystemMonitor: ObservableObject { self.networkUploadTrend.append(snapshot.networkStats.uploadBytesPerSec, at: sampleTimestamp) self.networkDownloadTrend.append(snapshot.networkStats.downloadBytesPerSec, at: sampleTimestamp) self.snapshot = snapshot - self.isSampling = false } } } @@ -575,6 +580,7 @@ final class SystemMonitor: ObservableObject { } private func updateTopProcesses(_ topProcesses: TopProcessSnapshot) { + guard isMonitoringActive, privacySettings.processInsightsEnabled else { return } var updatedSnapshot = snapshot updatedSnapshot.topProcesses = topProcesses snapshot = updatedSnapshot @@ -648,52 +654,14 @@ final class SystemMonitor: ObservableObject { } } - // MARK: - Network throughput via getifaddrs + // MARK: - Network throughput (queue-owned 64-bit interface counters) private func readNetworkStats() -> NetworkStats { - var totalSent: UInt64 = 0 - var totalReceived: UInt64 = 0 - - var ifap: UnsafeMutablePointer? - guard getifaddrs(&ifap) == 0, let firstAddr = ifap else { return networkStats } - defer { freeifaddrs(ifap) } - - var cursor: UnsafeMutablePointer? = firstAddr - while let ifa = cursor { - let interface = ifa.pointee - - if let data = interface.ifa_data { - let name = String(cString: interface.ifa_name) - - // Skip loopback, only count physical / WiFi interfaces - if name != "lo0" { - let stats = data.assumingMemoryBound(to: if_data.self).pointee - totalSent += UInt64(stats.ifi_obytes) - totalReceived += UInt64(stats.ifi_ibytes) - } - } - - cursor = interface.ifa_next - } - - let now = Date() - let elapsed = now.timeIntervalSince(previousNetworkTime) - guard elapsed > 0, previousNetworkBytes.sent > 0 || previousNetworkBytes.received > 0 else { - previousNetworkBytes = (totalSent, totalReceived) - previousNetworkTime = now - return networkStats - } - - let sentDelta = totalSent >= previousNetworkBytes.sent ? totalSent - previousNetworkBytes.sent : 0 - let receivedDelta = totalReceived >= previousNetworkBytes.received ? totalReceived - previousNetworkBytes.received : 0 - - previousNetworkBytes = (totalSent, totalReceived) - previousNetworkTime = now - - return NetworkStats( - uploadBytesPerSec: Double(sentDelta) / elapsed, - downloadBytesPerSec: Double(receivedDelta) / elapsed - ) + guard let counters = NetworkCounterReader.read() else { return cachedNetworkStats } + let rates = networkCounterTracker.sample(counters, at: ProcessInfo.processInfo.systemUptime) + cachedNetworkStats = NetworkStats(uploadBytesPerSec: rates.sent, downloadBytesPerSec: rates.received) + return cachedNetworkStats } + // MARK: - Disk stats (via FileManager) private func readDiskStats(now: Date = Date()) -> DiskStats { diskStatsCache.read(now: now) { @@ -756,6 +724,11 @@ final class SystemMonitor: ObservableObject { } private func readCPUUsage() -> CPUStats { + cachedCPUStats = sampleCPUUsage() + return cachedCPUStats + } + + private func sampleCPUUsage() -> CPUStats { var loadInfo = host_cpu_load_info_data_t() var count = mach_msg_type_number_t(MemoryLayout.stride / MemoryLayout.stride) @@ -767,9 +740,9 @@ final class SystemMonitor: ObservableObject { guard result == KERN_SUCCESS else { return CPUStats( - usagePercent: cpuUsagePercent, - performanceCoreUsagePercent: performanceCoreUsagePercent, - efficiencyCoreUsagePercent: efficiencyCoreUsagePercent + usagePercent: cachedCPUStats.usagePercent, + performanceCoreUsagePercent: cachedCPUStats.performanceCoreUsagePercent, + efficiencyCoreUsagePercent: cachedCPUStats.efficiencyCoreUsagePercent ) } @@ -777,9 +750,9 @@ final class SystemMonitor: ObservableObject { previousCPULoadInfo = loadInfo hasPreviousCPUInfo = true return CPUStats( - usagePercent: cpuUsagePercent, - performanceCoreUsagePercent: performanceCoreUsagePercent, - efficiencyCoreUsagePercent: efficiencyCoreUsagePercent + usagePercent: cachedCPUStats.usagePercent, + performanceCoreUsagePercent: cachedCPUStats.performanceCoreUsagePercent, + efficiencyCoreUsagePercent: cachedCPUStats.efficiencyCoreUsagePercent ) } @@ -795,9 +768,9 @@ final class SystemMonitor: ObservableObject { let total = user + system + idle + nice guard total > 0 else { return CPUStats( - usagePercent: cpuUsagePercent, - performanceCoreUsagePercent: performanceCoreUsagePercent, - efficiencyCoreUsagePercent: efficiencyCoreUsagePercent + usagePercent: cachedCPUStats.usagePercent, + performanceCoreUsagePercent: cachedCPUStats.performanceCoreUsagePercent, + efficiencyCoreUsagePercent: cachedCPUStats.efficiencyCoreUsagePercent ) } @@ -824,7 +797,7 @@ final class SystemMonitor: ObservableObject { ) guard result == KERN_SUCCESS, let processorInfo else { - return (performanceCoreUsagePercent, efficiencyCoreUsagePercent) + return (cachedCPUStats.performanceCoreUsagePercent, cachedCPUStats.efficiencyCoreUsagePercent) } defer { @@ -835,13 +808,13 @@ final class SystemMonitor: ObservableObject { let sample = Array(UnsafeBufferPointer(start: processorInfo, count: Int(processorInfoCount))) let cpuCount = Int(processorCount) guard cpuCount > 0, sample.count >= cpuCount * Int(CPU_STATE_MAX) else { - return (performanceCoreUsagePercent, efficiencyCoreUsagePercent) + return (cachedCPUStats.performanceCoreUsagePercent, cachedCPUStats.efficiencyCoreUsagePercent) } if !hasPreviousProcessorInfo || previousProcessorLoadInfo.count != sample.count { previousProcessorLoadInfo = sample hasPreviousProcessorInfo = true - return (performanceCoreUsagePercent, efficiencyCoreUsagePercent) + return (cachedCPUStats.performanceCoreUsagePercent, cachedCPUStats.efficiencyCoreUsagePercent) } defer { previousProcessorLoadInfo = sample } @@ -851,7 +824,7 @@ final class SystemMonitor: ObservableObject { performanceCoreCount: SystemMonitor.performanceCoreCount(), efficiencyCoreCount: SystemMonitor.efficiencyCoreCount() ) else { - return (performanceCoreUsagePercent, efficiencyCoreUsagePercent) + return (cachedCPUStats.performanceCoreUsagePercent, cachedCPUStats.efficiencyCoreUsagePercent) } let performanceUsage = usageForProcessorRange( diff --git a/Core-Monitor/TopProcessSampler.swift b/Core-Monitor/TopProcessSampler.swift index 5d2abb4..a470f1b 100644 --- a/Core-Monitor/TopProcessSampler.swift +++ b/Core-Monitor/TopProcessSampler.swift @@ -35,7 +35,7 @@ final class TopProcessSampler { private var isRunning = false private var previousCPUTimeByPID: [pid_t: UInt64] = [:] private var previousSampleDate = Date() - private var isSampling = false + private var samplingSession = SamplingSession() init(interval: TimeInterval = 5.0, limit: Int = 4) { self.interval = interval @@ -56,6 +56,7 @@ final class TopProcessSampler { timer?.invalidate() timer = nil isRunning = true + samplingSession.start() sample() @@ -79,6 +80,9 @@ final class TopProcessSampler { timer?.invalidate() timer = nil isRunning = false + samplingSession.stop() + previousCPUTimeByPID = [:] + previousSampleDate = Date() } static func shouldRestartTimer( @@ -91,8 +95,7 @@ final class TopProcessSampler { } private func sample() { - guard !isSampling else { return } - isSampling = true + guard let ticket = samplingSession.begin() else { return } let now = Date() let elapsed = max(now.timeIntervalSince(previousSampleDate), 1) @@ -124,10 +127,9 @@ final class TopProcessSampler { let nextCPUTimeByPID = Dictionary(sampled.map { ($0.pid, $0.cpuTime) }, uniquingKeysWith: { first, _ in first }) DispatchQueue.main.async { [weak self] in - guard let self else { return } + guard let self, self.samplingSession.complete(ticket) else { return } self.previousSampleDate = now self.previousCPUTimeByPID = nextCPUTimeByPID - self.isSampling = false self.onUpdate?(snapshot) } } diff --git a/Core-Monitor/WeatherService.swift b/Core-Monitor/WeatherService.swift index 5020df1..3b4bb69 100644 --- a/Core-Monitor/WeatherService.swift +++ b/Core-Monitor/WeatherService.swift @@ -480,7 +480,7 @@ final class WeatherViewModel: ObservableObject { do { try await loadWeatherSnapshot(using: provider, location: location) } catch { - if let lastSnapshot { + if let lastSnapshot, Self.isFresh(lastSnapshot, now: Date(), maximumAge: refreshInterval) { state = .loaded(lastSnapshot) return } @@ -498,6 +498,11 @@ final class WeatherViewModel: ObservableObject { } } + static func isFresh(_ snapshot: WeatherSnapshot, now: Date, maximumAge: TimeInterval) -> Bool { + let age = now.timeIntervalSince(snapshot.updatedAt) + return age >= 0 && age < maximumAge + } + private func loadWeatherSnapshot(using provider: WeatherProviding, location: CLLocation) async throws { let snapshot = try await fetchWeatherSnapshot(using: provider, location: location) applyLoadedSnapshot(snapshot) diff --git a/Core-MonitorTests/BatteryDetailFormatterTests.swift b/Core-MonitorTests/BatteryDetailFormatterTests.swift index c8aefd3..f5e5dfd 100644 --- a/Core-MonitorTests/BatteryDetailFormatterTests.swift +++ b/Core-MonitorTests/BatteryDetailFormatterTests.swift @@ -1,7 +1,9 @@ import XCTest @testable import Core_Monitor +@MainActor final class BatteryDetailFormatterTests: XCTestCase { + private let english = Locale(identifier: "en_US") func testChargingRuntimeUsesPowerAdapterLanguage() { var info = BatteryInfo() info.hasBattery = true @@ -10,9 +12,9 @@ final class BatteryDetailFormatterTests: XCTestCase { info.timeRemainingMinutes = 95 info.source = "AC Power" - XCTAssertEqual(BatteryDetailFormatter.powerStateDescription(for: info), "Charging") - XCTAssertEqual(BatteryDetailFormatter.sourceDescription(for: info), "Power Adapter") - XCTAssertEqual(BatteryDetailFormatter.runtimeDescription(for: info), "1h 35m until full") + XCTAssertEqual(BatteryDetailFormatter.powerStateDescription(for: info, locale: english), "Charging") + XCTAssertEqual(BatteryDetailFormatter.sourceDescription(for: info, locale: english), "Power Adapter") + XCTAssertEqual(BatteryDetailFormatter.runtimeDescription(for: info, locale: english), "1h 35m until full") } func testBatteryRuntimeUsesRemainingLanguage() { @@ -23,14 +25,35 @@ final class BatteryDetailFormatterTests: XCTestCase { info.timeRemainingMinutes = 42 info.source = "Battery Power" - XCTAssertEqual(BatteryDetailFormatter.powerStateDescription(for: info), "Battery Power") - XCTAssertEqual(BatteryDetailFormatter.sourceDescription(for: info), "Internal Battery") - XCTAssertEqual(BatteryDetailFormatter.runtimeDescription(for: info), "42m remaining") + XCTAssertEqual(BatteryDetailFormatter.powerStateDescription(for: info, locale: english), "Battery Power") + XCTAssertEqual(BatteryDetailFormatter.sourceDescription(for: info, locale: english), "Internal Battery") + XCTAssertEqual(BatteryDetailFormatter.runtimeDescription(for: info, locale: english), "42m remaining") } func testFormatterUsesStablePrecisionForElectricalValues() { - XCTAssertEqual(BatteryDetailFormatter.temperatureDescription(31.26), "31.3 °C") - XCTAssertEqual(BatteryDetailFormatter.voltageDescription(12.345), "12.35 V") - XCTAssertEqual(BatteryDetailFormatter.amperageDescription(-1.234), "-1.23 A") + XCTAssertEqual(BatteryDetailFormatter.temperatureDescription(31.26, locale: english), "31.3 °C") + // Check precision without assuming a rounding rule at an exact midpoint. + XCTAssertEqual(BatteryDetailFormatter.voltageDescription(12.346, locale: english), "12.35 V") + XCTAssertEqual(BatteryDetailFormatter.amperageDescription(-1.234, locale: english), "-1.23 A") + } + + func testElectricalValuesFollowExplicitLocale() { + let locale = Locale(identifier: "de_DE") + XCTAssertEqual(BatteryDetailFormatter.temperatureDescription(31.26, locale: locale), "31,3 °C") + XCTAssertEqual(BatteryDetailFormatter.voltageDescription(12.346, locale: locale), "12,35 V") + XCTAssertEqual(BatteryDetailFormatter.amperageDescription(-1.234, locale: locale), "-1,23 A") + } + + func testBatteryLabelsAndRuntimeFollowSelectedLanguage() throws { + let locale = Locale(identifier: "sv_SE") + var info = BatteryInfo() + info.hasBattery = true + info.timeRemainingMinutes = 42 + XCTAssertEqual(BatteryDetailFormatter.sourceDescription(for: info, locale: locale), "Internt batteri") + let runtime = try XCTUnwrap(BatteryDetailFormatter.runtimeDescription(for: info, locale: locale)) + XCTAssertTrue(runtime.hasSuffix(" kvar"), runtime) + info.isCharging = true + info.timeRemainingMinutes = 0 + XCTAssertEqual(BatteryDetailFormatter.runtimeDescription(for: info, locale: locale), "Snart klart") } } diff --git a/Core-MonitorTests/HelperXPCClientTests.swift b/Core-MonitorTests/HelperXPCClientTests.swift new file mode 100644 index 0000000..8b14fee --- /dev/null +++ b/Core-MonitorTests/HelperXPCClientTests.swift @@ -0,0 +1,88 @@ +import Foundation +import XCTest +@testable import Core_Monitor + +@MainActor +final class HelperXPCClientTests: XCTestCase { + func testRequestsUseRealXPCAndReuseTheSession() async throws { + let delegate = TestHelperListener() + let listener = NSXPCListener.anonymous() + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let client = HelperXPCClient { NSXPCConnection(listenerEndpoint: listener.endpoint) } + defer { client.disconnect() } + + for _ in 0..<2 { + let count: Int = try await client.request(timeout: 3) { proxy, finish in + proxy.readValue("FNum") { value, error in finish(value?.intValue, error as String?) } + } + XCTAssertEqual(count, 2) + } + XCTAssertEqual(delegate.connectionCount, 1) + } + + func testTimeoutDoesNotBlockMainActorAndLateReplyIsIgnored() async throws { + let delegate = TestHelperListener() + let listener = NSXPCListener.anonymous() + listener.delegate = delegate + listener.resume() + defer { withExtendedLifetime(delegate) { listener.invalidate() } } + let client = HelperXPCClient { NSXPCConnection(listenerEndpoint: listener.endpoint) } + var finishRequest: ((Int?, String?) -> Void)? + let started = expectation(description: "Request started") + let pending = Task { @MainActor in + try await client.request(timeout: 0.2) { _, finish in + finishRequest = finish + started.fulfill() + } as Int + } + await fulfillment(of: [started], timeout: 1) + // Main-actor code runs while the request is still waiting. + XCTAssertTrue(client.isConnected) + do { _ = try await pending.value; XCTFail("Expected timeout") } + catch { XCTAssertTrue(error.localizedDescription.contains("Timed out")) } + XCTAssertFalse(client.isConnected) + finishRequest?(3, nil) + await Task.yield() + } + + func testDisconnectCompletesAnOutstandingRequest() async { + let delegate = TestHelperListener() + let listener = NSXPCListener.anonymous() + listener.delegate = delegate + listener.resume() + defer { withExtendedLifetime(delegate) { listener.invalidate() } } + let client = HelperXPCClient { NSXPCConnection(listenerEndpoint: listener.endpoint) } + let started = expectation(description: "Request started") + let pending = Task { @MainActor in + try await client.request(timeout: 3) { _, _ in started.fulfill() } as Int + } + await fulfillment(of: [started], timeout: 1) + client.disconnect(reason: "Test shutdown") + do { _ = try await pending.value; XCTFail("Expected disconnect") } + catch { XCTAssertEqual(error.localizedDescription, "Test shutdown") } + } +} + +private final class TestHelperListener: NSObject, NSXPCListenerDelegate { + private let lock = NSLock() + private var count = 0 + var connectionCount: Int { lock.lock(); defer { lock.unlock() }; return count } + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + lock.lock(); count += 1; lock.unlock() + connection.exportedInterface = NSXPCInterface(with: SMCHelperXPCProtocol.self) + connection.exportedObject = TestHelperSession() + connection.resume() + return true + } +} + +private final class TestHelperSession: NSObject, SMCHelperXPCProtocol { + func readSafetyVersion(withReply reply: @escaping (NSNumber) -> Void) { reply(1) } + func renewControlLease(withReply reply: @escaping (NSNumber, NSString?) -> Void) { reply(true, nil) } + func setFanManual(_ fanID: Int, rpm: Int, withReply reply: @escaping (NSString?) -> Void) { reply(nil) } + func setFanAuto(_ fanID: Int, withReply reply: @escaping (NSString?) -> Void) { reply(nil) } + func readValue(_ key: String, withReply reply: @escaping (NSNumber?, NSString?) -> Void) { reply(2, nil) } + func readControlMetadata(withReply reply: @escaping (NSString?, NSNumber?, NSString?) -> Void) { reply("F%dMd", false, nil) } +} diff --git a/Core-MonitorTests/RuntimeSafetyTests.swift b/Core-MonitorTests/RuntimeSafetyTests.swift new file mode 100644 index 0000000..1a6dff0 --- /dev/null +++ b/Core-MonitorTests/RuntimeSafetyTests.swift @@ -0,0 +1,167 @@ +import Darwin +import XCTest +@testable import Core_Monitor + +@MainActor +final class RuntimeSafetyTests: XCTestCase { + func testStoppedSessionDiscardsLateResult() throws { + var session = SamplingSession() + XCTAssertNil(session.begin()) + session.start() + let old = try XCTUnwrap(session.begin()) + session.stop() + XCTAssertFalse(session.complete(old)) + XCTAssertNil(session.begin()) + } + + func testOldCompletionCannotClearNewSessionsInFlightSample() throws { + var session = SamplingSession() + session.start() + let old = try XCTUnwrap(session.begin()) + session.stop() + session.start() + let current = try XCTUnwrap(session.begin()) + XCTAssertFalse(session.complete(old)) + XCTAssertNil(session.begin()) + XCTAssertTrue(session.complete(current)) + XCTAssertNotNil(session.begin()) + } + + func testDuplicateCompletionIsRejected() throws { + var session = SamplingSession() + session.start() + let ticket = try XCTUnwrap(session.begin()) + XCTAssertTrue(session.complete(ticket)) + XCTAssertFalse(session.complete(ticket)) + let next = try XCTUnwrap(session.begin()) + XCTAssertFalse(session.complete(ticket)) + XCTAssertTrue(session.complete(next)) + } + + func testNetworkCountsMoreThanOne32BitWrapBetweenSamples() { + var tracker = NetworkCounterTracker() + _ = tracker.sample([1: NetworkCounter(sent: 100, received: 200)], at: 1) + let bytes: UInt64 = 3 * (UInt64(UInt32.max) + 1) + let rates = tracker.sample([1: NetworkCounter(sent: 100 + bytes, received: 200 + bytes)], at: 31) + XCTAssertEqual(rates.sent, Double(bytes) / 30, accuracy: 0.001) + XCTAssertEqual(rates.received, Double(bytes) / 30, accuracy: 0.001) + } + + func testInterfaceRemovalAdditionAndResetDoNotCorruptOtherRates() { + var tracker = NetworkCounterTracker() + _ = tracker.sample([ + 1: NetworkCounter(sent: 100, received: 100), + 2: NetworkCounter(sent: 1_000, received: 1_000) + ], at: 1) + let added = tracker.sample([ + 1: NetworkCounter(sent: 200, received: 300), + 3: NetworkCounter(sent: 90_000, received: 80_000) + ], at: 2) + XCTAssertEqual(added.sent, 100) + XCTAssertEqual(added.received, 200) + let reset = tracker.sample([ + 1: NetworkCounter(sent: 1, received: 1), + 3: NetworkCounter(sent: 90_500, received: 80_600) + ], at: 3) + XCTAssertEqual(reset.sent, 500) + XCTAssertEqual(reset.received, 600) + } + + func testNetworkDecoderReads64BitCountersAndExcludesLoopback() throws { + var network = if_msghdr2() + network.ifm_msglen = UInt16(MemoryLayout.size) + network.ifm_type = UInt8(RTM_IFINFO2) + network.ifm_index = 2 + network.ifm_data.ifi_obytes = UInt64(UInt32.max) + 100 + network.ifm_data.ifi_ibytes = UInt64(UInt32.max) + 200 + var loopback = network + loopback.ifm_index = 1 + loopback.ifm_flags = IFF_LOOPBACK + var bytes = withUnsafeBytes(of: &network) { Array($0) } + bytes += withUnsafeBytes(of: &loopback) { Array($0) } + let counters = try XCTUnwrap(bytes.withUnsafeBytes(NetworkCounterReader.decode)) + XCTAssertEqual(counters.count, 1) + XCTAssertEqual(counters[2]?.sent, UInt64(UInt32.max) + 100) + XCTAssertEqual(counters[2]?.received, UInt64(UInt32.max) + 200) + } + + func testNetworkDecoderRejectsTruncatedAndZeroLengthMessages() { + let samples: [[UInt8]] = [[0], [0, 0, 0, 0], [20, 0, 0, UInt8(RTM_IFINFO2)]] + for bytes in samples { + XCTAssertNil(bytes.withUnsafeBytes(NetworkCounterReader.decode)) + } + } + + func testOnlySingleDigitFanIDsAndValidCountsAreAccepted() { + XCTAssertTrue(SMCFanDetection.supports(fanID: 0)) + XCTAssertTrue(SMCFanDetection.supports(fanID: 9)) + for id in [-1, 10, 11, Int.max] { XCTAssertFalse(SMCFanDetection.supports(fanID: id)) } + for value in [Double.nan, .infinity, -1, 0, 1.5, 11] { + XCTAssertNil(SMCFanDetection.validatedCount(value)) + } + XCTAssertEqual(SMCFanDetection.validatedCount(2), 2) + var keys: [String] = [] + XCTAssertEqual(SMCFanDetection.fallbackCount { keys.append($0); return false }, 0) + XCTAssertTrue(keys.allSatisfy { $0.utf8.count == 4 }) + } + + func testCrashExpirationRestoresEveryOwnedFan() { + var lease = FanControlLease() + let owner = UUID() + XCTAssertTrue(lease.acquire(0, owner: owner, now: 0)) + XCTAssertTrue(lease.acquire(1, owner: owner, now: 0)) + var restored: [Int] = [] + _ = lease.restoreExpired(now: FanControlLease.duration - 1) { restored.append($0) } + XCTAssertTrue(restored.isEmpty) + _ = lease.restoreExpired(now: FanControlLease.duration) { restored.append($0) } + XCTAssertEqual(restored, [0, 1]) + XCTAssertTrue(lease.fanIDs.isEmpty) + } + + func testHeartbeatKeepsUnchangedTargetsLeased() { + var lease = FanControlLease() + let owner = UUID() + _ = lease.acquire(0, owner: owner, now: 0) + XCTAssertTrue(lease.renewed(owner: owner, now: 10)) + var restored: [Int] = [] + _ = lease.restoreExpired(now: 15) { restored.append($0) } + XCTAssertTrue(restored.isEmpty) + _ = lease.restoreExpired(now: 25) { restored.append($0) } + XCTAssertEqual(restored, [0]) + } + + func testDisconnectRestoresOnlyThatClientsFansAndRetriesFailures() { + enum Failure: Error { case unavailable } + var lease = FanControlLease() + let first = UUID(), second = UUID() + _ = lease.acquire(0, owner: first, now: 0) + _ = lease.acquire(1, owner: second, now: 0) + XCTAssertFalse(lease.acquire(0, owner: second, now: 1)) + lease.expire(owner: first) + XCTAssertEqual(lease.restoreExpired(now: 2) { _ in throw Failure.unavailable }, [0]) + XCTAssertEqual(lease.fanIDs, [0, 1]) + var restored: [Int] = [] + _ = lease.restoreExpired(now: 3) { restored.append($0) } + XCTAssertEqual(restored, [0]) + XCTAssertEqual(lease.fanIDs, [1]) + XCTAssertFalse(lease.renewed(owner: first, now: 3)) + } + + func testExpiredLeaseCannotBeRevivedByLateHeartbeat() { + var lease = FanControlLease() + let owner = UUID() + _ = lease.acquire(0, owner: owner, now: 0) + XCTAssertFalse(lease.renewed(owner: owner, now: 16)) + var restored: [Int] = [] + _ = lease.restoreExpired(now: 16) { restored.append($0) } + XCTAssertEqual(restored, [0]) + } + + func testRepeatedAlertsReuseIdentityWhileDifferentRulesStaySeparate() { + let first = AlertManager.notificationIdentifier(for: .cpuTemperature) + XCTAssertEqual(first, AlertManager.notificationIdentifier(for: .cpuTemperature)) + XCTAssertNotEqual(first, AlertManager.notificationIdentifier(for: .fanTooLowUnderHeat)) + let identifiers = AlertRuleKind.allCases.map(AlertManager.notificationIdentifier) + XCTAssertEqual(Set(identifiers).count, identifiers.count) + } +} diff --git a/Core-MonitorTests/WeatherViewModelTests.swift b/Core-MonitorTests/WeatherViewModelTests.swift index a728558..2a781a5 100644 --- a/Core-MonitorTests/WeatherViewModelTests.swift +++ b/Core-MonitorTests/WeatherViewModelTests.swift @@ -188,13 +188,37 @@ final class WeatherViewModelTests: XCTestCase { XCTFail("Expected a loaded weather snapshot from the fallback provider.") } } + func testFailedRefreshDiscardsExpiredWeather() async { + let provider = RecordingWeatherProvider() + let access = MockWeatherLocationAccess(status: .authorizedAlways, currentLocation: CLLocation(latitude: 1, longitude: 1)) + let model = WeatherViewModel(provider: provider, locationAccess: access, weatherCapabilityEnabled: { true }) + await model.refreshNow() + provider.shouldFail = true + await model.refreshNow() + guard case .error = model.state else { return XCTFail("Expired weather must not appear current.") } + } + + func testFailedRefreshCanKeepRecentWeather() async { + let provider = RecordingWeatherProvider() + provider.updatedAt = Date() + let access = MockWeatherLocationAccess(status: .authorizedAlways, currentLocation: CLLocation(latitude: 1, longitude: 1)) + let model = WeatherViewModel(provider: provider, locationAccess: access, weatherCapabilityEnabled: { true }) + await model.refreshNow() + provider.shouldFail = true + await model.refreshNow() + guard case .loaded = model.state else { return XCTFail("A recent reading may survive a transient failure.") } + } + } private final class RecordingWeatherProvider: WeatherProviding { private(set) var requestedLocation: CLLocation? var onRequest: ((CLLocation) -> Void)? + var shouldFail = false + var updatedAt = Date(timeIntervalSince1970: 1_000) func currentWeather(for location: CLLocation) async throws -> WeatherSnapshot { + if shouldFail { throw FailingWeatherProvider.TestError() } requestedLocation = location onRequest?(location) return WeatherSnapshot( @@ -207,7 +231,7 @@ private final class RecordingWeatherProvider: WeatherProviding { low: 18, feelsLike: 20, humidity: 52, - updatedAt: Date(timeIntervalSince1970: 1_000) + updatedAt: updatedAt ) } } diff --git a/README.md b/README.md index b2add16..626da74 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ core-monitor can take over fan speeds through a small privileged helper, then ha 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. +manual targets are supervised by the privileged helper while the app stays connected and sends a heartbeat. a lost connection or expired lease triggers an attempt to return the fans to automatic control. reinstall the bundled helper when updating to this version. see [runtime behavior and validation](docs/development/runtime-safety.md) for compatibility and hardware-testing limits. +

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

diff --git a/Shared/FanControlLease.swift b/Shared/FanControlLease.swift new file mode 100644 index 0000000..45eb92f --- /dev/null +++ b/Shared/FanControlLease.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Serialized by the helper's controller queue. Record ownership before a +/// hardware write so even a partially failed write is covered by recovery. +struct FanControlLease { + static let duration: TimeInterval = 15 + private struct Entry { + let owner: UUID + var expiresAt: TimeInterval + } + private var entries: [Int: Entry] = [:] + + var fanIDs: [Int] { entries.keys.sorted() } + + func canControl(_ fanID: Int, owner: UUID) -> Bool { + entries[fanID] == nil || entries[fanID]?.owner == owner + } + + mutating func acquire(_ fanID: Int, owner: UUID, now: TimeInterval) -> Bool { + guard canControl(fanID, owner: owner) else { return false } + entries[fanID] = Entry(owner: owner, expiresAt: now + Self.duration) + return true + } + + mutating func renewed(owner: UUID, now: TimeInterval) -> Bool { + var renewed = false + for id in fanIDs where entries[id]?.owner == owner { + guard let entry = entries[id], entry.expiresAt > now else { continue } + entries[id]?.expiresAt = now + Self.duration + renewed = true + } + return renewed + } + + mutating func release(_ fanID: Int) { entries[fanID] = nil } + + mutating func expire(owner: UUID) { + for id in fanIDs where entries[id]?.owner == owner { entries[id]?.expiresAt = 0 } + } + + /// Keep failed restores due, so a transient SMC failure is retried. + mutating func restoreExpired(now: TimeInterval, restore: (Int) throws -> Void) -> [Int] { + var failed: [Int] = [] + for id in fanIDs where (entries[id]?.expiresAt ?? .infinity) <= now { + do { + try restore(id) + entries[id] = nil + } catch { + failed.append(id) + } + } + return failed + } +} diff --git a/Shared/SMCFanDetection.swift b/Shared/SMCFanDetection.swift index f05573c..02b263d 100644 --- a/Shared/SMCFanDetection.swift +++ b/Shared/SMCFanDetection.swift @@ -1,11 +1,24 @@ import Foundation enum SMCFanDetection { + // The SMC key format reserves one decimal character for the fan ID. + nonisolated static let maximumFanCount = 10 + + nonisolated static func supports(fanID: Int) -> Bool { + (0.. Int? { + guard let value, value.isFinite, value > 0, + value <= Double(maximumFanCount), value.rounded(.towardZero) == value else { return nil } + return Int(value) + } + /// 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 { + for fanID in 0.. Void) + nonisolated func renewControlLease(withReply reply: @escaping (NSNumber, NSString?) -> Void) nonisolated func setFanManual(_ fanID: Int, rpm: Int, withReply reply: @escaping (NSString?) -> Void) nonisolated func setFanAuto(_ fanID: Int, withReply reply: @escaping (NSString?) -> Void) nonisolated func readValue(_ key: String, withReply reply: @escaping (NSNumber?, NSString?) -> Void) diff --git a/smc-helper/main.swift b/smc-helper/main.swift index 712ec2b..c1f4812 100644 --- a/smc-helper/main.swift +++ b/smc-helper/main.swift @@ -120,7 +120,7 @@ private final class SMCController { try writeValue(key: modeKey, value: 0) try? writeValue(key: String(format: "F%dTg", fanID), value: 0) if hasForceTest(), otherFansStillManual == 0, isForceTestEnabled() { - try? writeValue(key: "Ftst", value: 0) + try writeValue(key: "Ftst", value: 0) } } @@ -279,9 +279,8 @@ private final class SMCController { } private func resolvedFanCount() -> Int { - if let directCount = try? readValue("FNum"), - Int(directCount.rounded()) > 0 { - return Int(directCount.rounded()) + if let directCount = SMCFanDetection.validatedCount(try? readValue("FNum")) { + return directCount } return SMCFanDetection.fallbackCount(keyExists: keyExists) @@ -513,14 +512,14 @@ private func printUsageAndExit() -> Never { private func validatedFanID(_ rawValue: String) throws -> Int { guard let fanID = Int(rawValue) else { - throw HelperError("Fan ID must be between 0 and 11") + throw HelperError("Fan ID must be between 0 and 9") } return try validatedFanID(fanID) } private func validatedFanID(_ fanID: Int) throws -> Int { - guard (0..<12).contains(fanID) else { - throw HelperError("Fan ID must be between 0 and 11") + guard SMCFanDetection.supports(fanID: fanID) else { + throw HelperError("Fan ID must be between 0 and 9") } return fanID } @@ -593,85 +592,153 @@ private final class HelperClientValidator { private let helperMachServiceName = Bundle.main.bundleIdentifier ?? "ventaphobia.smc-helper" -private final class SMCHelperXPCService: NSObject, NSXPCListenerDelegate, SMCHelperXPCProtocol { +private final class SMCHelperXPCService: NSObject, NSXPCListenerDelegate { private let controller = SMCController() private let clientValidator = HelperClientValidator() private let controllerQueue = DispatchQueue(label: "ventaphobia.smc-helper.smc-controller", qos: .userInitiated) + private var clients = Set() + private var leases = FanControlLease() + private var watchdog: DispatchSourceTimer? override init() { super.init() - try? controller.open() + let timer = DispatchSource.makeTimerSource(queue: controllerQueue) + timer.schedule(deadline: .now() + 1, repeating: 1) + timer.setEventHandler { [weak self] in self?.restoreExpiredLeases() } + watchdog = timer + timer.resume() } - func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { - guard let clientValidator, clientValidator.authorize(newConnection) else { - NSLog("smc-helper rejected unauthorized XPC client from pid %d", newConnection.processIdentifier) + deinit { watchdog?.cancel() } + + func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + guard let clientValidator, clientValidator.authorize(connection) else { + NSLog("smc-helper rejected unauthorized XPC client from pid %d", connection.processIdentifier) return false } - - newConnection.exportedInterface = NSXPCInterface(with: SMCHelperXPCProtocol.self) - newConnection.exportedObject = self - newConnection.resume() + let owner = UUID() + controllerQueue.async { self.clients.insert(owner) } + connection.exportedInterface = NSXPCInterface(with: SMCHelperXPCProtocol.self) + connection.exportedObject = SMCHelperClientSession(service: self, owner: owner) + connection.invalidationHandler = { [weak self] in self?.disconnect(owner) } + connection.interruptionHandler = { [weak self] in self?.disconnect(owner) } + connection.resume() return true } - func setFanManual(_ fanID: Int, rpm: Int, withReply reply: @escaping (NSString?) -> Void) { - controllerQueue.async { [controller] in + private func disconnect(_ owner: UUID) { + controllerQueue.async { + self.clients.remove(owner) + self.leases.expire(owner: owner) + self.restoreExpiredLeases() + } + } + + private func restoreExpiredLeases() { + let controller = controller + let failed = leases.restoreExpired(now: ProcessInfo.processInfo.systemUptime) { fanID in + try controller.open() + try controller.setFanAuto(fanID) + } + if !failed.isEmpty { NSLog("smc-helper will retry automatic restore for fans %@", failed.description) } + } + + func renew(owner: UUID, reply: @escaping (NSNumber, NSString?) -> Void) { + controllerQueue.async { + guard self.clients.contains(owner) else { reply(NSNumber(value: false), "Client session ended"); return } + self.restoreExpiredLeases() + reply(NSNumber(value: self.leases.renewed(owner: owner, now: ProcessInfo.processInfo.systemUptime)), nil) + } + } + + func setManual(owner: UUID, fanID: Int, rpm: Int, reply: @escaping (NSString?) -> Void) { + controllerQueue.async { do { - let validatedFanID = try validatedFanID(fanID) - let validatedRPM = try validatedRPM(rpm) - try controller.open() - try controller.setFanManual(validatedFanID, rpm: validatedRPM) + guard self.clients.contains(owner) else { throw HelperError("Client session ended") } + let fanID = try validatedFanID(fanID) + let rpm = try validatedRPM(rpm) + self.restoreExpiredLeases() + guard self.leases.acquire(fanID, owner: owner, now: ProcessInfo.processInfo.systemUptime) else { + throw HelperError("Fan is controlled by another client session") + } + do { + try self.controller.open() + try self.controller.setFanManual(fanID, rpm: rpm) + } catch { + self.leases.expire(owner: owner) + self.restoreExpiredLeases() + throw error + } reply(nil) - } catch { - reply(error.localizedDescription as NSString) - } + } catch { reply(error.localizedDescription as NSString) } } } - func setFanAuto(_ fanID: Int, withReply reply: @escaping (NSString?) -> Void) { - controllerQueue.async { [controller] in + func setAuto(owner: UUID, fanID: Int, reply: @escaping (NSString?) -> Void) { + controllerQueue.async { do { - let validatedFanID = try validatedFanID(fanID) - try controller.open() - try controller.setFanAuto(validatedFanID) + guard self.clients.contains(owner) else { throw HelperError("Client session ended") } + let fanID = try validatedFanID(fanID) + guard self.leases.canControl(fanID, owner: owner) else { + throw HelperError("Fan is controlled by another client session") + } + try self.controller.open() + try self.controller.setFanAuto(fanID) + self.leases.release(fanID) reply(nil) - } catch { - reply(error.localizedDescription as NSString) - } + } catch { reply(error.localizedDescription as NSString) } } } - func readValue(_ key: String, withReply reply: @escaping (NSNumber?, NSString?) -> Void) { - controllerQueue.async { [controller] in + func read(owner: UUID, key: String, reply: @escaping (NSNumber?, NSString?) -> Void) { + controllerQueue.async { do { - let validatedKey = try validatedSMCKey(key) - try controller.open() - let value = try controller.readValue(validatedKey) - reply(NSNumber(value: value), nil) - } catch { - reply(nil, error.localizedDescription as NSString) - } + guard self.clients.contains(owner) else { throw HelperError("Client session ended") } + let key = try validatedSMCKey(key) + try self.controller.open() + reply(NSNumber(value: try self.controller.readValue(key)), nil) + } catch { reply(nil, error.localizedDescription as NSString) } } } - func readControlMetadata(withReply reply: @escaping (NSString?, NSNumber?, NSString?) -> Void) { - controllerQueue.async { [controller] in + func metadata(owner: UUID, reply: @escaping (NSString?, NSNumber?, NSString?) -> Void) { + controllerQueue.async { do { - try controller.open() - let metadata = controller.controlMetadata() - reply( - metadata.modeKeyTemplate as NSString, - NSNumber(value: metadata.hasForceTestKey), - nil - ) - } catch { - reply(nil, nil, error.localizedDescription as NSString) - } + guard self.clients.contains(owner) else { throw HelperError("Client session ended") } + try self.controller.open() + let metadata = self.controller.controlMetadata() + reply(metadata.modeKeyTemplate as NSString, NSNumber(value: metadata.hasForceTestKey), nil) + } catch { reply(nil, nil, error.localizedDescription as NSString) } } } } +private final class SMCHelperClientSession: NSObject, SMCHelperXPCProtocol { + private let service: SMCHelperXPCService + private let owner: UUID + + init(service: SMCHelperXPCService, owner: UUID) { + self.service = service + self.owner = owner + super.init() + } + + func readSafetyVersion(withReply reply: @escaping (NSNumber) -> Void) { reply(1) } + func renewControlLease(withReply reply: @escaping (NSNumber, NSString?) -> Void) { service.renew(owner: owner, reply: reply) } + func setFanManual(_ fanID: Int, rpm: Int, withReply reply: @escaping (NSString?) -> Void) { + service.setManual(owner: owner, fanID: fanID, rpm: rpm, reply: reply) + } + func setFanAuto(_ fanID: Int, withReply reply: @escaping (NSString?) -> Void) { + service.setAuto(owner: owner, fanID: fanID, reply: reply) + } + func readValue(_ key: String, withReply reply: @escaping (NSNumber?, NSString?) -> Void) { + service.read(owner: owner, key: key, reply: reply) + } + func readControlMetadata(withReply reply: @escaping (NSString?, NSNumber?, NSString?) -> Void) { + service.metadata(owner: owner, reply: reply) + } +} + private func runCommandLineMode(arguments: [String]) -> Never { guard arguments.count >= 2 else { printUsageAndExit() } @@ -683,11 +750,7 @@ private func runCommandLineMode(arguments: [String]) -> Never { switch command { case "set": - guard arguments.count == 4 else { printUsageAndExit() } - let fanID = try validatedFanID(arguments[2]) - let rpm = try validatedRPM(arguments[3]) - try controller.setFanManual(fanID, rpm: rpm) - print("ok") + throw HelperError("Manual control requires the app's live XPC session so crash recovery can supervise it.") case "auto": guard arguments.count == 3 else { printUsageAndExit() }