diff --git a/Modules/Sensors/fanPower.swift b/Modules/Sensors/fanPower.swift new file mode 100644 index 00000000000..0d92ecca03e --- /dev/null +++ b/Modules/Sensors/fanPower.swift @@ -0,0 +1,118 @@ +// +// fanPower.swift +// Sensors +// +// Copyright © 2024 Serhiy Mytrovtsiy. All rights reserved. +// + +import IOKit +import IOKit.pwr_mgt +import Kit + +// IOKit/IOMessage.h constants — not Swift-bridged via IOKit.pwr_mgt umbrella. +private let kIOMessageSystemWillSleep: UInt32 = 0xe0000280 +private let kIOMessageSystemHasPoweredOn: UInt32 = 0xe0000300 + +// Manages fan mode/speed snapshot across sleep/wake cycles at the IOKit layer. +// NSWorkspace.didWakeNotification arrives after SMC is ready only intermittently; +// IORegisterForSystemPower fires earlier and is not tied to any UI lifecycle. +internal final class FanPowerManager { + internal static let shared = FanPowerManager() + + // Snapshot keyed by fan id: (mode, speed) captured just before sleep. + private var snapshot: [Int: (mode: FanMode, speed: Int)] = [:] + + // Fan list registered by the reader on discovery. + private var fans: [Fan] = [] + + private var notifyPort: IONotificationPortRef? + private var notifierObject: io_object_t = 0 + private var rootPort: io_connect_t = IO_OBJECT_NULL + + private init() { + self.registerIOPower() + } + + internal func register(fan: Fan) { + guard !self.fans.contains(where: { $0.id == fan.id }) else { return } + self.fans.append(fan) + } + + private func registerIOPower() { + // Retain self for the C callback context; released in deinit via IODeregisterForSystemPower. + let selfPtr = Unmanaged.passRetained(self).toOpaque() + + let callback: IOServiceInterestCallback = { refcon, _, messageType, messageArgument in + guard let refcon else { return } + let manager = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + switch messageType { + case kIOMessageSystemWillSleep: + manager.handleWillSleep() + IOAllowPowerChange(manager.rootPort, Int(bitPattern: messageArgument)) + case kIOMessageSystemHasPoweredOn: + manager.handleHasPoweredOn() + default: + break + } + } + + self.rootPort = IORegisterForSystemPower(selfPtr, &self.notifyPort, callback, &self.notifierObject) + + guard self.rootPort != IO_OBJECT_NULL, let port = self.notifyPort else { + error("FanPowerManager: IORegisterForSystemPower failed") + Unmanaged.fromOpaque(selfPtr).release() + return + } + + IONotificationPortSetDispatchQueue(port, DispatchQueue.global(qos: .utility)) + } + + private func handleWillSleep() { + guard SMCHelper.shared.isActive() else { return } + + self.snapshot.removeAll() + + for fan in self.fans { + guard let mode = fan.customMode, !mode.isAutomatic else { continue } + self.snapshot[fan.id] = (mode: mode, speed: fan.customSpeed ?? 0) + SMCHelper.shared.setFanMode(fan.id, mode: FanMode.automatic.rawValue) + } + + debug("FanPowerManager: snapshotted \(self.snapshot.count) fan(s) before sleep") + } + + private func handleHasPoweredOn() { + guard !self.snapshot.isEmpty else { return } + + // Give the SMC ~2 s to settle after wake before restoring. + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2) { [weak self] in + guard let self else { return } + guard SMCHelper.shared.isActive() else { + debug("FanPowerManager: helper not active on wake, skipping restore") + return + } + + for (id, entry) in self.snapshot { + SMCHelper.shared.setFanMode(id, mode: entry.mode.rawValue) + if !entry.mode.isAutomatic && entry.speed > 0 { + SMCHelper.shared.setFanSpeed(id, speed: entry.speed) + } + } + + debug("FanPowerManager: restored \(self.snapshot.count) fan(s) after wake") + self.snapshot.removeAll() + } + } + + deinit { + if self.notifierObject != 0 { + IODeregisterForSystemPower(&self.notifierObject) + } + if self.rootPort != IO_OBJECT_NULL { + IOServiceClose(self.rootPort) + } + if let port = self.notifyPort { + IONotificationPortDestroy(port) + } + } +} diff --git a/Modules/Sensors/main.swift b/Modules/Sensors/main.swift index 3804616d348..fba10779a87 100644 --- a/Modules/Sensors/main.swift +++ b/Modules/Sensors/main.swift @@ -49,6 +49,9 @@ public class Sensors: Module { self.popupView.setup(self.sensorsReader?.list.sensors) self.portalView.setup(self.sensorsReader?.list.sensors) self.notificationsView.setup(self.sensorsReader?.list.sensors) + self.sensorsReader?.list.sensors.compactMap({ $0 as? Fan }).forEach { + FanPowerManager.shared.register(fan: $0) + } self.settingsView.callback = { [weak self] in self?.sensorsReader?.read() diff --git a/Modules/Sensors/popup.swift b/Modules/Sensors/popup.swift index 8e9b36e1b9b..3a665751ccd 100644 --- a/Modules/Sensors/popup.swift +++ b/Modules/Sensors/popup.swift @@ -508,7 +508,6 @@ internal class FanView: NSStackView { return self.fan.value } } - private var resetModeAfterSleep: Bool = false private var controlState: Bool private var fanValue: FanValue { FanValue(rawValue: Store.shared.string(key: "Sensors_popup_fanValue", defaultValue: FanValue.percentage.rawValue)) ?? .percentage @@ -518,9 +517,6 @@ internal class FanView: NSStackView { self.edgeInsets.top + self.edgeInsets.bottom + (self.spacing*CGFloat(self.arrangedSubviews.count)) } - private var willSleepMode: FanMode? = nil // fan mode before sleep - private var willSleepSpeed: Int? = nil // fan speed before sleep - public init(_ fan: Fan, width: CGFloat, callback: @escaping (() -> Void)) { self.fan = fan self.sizeCallback = callback @@ -544,8 +540,6 @@ internal class FanView: NSStackView { self.nameAndSpeed() self.setupControls() - NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(self.wakeListener), name: NSWorkspace.didWakeNotification, object: nil) - NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(self.sleepListener), name: NSWorkspace.willSleepNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(self.syncFanSpeed), name: .syncFansControl, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(self.changeHelperState), name: .fanHelperState, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(self.controlCallback), name: .toggleFanControl, object: nil) @@ -567,7 +561,6 @@ internal class FanView: NSStackView { } deinit { - NSWorkspace.shared.notificationCenter.removeObserver(self) NotificationCenter.default.removeObserver(self, name: .syncFansControl, object: nil) NotificationCenter.default.removeObserver(self, name: .fanHelperState, object: nil) NotificationCenter.default.removeObserver(self, name: .toggleSettings, object: nil) @@ -841,45 +834,6 @@ internal class FanView: NSStackView { NotificationCenter.default.post(name: .syncFansControl, object: nil, userInfo: ["speed": Int(self.fan.maxSpeed)]) } - @objc private func wakeListener(aNotification: NSNotification) { - self.resetModeAfterSleep = true - - if self.speedState { - if let mode = self.willSleepMode, let speed = self.willSleepSpeed { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - SMCHelper.shared.setFanMode(self.fan.id, mode: mode.rawValue) - self.modeButtons?.setMode(mode) - if !mode.isAutomatic { - self.setSpeed(value: speed, then: { - DispatchQueue.main.async { - self.sliderValueField?.textColor = .systemBlue - } - }) - } - } - } - self.willSleepMode = nil - self.willSleepSpeed = nil - } - - if let value = self.fan.customSpeed, !self.fan.mode.isAutomatic { - self.setSpeed(value: value, then: { - DispatchQueue.main.async { - self.sliderValueField?.textColor = .systemBlue - } - }) - } - } - - @objc private func sleepListener(aNotification: NSNotification) { - guard SMCHelper.shared.isActive(), let mode = self.fan.customMode, !mode.isAutomatic else { return } - - self.willSleepMode = mode - self.willSleepSpeed = self.fan.customSpeed - SMCHelper.shared.setFanMode(fan.id, mode: FanMode.automatic.rawValue) - self.modeButtons?.setMode(.automatic) - } - @objc private func syncFanSpeed(_ notification: Notification) { guard self.syncState else { return } var speed = notification.userInfo?["speed"] as? Int @@ -919,15 +873,6 @@ internal class FanView: NSStackView { v.setValue(ColorValue(Double(percentage) / 100)) } - if self.resetModeAfterSleep && !value.mode.isAutomatic { - if self.sliderValueField?.stringValue != "" && self.slider?.doubleValue != value.value { - self.slider?.doubleValue = value.value - self.sliderValueField?.stringValue = "" - } - self.modeButtons?.setMode(.forced) - self.resetModeAfterSleep = false - } - self.ready = true } }) diff --git a/Stats.xcodeproj/project.pbxproj b/Stats.xcodeproj/project.pbxproj index 7ce31bc0d34..efdfcf0f91f 100644 --- a/Stats.xcodeproj/project.pbxproj +++ b/Stats.xcodeproj/project.pbxproj @@ -203,6 +203,7 @@ 9AE29AF6249A52B00071B02D /* config.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9AE29AF4249A52870071B02D /* config.plist */; }; 9AE29AFB249A53DC0071B02D /* readers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE29AF9249A53780071B02D /* readers.swift */; }; 9AE29AFC249A53DC0071B02D /* values.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE29AF7249A53420071B02D /* values.swift */; }; + 9AE29AFE249A53DC0071B02D /* fanPower.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE29AFD249A53DC0071B02D /* fanPower.swift */; }; 9AEBBE4D28D773430082A6A1 /* Dot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AEBBE4C28D773430082A6A1 /* Dot.swift */; }; 9AF9EE0924648751005D2270 /* Disk.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AF9EE0224648751005D2270 /* Disk.framework */; }; 9AF9EE0A24648751005D2270 /* Disk.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9AF9EE0224648751005D2270 /* Disk.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -714,6 +715,7 @@ 9AE29AF4249A52870071B02D /* config.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; name = config.plist; path = Modules/Sensors/config.plist; sourceTree = SOURCE_ROOT; }; 9AE29AF7249A53420071B02D /* values.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = values.swift; path = Modules/Sensors/values.swift; sourceTree = SOURCE_ROOT; }; 9AE29AF9249A53780071B02D /* readers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = readers.swift; path = Modules/Sensors/readers.swift; sourceTree = SOURCE_ROOT; }; + 9AE29AFD249A53DC0071B02D /* fanPower.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = fanPower.swift; path = Modules/Sensors/fanPower.swift; sourceTree = SOURCE_ROOT; }; 9AEBBE4C28D773430082A6A1 /* Dot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dot.swift; sourceTree = ""; }; 9AF9EE0224648751005D2270 /* Disk.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Disk.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9AF9EE0524648751005D2270 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1280,6 +1282,7 @@ children = ( 9AE29AF1249A50CD0071B02D /* main.swift */, 9AE29AF9249A53780071B02D /* readers.swift */, + 9AE29AFD249A53DC0071B02D /* fanPower.swift */, 9A58DE9D24B363D800716A9F /* popup.swift */, 5CA518372B543FE600EBCCC4 /* portal.swift */, 9A58DE9F24B363F300716A9F /* settings.swift */, @@ -2246,6 +2249,7 @@ 9AB6D03926447CAA003215A5 /* reader.m in Sources */, 9AE29AFB249A53DC0071B02D /* readers.swift in Sources */, 9AE29AFC249A53DC0071B02D /* values.swift in Sources */, + 9AE29AFE249A53DC0071B02D /* fanPower.swift in Sources */, 9A58DE9E24B363D800716A9F /* popup.swift in Sources */, 9AE29AF3249A51D70071B02D /* main.swift in Sources */, 9A58DEA024B363F300716A9F /* settings.swift in Sources */,