import Foundation import OSLog import OnyxHelperProtocol private let log = Logger(subsystem: "com.scarriffleservices.onyx.helper", category: "Helper") /// Der privilegierte Helfer. /// /// Läuft als root und tut genau eine Sache: Lüfterdrehzahlen setzen. Kein /// Netzwerk, keine Oberfläche, kein Dateizugriff. Je weniger hier steht, desto /// kleiner die Angriffsfläche eines Dienstes mit Systemrechten. /// /// **Er entscheidet selbst, was zulässig ist.** Die App darf einen Wunsch /// äußern; ob er ausgeführt wird, prüft ausschließlich `FanSafety` hier — mit /// Temperaturen, die der Helfer selbst misst. final class FanService: NSObject, OnyxHelperProtocol { private let smc: SMCAccess? /// Wunschdrehzahl je Lüfter. Leer heißt Automatik. private var requests: [Int: FanRequest] = [:] private var lastHeartbeat = Date.distantPast private var lastDecision: [Int: FanDecision] = [:] private let queue = DispatchQueue(label: "com.scarriffleservices.onyx.helper.fans") private lazy var temperatureKeys: [String] = smc?.keys(withPrefix: "Tp") ?? [] private lazy var fanCount: Int = { guard let value = smc?.float("FNum") else { return Int(smc?.uint8("FNum") ?? 0) } return Int(value) }() override init() { smc = SMCAccess() super.init() startControlLoop() } // MARK: - Regelschleife /// Läuft ununterbrochen, solange der Helfer lebt. /// /// Die Entscheidung wird **jede Sekunde neu** getroffen, nicht nur beim /// Setzen. Nur so greifen Wachhund und Temperaturwächter auch dann, wenn /// von der App nie wieder etwas kommt. private func startControlLoop() { queue.async { [weak self] in let timer = Timer(timeInterval: 1, repeats: true) { _ in self?.apply() } RunLoop.current.add(timer, forMode: .common) RunLoop.current.run() } } private func apply() { guard let smc, fanCount > 0 else { return } let hottest = hottestTemperature() for index in 0.. Double? { guard let smc, !temperatureKeys.isEmpty else { return nil } return temperatureKeys.compactMap { key -> Double? in guard let value = smc.float(key), value > 5, value < 150 else { return nil } return value }.max() } /// Beim Beenden alles zurückstellen. /// /// Ohne das blieben die Lüfter nach einem Abmelden oder Neustart des /// Dienstes auf ihrem letzten Wert stehen — bis zum nächsten Neustart des /// Rechners. func restoreAutomatic() { guard let smc else { return } for index in 0.. Void) { reply(OnyxHelper.version) } func heartbeat(reply: @escaping (Bool) -> Void) { lastHeartbeat = Date() reply(true) } func fanStatus(reply: @escaping (Data?) -> Void) { reply(report()) } func setFan(index: Int, targetRPM: Double, reply: @escaping (Data?) -> Void) { lastHeartbeat = Date() requests[index] = FanRequest(targetRPM: targetRPM) apply() reply(report()) } func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void) { lastHeartbeat = Date() requests[index] = nil apply() reply(report()) } private func report() -> Data? { guard let smc else { return nil } let fans = (0.. FanStatus in let decision = lastDecision[index] var reason: String? var manual = false if case .manual = decision { manual = true } if case .automatic(let value) = decision { reason = value.rawValue } return FanStatus(index: index, currentRPM: smc.float("F\(index)Ac") ?? 0, targetRPM: smc.float("F\(index)Tg") ?? 0, limits: FanLimits(minimum: smc.float("F\(index)Mn") ?? 0, maximum: smc.float("F\(index)Mx") ?? 0), isManual: manual, automaticReason: reason) } return try? JSONEncoder().encode( FanStatusReport(fans: fans, hottestCelsius: hottestTemperature(), helperVersion: OnyxHelper.version)) } } // MARK: - XPC final class Listener: NSObject, NSXPCListenerDelegate { let service = FanService() func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { // Ohne diese Prüfung könnte **jedes** Programm auf dem Rechner die // Lüfter steuern: der Mach-Dienst eines root-Daemons ist systemweit // sichtbar, und wer ihn anspricht, spricht mit root. do { try connection.setCodeSigningRequirement(OnyxHelper.clientRequirement) } catch { log.error("Verbindung abgelehnt: Signaturanforderung nicht setzbar") return false } connection.exportedInterface = NSXPCInterface(with: OnyxHelperProtocol.self) connection.exportedObject = service connection.resume() return true } } let listener = NSXPCListener(machServiceName: OnyxHelper.machServiceName) let delegate = Listener() listener.delegate = delegate listener.resume() // Beim Beenden — auch durch launchd — die Lüfter freigeben. for signalNumber in [SIGTERM, SIGINT] { signal(signalNumber, SIG_IGN) let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main) source.setEventHandler { delegate.service.restoreAutomatic() exit(0) } source.resume() } log.notice("Onyx-Helfer bereit") RunLoop.main.run()