macOS bindet einen privilegierten Dienst beim Anmelden an das Programm, das ihn angemeldet hat. Zieht das Programm um — aus „Downloads" nach „Programme", oder vom Bauverzeichnis in ein installiertes Bundle —, ist das ein anderes Bundle. Der alte Dienst läuft weiter und redet nicht mit dem neuen Programm. Von außen ist das die verwirrendste Kombination, die es gibt: der Zustand meldet „eingerichtet", und trotzdem kommt bei jedem Zugriff „Kommunikation mit der Hilfs-App nicht möglich". Ohne Reparatur bleibt nur, den Helfer von Hand zu entfernen und neu einzurichten — und darauf muss man erst kommen. Scheitert die Verbindung, während der Zustand „eingerichtet" meldet, meldet die App den Dienst jetzt einmal ab und wieder an. Einmal je Programmlauf: öfter wäre eine Schleife. Danach steht er auf „wartet auf Freigabe" — das Verschieben eines Programms mit Root-Dienst darf niemand still hinnehmen, auch diese App nicht. Dazu ein Fehler im DMG-Bau, der beim Nachbauen auffiel: war ein gleichnamiges Volume noch eingehängt, hängte macOS das neue als „Onyx 1" ein, während das AppleScript weiter „Onyx" einrichtete. Das Abbild kam ohne Fenstergestaltung heraus, ohne dass irgendetwas fehlschlug. Jetzt wird vorher abgehängt und der Einhängepunkt aus der Ausgabe gelesen statt geraten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
9.0 KiB
Swift
233 lines
9.0 KiB
Swift
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: ChargeLimitReading {
|
|
|
|
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?
|
|
/// Ob die Registrierung in diesem Programmlauf schon einmal erneuert wurde.
|
|
/// Einmal ist Selbstheilung, immer wieder wäre eine Schleife.
|
|
private var hasRepairedRegistration = false
|
|
|
|
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
|
|
|
|
/// Die XPC-Verbindung.
|
|
///
|
|
/// **Jeder Rückruf hier ist `@Sendable`, und das ist kein Feinschliff.**
|
|
/// Ohne die Auszeichnung erbt der Abschluss die MainActor-Isolation dieser
|
|
/// Methode. XPC ruft ihn aber auf seiner eigenen Warteschlange, Swift 6
|
|
/// prüft das zur Laufzeit — und beendet den Prozess:
|
|
///
|
|
/// Thread 8: com.apple.NSXPCConnection.m-user…onyx.helper
|
|
/// swift_task_isCurrentExecutorWithFlags
|
|
/// closure #3 in FanControl.proxy()
|
|
/// EXC_BREAKPOINT
|
|
///
|
|
/// Das `Task { @MainActor in … }` im Rumpf half nicht: die Prüfung
|
|
/// geschieht beim **Betreten** des Abschlusses, nicht beim Zugriff.
|
|
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 = { @Sendable [weak self] in
|
|
Task { @MainActor in self?.connection = nil }
|
|
}
|
|
connection.interruptionHandler = { @Sendable [weak self] in
|
|
Task { @MainActor in self?.connection = nil }
|
|
}
|
|
connection.resume()
|
|
self.connection = connection
|
|
}
|
|
return connection?.remoteObjectProxyWithErrorHandler { @Sendable [weak self] error in
|
|
Task { @MainActor in
|
|
self?.lastError = error.localizedDescription
|
|
log.error("Helfer nicht erreichbar: \(error.localizedDescription, privacy: .public)")
|
|
await self?.repairRegistration()
|
|
}
|
|
} as? OnyxHelperProtocol
|
|
}
|
|
|
|
/// Meldet den Helfer ab und wieder an.
|
|
///
|
|
/// **Der Fall, für den das da ist: die App ist umgezogen.** Aus dem
|
|
/// Ordner „Downloads" nach „Programme", oder vom Bauverzeichnis in ein
|
|
/// installiertes Bundle. macOS bindet den privilegierten Dienst beim
|
|
/// Anmelden an das Programm, das ihn angemeldet hat — nach dem Umzug ist
|
|
/// das ein anderes Bundle. Der alte Dienst läuft weiter, redet aber nicht
|
|
/// mit dem neuen Programm.
|
|
///
|
|
/// Von außen ist das die verwirrendste Kombination, die es gibt: der
|
|
/// Zustand meldet „eingerichtet", und trotzdem kommt bei jedem Zugriff
|
|
/// „Kommunikation nicht möglich". Ohne diese Reparatur bleibt nur, den
|
|
/// Helfer von Hand zu entfernen und neu einzurichten — und darauf muss
|
|
/// man erst einmal kommen.
|
|
///
|
|
/// Danach steht der Dienst auf „wartet auf Freigabe": das Verschieben
|
|
/// eines Programms mit Root-Dienst darf niemand still hinnehmen, auch
|
|
/// diese App nicht.
|
|
private func repairRegistration() async {
|
|
guard !hasRepairedRegistration, installState == .installed else { return }
|
|
hasRepairedRegistration = true
|
|
log.notice("Helfer nicht erreichbar — Registrierung wird erneuert")
|
|
|
|
disconnect()
|
|
try? await service.unregister()
|
|
// launchd räumt den alten Dienst nicht im selben Atemzug ab.
|
|
try? await Task.sleep(for: .seconds(1))
|
|
install()
|
|
}
|
|
|
|
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 { @Sendable [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) { @Sendable [weak self] data in
|
|
Task { @MainActor in self?.apply(data) }
|
|
}
|
|
}
|
|
|
|
public func setAutomatic(index: Int) {
|
|
proxy()?.setFanAutomatic(index: index) { @Sendable [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 { @Sendable [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 kommt kein Wert — dann steht ein Strich da statt
|
|
/// einer Zahl, die es nicht gibt.
|
|
public var canReadCharge: Bool { installState == .installed }
|
|
}
|