import Foundation import ServiceManagement import OSLog import OnyxHelperProtocol import MetricsProvider private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "FanControl") /// Die App-Seite der Lüftersteuerung. /// /// Sie darf **wünschen**, nicht bestimmen. Ob eine Drehzahl gesetzt wird, /// entscheidet allein das Sicherheitsnetz im Helfer — mit Temperaturen, die er /// selbst misst. Diese Klasse hält nur die Verbindung und den Herzschlag. @MainActor @Observable public final class FanControl: ChargeLimitControlling { public enum InstallState: Equatable { case notInstalled case requiresApproval case installed case failed(String) } public private(set) var installState: InstallState = .notInstalled public private(set) var report: FanStatusReport? public private(set) var lastError: String? private var connection: NSXPCConnection? private var heartbeatTimer: Timer? private var statusTimer: Timer? private var service: SMAppService { SMAppService.daemon(plistName: "com.scarriffleservices.onyx.helper.plist") } public init() { refreshInstallState() } // MARK: - Einrichtung public func refreshInstallState() { switch service.status { case .enabled: installState = .installed case .requiresApproval: installState = .requiresApproval case .notRegistered, .notFound: installState = .notInstalled @unknown default: installState = .notInstalled } } /// Registriert den Helfer. macOS fragt dabei nach Zustimmung. public func install() { do { try service.register() refreshInstallState() log.notice("Helfer registriert: \(String(describing: self.installState))") } catch { // `requiresApproval` kommt hier als Fehler an und ist keiner: der // Nutzer muss den Dienst nur noch in den Systemeinstellungen // freigeben. refreshInstallState() if installState != .requiresApproval { installState = .failed(error.localizedDescription) log.error("Helfer nicht registrierbar: \(error.localizedDescription, privacy: .public)") } } } /// Entfernt den Helfer wieder. Die Lüfter fallen dabei auf Automatik. public func uninstall() async { for fan in report?.fans ?? [] { setAutomatic(index: fan.index) } disconnect() try? await service.unregister() refreshInstallState() } /// Öffnet die Stelle, an der macOS den Dienst freigeben lässt. public func openApprovalSettings() { SMAppService.openSystemSettingsLoginItems() } // MARK: - Verbindung private func proxy() -> OnyxHelperProtocol? { if connection == nil { let connection = NSXPCConnection(machServiceName: OnyxHelper.machServiceName, options: .privileged) connection.remoteObjectInterface = NSXPCInterface(with: OnyxHelperProtocol.self) // In beide Richtungen prüfen: die App spricht nur mit einem Helfer, // der ebenso signiert ist wie sie. Sonst könnte ein untergeschobener // Dienst unter demselben Namen die Antworten liefern. try? connection.setCodeSigningRequirement(OnyxHelper.helperRequirement) connection.invalidationHandler = { [weak self] in Task { @MainActor in self?.connection = nil } } connection.interruptionHandler = { [weak self] in Task { @MainActor in self?.connection = nil } } connection.resume() self.connection = connection } return connection?.remoteObjectProxyWithErrorHandler { [weak self] error in Task { @MainActor in self?.lastError = error.localizedDescription log.error("Helfer nicht erreichbar: \(error.localizedDescription, privacy: .public)") } } as? OnyxHelperProtocol } private func disconnect() { heartbeatTimer?.invalidate(); heartbeatTimer = nil statusTimer?.invalidate(); statusTimer = nil connection?.invalidate(); connection = nil report = nil } /// Beginnt zu beobachten und den Herzschlag zu senden. /// /// Der Herzschlag ist die Lebensversicherung: bleibt er aus — weil Onyx /// abstürzt, beendet wird oder hängt — fallen die Lüfter im Helfer nach /// fünf Sekunden auf Automatik zurück. public func start() { refreshChargeLimit() guard installState == .installed, heartbeatTimer == nil else { return } heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.proxy()?.heartbeat { _ in } } } statusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.refreshStatus() } } refreshStatus() } public func stop() { disconnect() } private func refreshStatus() { proxy()?.fanStatus { [weak self] data in Task { @MainActor in self?.apply(data) } } } private func apply(_ data: Data?) { guard let data, let decoded = try? JSONDecoder().decode(FanStatusReport.self, from: data) else { return } report = decoded lastError = nil } // MARK: - Steuern public func setTarget(index: Int, rpm: Double) { proxy()?.setFan(index: index, targetRPM: rpm) { [weak self] data in Task { @MainActor in self?.apply(data) } } } public func setAutomatic(index: Int) { proxy()?.setFanAutomatic(index: index) { [weak self] data in Task { @MainActor in self?.apply(data) } } } // MARK: - Ladelimit /// Das eingestellte Ladelimit in Prozent. `nil` heißt: keines, oder noch /// nicht gelesen. public private(set) var chargeLimit: Int? public func refreshChargeLimit() { proxy()?.chargeLimit { [weak self] percent in Task { @MainActor in self?.chargeLimit = percent > 0 ? percent : nil } } } /// Setzt das Limit und übernimmt, was der Helfer **zurückgelesen** hat — /// nicht das, was gewünscht war. Ein Schreibvorgang, den der SMC verwirft, /// darf sich in der Oberfläche nicht als Erfolg zeigen. /// Ohne laufenden Helfer lässt sich nichts schreiben — der Regler wird /// dann angezeigt, aber gesperrt, statt still zu versagen. public var canControlCharge: Bool { installState == .installed } public func setChargeLimit(_ percent: Int) { proxy()?.setChargeLimit(percent: percent) { [weak self] result in Task { @MainActor in self?.chargeLimit = result > 0 ? result : nil } } } }