From 5a0f76ecf704aa3925e89ec924fba4987e2d7f26 Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:14:45 +0200 Subject: [PATCH 1/4] Add supervised fan sessions and correct monitoring and UI lifecycle handling --- Core-Monitor/AlertManager.swift | 6 +- Core-Monitor/BatteryDetailFormatter.swift | 67 ++--- Core-Monitor/BatteryDetails.xcstrings | 251 +++++++++++++++++++ Core-Monitor/Core_MonitorApp.swift | 1 - Core-Monitor/DiskProcessSampler.swift | 12 +- Core-Monitor/FanController.swift | 215 ++++++++++------ Core-Monitor/HardwareRescueDiagnostics.swift | 4 +- Core-Monitor/HelperDiagnosticsExporter.swift | 8 +- Core-Monitor/HelperXPCClient.swift | 98 ++++++++ Core-Monitor/NetworkCounterSampler.swift | 78 ++++++ Core-Monitor/SMCHelperManager.swift | 120 ++++----- Core-Monitor/SMCHelperXPC.swift | 2 + Core-Monitor/SamplingSession.swift | 32 +++ Core-Monitor/SystemMonitor.swift | 115 ++++----- Core-Monitor/TopProcessSampler.swift | 12 +- Core-Monitor/WeatherService.swift | 7 +- Shared/FanControlLease.swift | 54 ++++ Shared/SMCFanDetection.swift | 15 +- scripts/tests/check_helper_lifetime.sh | 1 + smc-helper/SMCHelperXPC.swift | 2 + smc-helper/main.swift | 181 ++++++++----- 21 files changed, 957 insertions(+), 324 deletions(-) create mode 100644 Core-Monitor/BatteryDetails.xcstrings create mode 100644 Core-Monitor/HelperXPCClient.swift create mode 100644 Core-Monitor/NetworkCounterSampler.swift create mode 100644 Core-Monitor/SamplingSession.swift create mode 100644 Shared/FanControlLease.swift diff --git a/Core-Monitor/AlertManager.swift b/Core-Monitor/AlertManager.swift index 87c1fab7..811461c3 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 803c8c46..bcafe01e 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 00000000..a4cd26c9 --- /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 bb1bd273..09b1dec3 100644 --- a/Core-Monitor/Core_MonitorApp.swift +++ b/Core-Monitor/Core_MonitorApp.swift @@ -485,7 +485,6 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { coordinator: coordinator, startupManager: startupManager ) { [weak self] in - self?.dashboardController = nil self?.restoreAccessoryActivationPolicyIfNeeded() } debugLaunch("dashboardController created") diff --git a/Core-Monitor/DiskProcessSampler.swift b/Core-Monitor/DiskProcessSampler.swift index 200c9acd..9a9cac65 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 f69365c9..cd7159f3 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 f2005a16..7aa6eb4d 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 a75d167f..c87d2bca 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 00000000..83a9b4a3 --- /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 00000000..e78e167b --- /dev/null +++ b/Core-Monitor/NetworkCounterSampler.swift @@ -0,0 +1,78 @@ +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 + } + 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/SMCHelperManager.swift b/Core-Monitor/SMCHelperManager.swift index 89ec32f9..4f075c1c 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() @@ -200,15 +214,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 +239,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 +249,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 +261,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 +271,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 +288,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 +490,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 +514,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 +533,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 +544,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,55 +720,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)) + ) async -> ConnectionResult { + do { + return .success(try await client.request(timeout: timeout, perform: perform)) + } catch { + return .failure(Self.decorateConnectionFailure(error.localizedDescription)) } - - return .success(remoteValue) } private nonisolated static func probeConnection(label: String) -> ProbeOutcome { diff --git a/Core-Monitor/SMCHelperXPC.swift b/Core-Monitor/SMCHelperXPC.swift index bba50ee4..52a4f692 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 00000000..cc28f062 --- /dev/null +++ b/Core-Monitor/SamplingSession.swift @@ -0,0 +1,32 @@ +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 } + 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 93d0f622..8197c7dc 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 5d2abb4a..a470f1bd 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 5020df1f..3b4bb691 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/Shared/FanControlLease.swift b/Shared/FanControlLease.swift new file mode 100644 index 00000000..45eb92f2 --- /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 f05573c0..02b263d7 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 712ec2b2..c1f48127 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() } From cbbc35e0a74ed7a0e2f8ac79ea311f1b96c34573 Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:20:29 +0200 Subject: [PATCH 2/4] Cover lease recovery, asynchronous XPC, sampling lifecycle, and localized output --- Core-Monitor.xcodeproj/project.pbxproj | 12 ++ Core-Monitor/SMCHelperManager.swift | 65 ++----- .../BatteryDetailFormatterTests.swift | 21 +++ Core-MonitorTests/HelperXPCClientTests.swift | 88 ++++++++++ Core-MonitorTests/RuntimeSafetyTests.swift | 164 ++++++++++++++++++ Core-MonitorTests/WeatherViewModelTests.swift | 26 ++- 6 files changed, 320 insertions(+), 56 deletions(-) create mode 100644 Core-MonitorTests/HelperXPCClientTests.swift create mode 100644 Core-MonitorTests/RuntimeSafetyTests.swift diff --git a/Core-Monitor.xcodeproj/project.pbxproj b/Core-Monitor.xcodeproj/project.pbxproj index 377e9b9f..c77cae8d 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/SMCHelperManager.swift b/Core-Monitor/SMCHelperManager.swift index 4f075c1c..511ff93d 100644 --- a/Core-Monitor/SMCHelperManager.swift +++ b/Core-Monitor/SMCHelperManager.swift @@ -176,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)) } } } @@ -731,54 +734,6 @@ final class SMCHelperManager: ObservableObject { } } - 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)) - } - - return .failure(Self.decorateConnectionFailure(nil)) - } - private nonisolated static func decorateConnectionFailure(_ rawMessage: String?) -> String { if let signingIssue = currentAppSigningIssue() { return signingIssue diff --git a/Core-MonitorTests/BatteryDetailFormatterTests.swift b/Core-MonitorTests/BatteryDetailFormatterTests.swift index c8aefd3b..14d59ee0 100644 --- a/Core-MonitorTests/BatteryDetailFormatterTests.swift +++ b/Core-MonitorTests/BatteryDetailFormatterTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import Core_Monitor +@MainActor final class BatteryDetailFormatterTests: XCTestCase { func testChargingRuntimeUsesPowerAdapterLanguage() { var info = BatteryInfo() @@ -33,4 +34,24 @@ final class BatteryDetailFormatterTests: XCTestCase { XCTAssertEqual(BatteryDetailFormatter.voltageDescription(12.345), "12.35 V") XCTAssertEqual(BatteryDetailFormatter.amperageDescription(-1.234), "-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.345, 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 00000000..8b14fee9 --- /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 00000000..26909de4 --- /dev/null +++ b/Core-MonitorTests/RuntimeSafetyTests.swift @@ -0,0 +1,164 @@ +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)) + } + + 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 a7285581..2a781a58 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 ) } } From aee5ba2b51c6190c18ec2e9748b64ce1c6d64a2f Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:25:28 +0200 Subject: [PATCH 3/4] Follow live locale changes and document runtime recovery limits --- Core-Monitor/NetworkCounterSampler.swift | 1 + Core-Monitor/OverviewPage.swift | 3 ++- Core-Monitor/PowerNetworkStoragePages.swift | 13 +++++------ Core-Monitor/SamplingSession.swift | 1 + Core-MonitorTests/RuntimeSafetyTests.swift | 3 +++ docs/development/runtime-safety.md | 24 +++++++++++++++++++++ 6 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 docs/development/runtime-safety.md diff --git a/Core-Monitor/NetworkCounterSampler.swift b/Core-Monitor/NetworkCounterSampler.swift index e78e167b..e53f1bc4 100644 --- a/Core-Monitor/NetworkCounterSampler.swift +++ b/Core-Monitor/NetworkCounterSampler.swift @@ -46,6 +46,7 @@ enum NetworkCounterReader { if errno == ENOMEM { continue } return nil } + guard length <= data.count else { continue } return data.withUnsafeBytes { buffer in decode(UnsafeRawBufferPointer(rebasing: buffer.prefix(length))) } diff --git a/Core-Monitor/OverviewPage.swift b/Core-Monitor/OverviewPage.swift index bd28b561..ec716a6b 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 483c25ee..442312ae 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/SamplingSession.swift b/Core-Monitor/SamplingSession.swift index cc28f062..c8d480ed 100644 --- a/Core-Monitor/SamplingSession.swift +++ b/Core-Monitor/SamplingSession.swift @@ -20,6 +20,7 @@ struct SamplingSession { mutating func begin() -> UInt64? { guard active, !inFlight else { return nil } + generation &+= 1 inFlight = true return generation } diff --git a/Core-MonitorTests/RuntimeSafetyTests.swift b/Core-MonitorTests/RuntimeSafetyTests.swift index 26909de4..1a6dff0f 100644 --- a/Core-MonitorTests/RuntimeSafetyTests.swift +++ b/Core-MonitorTests/RuntimeSafetyTests.swift @@ -33,6 +33,9 @@ final class RuntimeSafetyTests: XCTestCase { 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() { diff --git a/docs/development/runtime-safety.md b/docs/development/runtime-safety.md new file mode 100644 index 00000000..5842aabe --- /dev/null +++ b/docs/development/runtime-safety.md @@ -0,0 +1,24 @@ +# Runtime behavior and validation + +## Fan control + +Manual and managed fan targets belong to a signed app's persistent XPC connection. The helper records a 15-second lease before writing each fan. The app renews leases every two seconds, including when the target RPM has not changed. Connection loss immediately expires that client's leases. The helper checks expiry once per second on its serialized hardware queue and attempts to restore automatic control; failed restores stay queued for retry. + +Update the app and reinstall the bundled helper together. The app checks the helper's safety protocol before issuing manual writes. Older helpers cannot pass that check. Standalone CLI `set` is no longer supported because a process that exits cannot supervise a manual override; CLI `read` and `auto` remain available. Supported fan IDs are 0–9, matching the existing single-decimal-character SMC key format. + +The watchdog depends on a running helper and responsive hardware calls. It does not claim recovery from a helper/system crash or a permanently failing SMC. Signing, installation, sleep/wake, real fan restoration, and thermal behavior still require validation on a fan-equipped Apple Silicon Mac. Automated tests cover ownership, disconnect/expiry, renewal, failed-restore retries, and asynchronous XPC using an anonymous test listener without privileged hardware access. + +## Monitoring + +- Stopping or restarting a sampling session invalidates outstanding results. Turning off process insights also prevents old process results from repopulating the snapshot. +- CPU fallback values and network baselines belong to the sampling queue. Published UI snapshots are accessed on the main thread. +- Network rates use 64-bit counters per interface. Newly added or reset interfaces establish a new baseline; one interface disappearing does not zero the others' rates. + +## Windows, language, weather, and alerts + +- Closing the dashboard preserves its controller and frame for reopening. Existing off-screen recovery still applies after display changes. +- Battery descriptions and number/date formatting follow the selected app locale. Existing translations are reused; the new battery phrases include English, Swedish, German, French, and Spanish. Other missing translations use their English source text. Duration units use the platform formatter. +- A failed weather refresh may retain a cached reading only while it is younger than the configured refresh interval. Older readings give way to an error or an available fallback provider. +- Repeated desktop notifications use one identifier per alert rule. In-app history, severity changes, and repeat cooldowns retain their existing behavior. + +Physical fan tests and manual visual inspection are not replaced by the automated suite. From c601b0e7ea136d563a61298edaf37608d9bb0b15 Mon Sep 17 00:00:00 2001 From: SmokeStudios <290871035+OGSmokeStudios@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:28:49 +0200 Subject: [PATCH 4/4] Preserve close behavior and make formatting checks locale deterministic --- Core-Monitor/Core_MonitorApp.swift | 5 ++++- .../BatteryDetailFormatterTests.swift | 22 ++++++++++--------- README.md | 2 ++ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/Core-Monitor/Core_MonitorApp.swift b/Core-Monitor/Core_MonitorApp.swift index 09b1dec3..a902d997 100644 --- a/Core-Monitor/Core_MonitorApp.swift +++ b/Core-Monitor/Core_MonitorApp.swift @@ -485,7 +485,10 @@ final class CoreMonitorApplicationDelegate: NSObject, NSApplicationDelegate { coordinator: coordinator, startupManager: startupManager ) { [weak self] in - 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-MonitorTests/BatteryDetailFormatterTests.swift b/Core-MonitorTests/BatteryDetailFormatterTests.swift index 14d59ee0..f5e5dfdd 100644 --- a/Core-MonitorTests/BatteryDetailFormatterTests.swift +++ b/Core-MonitorTests/BatteryDetailFormatterTests.swift @@ -3,6 +3,7 @@ import XCTest @MainActor final class BatteryDetailFormatterTests: XCTestCase { + private let english = Locale(identifier: "en_US") func testChargingRuntimeUsesPowerAdapterLanguage() { var info = BatteryInfo() info.hasBattery = true @@ -11,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() { @@ -24,21 +25,22 @@ 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.345, locale: locale), "12,35 V") + XCTAssertEqual(BatteryDetailFormatter.voltageDescription(12.346, locale: locale), "12,35 V") XCTAssertEqual(BatteryDetailFormatter.amperageDescription(-1.234, locale: locale), "-1,23 A") } diff --git a/README.md b/README.md index b2add166..626da748 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