diff --git a/Kit/helpers.swift b/Kit/helpers.swift index 8cc638caaae..f1947203696 100644 --- a/Kit/helpers.swift +++ b/Kit/helpers.swift @@ -912,27 +912,37 @@ public class SMCHelper { private var connection: NSXPCConnection? = nil - public func setFanSpeed(_ id: Int, speed: Int) { - guard let helper = self.helper(nil) else { return } - helper.setFanSpeed(id: id, value: speed) { result in - if let result, !result.isEmpty { - NSLog("set fan speed: \(result)") - } - } + public func setFanSpeed(_ id: Int, speed: Int, completion: ((String?) -> Void)? = nil) { + guard let helper = self.helper(forCall: completion) else { return } + helper.setFanSpeed(id: id, value: speed) { completion?($0) } } - - public func setFanMode(_ id: Int, mode: Int) { - guard let helper = self.helper(nil) else { return } - helper.setFanMode(id: id, mode: mode) { result in - if let result, !result.isEmpty { - NSLog("set fan mode: \(result)") - } - } + + public func setFanMode(_ id: Int, mode: Int, completion: ((String?) -> Void)? = nil) { + guard let helper = self.helper(forCall: completion) else { return } + helper.setFanMode(id: id, mode: mode) { completion?($0) } } - - public func resetFanControl() { - guard let helper = self.helper(nil) else { return } - helper.resetFanControl { _ in } + + public func resetFanControl(completion: ((String?) -> Void)? = nil) { + guard let helper = self.helper(forCall: completion) else { return } + helper.resetFanControl { completion?($0) } + } + + // Per-call helper that wires the XPC errorHandler to the caller's completion + // so connection failures surface as errors instead of dropping the reply. + private func helper(forCall completion: ((String?) -> Void)?) -> HelperProtocol? { + guard let connection = self.helperConnection() else { + completion?("Fan helper not available") + return nil + } + let proxy = connection.remoteObjectProxyWithErrorHandler { error in + completion?("Fan helper unreachable: \(error.localizedDescription)") + } + guard let service = proxy as? HelperProtocol else { + completion?("Fan helper proxy unavailable") + return nil + } + service.setSMCPath(Bundle.main.path(forResource: "smc", ofType: nil)!) + return service } public func isActive() -> Bool { diff --git a/Modules/Sensors/popup.swift b/Modules/Sensors/popup.swift index 8e9b36e1b9b..a6c8704112c 100644 --- a/Modules/Sensors/popup.swift +++ b/Modules/Sensors/popup.swift @@ -85,7 +85,9 @@ internal class Popup: PopupWrapper { let fanViews = self.list.values.compactMap { $0 as? FanView } guard !fanViews.isEmpty else { return } guard fanViews.allSatisfy({ $0.fan.mode.isAutomatic }) else { return } - SMCHelper.shared.resetFanControl() + SMCHelper.shared.resetFanControl { error in + if let error { NSLog("resetFanControl error: \(error)") } + } } #endif @@ -551,9 +553,11 @@ internal class FanView: NSStackView { NotificationCenter.default.addObserver(self, selector: #selector(self.controlCallback), name: .toggleFanControl, object: nil) if let fanMode = self.fan.customMode, self.speedState && fanMode != FanMode.automatic { - SMCHelper.shared.setFanMode(fan.id, mode: fanMode.rawValue) + SMCHelper.shared.setFanMode(fan.id, mode: fanMode.rawValue) { [weak self] error in + if let error { self?.showFanError(error) } + } self.modeButtons?.setMode(FanMode(rawValue: fanMode.rawValue) ?? .automatic) - + self.setSpeed(value: Int(self.speed), then: { DispatchQueue.main.async { self.sliderValueField?.textColor = .systemBlue @@ -651,7 +655,9 @@ internal class FanView: NSStackView { if let fan = self?.fan, mode == .automatic || fan.mode != mode { self?.fan.mode = mode self?.fan.customMode = mode - SMCHelper.shared.setFanMode(fan.id, mode: mode.rawValue) + SMCHelper.shared.setFanMode(fan.id, mode: mode.rawValue) { [weak self] error in + if let error { self?.showFanError(error) } + } } self?.toggleControlView(mode == .forced) } @@ -659,9 +665,13 @@ internal class FanView: NSStackView { if let fan = self?.fan { if self?.fan.mode != .forced { self?.fan.mode = .forced - SMCHelper.shared.setFanMode(fan.id, mode: FanMode.forced.rawValue) + SMCHelper.shared.setFanMode(fan.id, mode: FanMode.forced.rawValue) { [weak self] error in + if let error { self?.showFanError(error) } + } + } + SMCHelper.shared.setFanSpeed(fan.id, speed: 0) { [weak self] error in + if let error { self?.showFanError(error) } } - SMCHelper.shared.setFanSpeed(fan.id, speed: 0) self?.fan.customSpeed = 0 } self?.toggleControlView(false) @@ -670,9 +680,13 @@ internal class FanView: NSStackView { if let fan = self?.fan { if self?.fan.mode != .forced { self?.fan.mode = .forced - SMCHelper.shared.setFanMode(fan.id, mode: FanMode.forced.rawValue) + SMCHelper.shared.setFanMode(fan.id, mode: FanMode.forced.rawValue) { [weak self] error in + if let error { self?.showFanError(error) } + } + } + SMCHelper.shared.setFanSpeed(fan.id, speed: Int(fan.maxSpeed)) { [weak self] error in + if let error { self?.showFanError(error) } } - SMCHelper.shared.setFanSpeed(fan.id, speed: Int(fan.maxSpeed)) self?.fan.customSpeed = Int(fan.maxSpeed) } self?.toggleControlView(false) @@ -783,21 +797,35 @@ internal class FanView: NSStackView { self.sliderValueField?.stringValue = "\(value) RPM" self.sliderValueField?.textColor = .secondaryLabelColor self.fan.customSpeed = value - + self.debouncer?.cancel() - + let task = DispatchWorkItem { [weak self] in - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - if let id = self?.fan.id { - SMCHelper.shared.setFanSpeed(id, speed: value) + guard let self else { return } + SMCHelper.shared.setFanSpeed(self.fan.id, speed: value) { [weak self] error in + if let error { + self?.showFanError(error) + // TODO: revert slider to previous value on failure — requires + // storing the pre-attempt speed before mutating fan.customSpeed above } then() } } - + self.debouncer = task DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3, execute: task) } + + private func showFanError(_ message: String) { + DispatchQueue.main.async { + let alert = NSAlert() + alert.messageText = localizedString("Fan control failed") + alert.informativeText = message + alert.alertStyle = .warning + alert.addButton(withTitle: localizedString("OK")) + alert.runModal() + } + } @objc private func sliderCallback(_ sender: NSSlider) { var value = sender.doubleValue @@ -847,7 +875,9 @@ internal class FanView: NSStackView { 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) + SMCHelper.shared.setFanMode(self.fan.id, mode: mode.rawValue) { [weak self] error in + if let error { self?.showFanError(error) } + } self.modeButtons?.setMode(mode) if !mode.isAutomatic { self.setSpeed(value: speed, then: { @@ -873,9 +903,10 @@ internal class FanView: NSStackView { @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 + // Best-effort reset on sleep; errors not surfaced as user can't act on them while sleeping SMCHelper.shared.setFanMode(fan.id, mode: FanMode.automatic.rawValue) self.modeButtons?.setMode(.automatic) } diff --git a/SMC/Helper/main.swift b/SMC/Helper/main.swift index ba71367a338..b92cf9611ce 100644 --- a/SMC/Helper/main.swift +++ b/SMC/Helper/main.swift @@ -122,36 +122,36 @@ extension Helper { return } let result = syncShell("\(smc) fan \(id) -m \(mode)") - + if let error = result.error, !error.isEmpty { NSLog("error set fan mode: \(error)") - completion(nil) + completion("Failed to set fan \(id) mode to \(mode) — SMC rejected write") return } - - completion(result.output) + + completion(nil) } } - + func setFanSpeed(id: Int, value: Int, completion: (String?) -> Void) { smcQueue.sync { guard let smc = self.smc else { completion("missing smc tool") return } - + let result = syncShell("\(smc) fan \(id) -v \(value)") - + if let error = result.error, !error.isEmpty { NSLog("error set fan speed: \(error)") - completion(nil) + completion("Failed to set fan \(id) speed to \(value) RPM — SMC rejected write") return } - - completion(result.output) + + completion(nil) } } - + func resetFanControl(completion: (String?) -> Void) { smcQueue.sync { guard let smc = self.smc else { @@ -161,10 +161,10 @@ extension Helper { let result = syncShell("\(smc) reset") if let error = result.error, !error.isEmpty { NSLog("error reset fan control: \(error)") - completion(nil) + completion("Failed to reset fan control — SMC rejected write") return } - completion(result.output) + completion(nil) } } diff --git a/SMC/smc.swift b/SMC/smc.swift index 0d55783f676..99cd6bf85f5 100644 --- a/SMC/smc.swift +++ b/SMC/smc.swift @@ -369,71 +369,72 @@ public class SMC { #endif } - public func setFanMode(_ id: Int, mode: FanMode) { + @discardableResult + public func setFanMode(_ id: Int, mode: FanMode) -> Bool { #if arch(arm64) if mode == .forced { - if !unlockFanControl(fanId: id) { return } + if !unlockFanControl(fanId: id) { return false } } else { let modeKey = fanModeKey(id) let targetKey = "F\(id)Tg" - + if self.getValue(modeKey) != nil { var modeVal = SMCVal_t(modeKey) let readResult = read(&modeVal) guard readResult == kIOReturnSuccess else { print(smcError("read", key: modeKey, result: readResult)) - return + return false } if modeVal.bytes[0] != 0 { modeVal.bytes[0] = 0 - if !writeWithRetry(modeVal) { return } + if !writeWithRetry(modeVal) { return false } } } - + var targetValue = SMCVal_t(targetKey) let result = read(&targetValue) guard result == kIOReturnSuccess else { print(smcError("read", key: targetKey, result: result)) - return + return false } - + let bytes = Float(0).bytes targetValue.bytes[0] = bytes[0] targetValue.bytes[1] = bytes[1] targetValue.bytes[2] = bytes[2] targetValue.bytes[3] = bytes[3] - - if !writeWithRetry(targetValue) { return } + + if !writeWithRetry(targetValue) { return false } } #else // Intel if self.getValue("F\(id)Md") != nil { var result: kern_return_t = 0 var value = SMCVal_t("F\(id)Md") - + result = read(&value) if result != kIOReturnSuccess { print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) - return + return false } - + value.bytes = [UInt8(mode.rawValue), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0)] - + result = write(value) if result != kIOReturnSuccess { print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) - return + return false } } - + let fansMode = Int(self.getValue("FS! ") ?? 0) var newMode: UInt8 = 0 - + if fansMode == 0 && id == 0 && mode == .forced { newMode = 1 } else if fansMode == 0 && id == 1 && mode == .forced { @@ -451,62 +452,64 @@ public class SMC { } else if fansMode == 3 && id == 1 && mode == .automatic { newMode = 1 } - + if fansMode == newMode { - return + return true } - + var result: kern_return_t = 0 var value = SMCVal_t("FS! ") - + result = read(&value) if result != kIOReturnSuccess { print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) - return + return false } - + value.bytes = [0, newMode, UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0)] - + result = write(value) if result != kIOReturnSuccess { print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) - return + return false } #endif + return true } - public func setFanSpeed(_ id: Int, speed: Int) { + @discardableResult + public func setFanSpeed(_ id: Int, speed: Int) -> Bool { if let maxSpeed = self.getValue("F\(id)Mx"), speed > Int(maxSpeed) { return setFanSpeed(id, speed: Int(maxSpeed)) } - + #if arch(arm64) var modeVal = SMCVal_t(fanModeKey(id)) let modeResult = read(&modeVal) guard modeResult == kIOReturnSuccess else { print("Error read fan mode: " + (String(cString: mach_error_string(modeResult), encoding: String.Encoding.ascii) ?? "unknown error")) - return + return false } if modeVal.bytes[0] != 1 { - if !unlockFanControl(fanId: id) { return } + if !unlockFanControl(fanId: id) { return false } } #endif - + var result: kern_return_t = 0 var value = SMCVal_t("F\(id)Tg") - + result = read(&value) if result != kIOReturnSuccess { print("Error read fan value: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) - return + return false } - + if value.dataType == "flt " { let bytes = Float(speed).bytes value.bytes[0] = bytes[0] @@ -519,28 +522,32 @@ public class SMC { value.bytes[2] = UInt8(0) value.bytes[3] = UInt8(0) } - + #if arch(arm64) if !writeWithRetry(value) { - return + return false } #else result = write(value) if result != kIOReturnSuccess { print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) - return + return false } #endif + return true } - public func resetFans() { + @discardableResult + public func resetFans() -> Bool { var value = SMCVal_t("FS! ") value.dataSize = 2 - + let result = write(value) if result != kIOReturnSuccess { print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) + return false } + return true } // MARK: - Apple Silicon Fan Control