import Foundation import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media") /// Hält den mediaremote-adapter am Leben und liest seinen Datenstrom. /// /// Der Umweg über `/usr/bin/perl` ist kein Trick um seiner selbst willen: /// seit macOS 15.4 prüft der `mediaremoted`-Daemon Entitlements, und /// Fremdprogramme bekommen keine Now-Playing-Daten mehr. Perl ist ein /// mitgeliefertes, berechtigtes Systemprogramm; es lädt das beigelegte /// Framework und gibt die Daten zeilenweise aus. @MainActor public final class AdapterProcess { /// Befehlskennungen aus `MediaRemote.h` des Adapters. private enum Command: Int { case play = 0, pause = 1, togglePlayPause = 2, next = 4, previous = 5 } public private(set) var isRunning = false /// Wird bei jeder Änderung gerufen. `nil` heißt: es läuft nichts. public var onUpdate: ((NowPlaying?) -> Void)? private var process: Process? private var buffer = Data() private var current: NowPlaying? private var restartAttempts = 0 private var restartTimer: Timer? private let perl = URL(fileURLWithPath: "/usr/bin/perl") public init() {} /// Ob die beigelegten Dateien vorhanden sind. Fehlen sie, greift die /// nächste Stufe der Kaskade — ohne dass irgendwo ein Prozess scheitert. public static var isAvailable: Bool { resources != nil } private static var resources: (script: URL, framework: URL)? { guard let vendor = Bundle.main.resourceURL?.appendingPathComponent("Vendor") else { return nil } let script = vendor.appendingPathComponent("mediaremote-adapter.pl") let framework = vendor.appendingPathComponent("MediaRemoteAdapter.framework") guard FileManager.default.fileExists(atPath: script.path), FileManager.default.fileExists(atPath: framework.path) else { return nil } return (script, framework) } // MARK: - Lebenszyklus public func start() { guard !isRunning, let resources = Self.resources else { return } let process = Process() process.executableURL = perl process.arguments = [resources.script.path, resources.framework.path, "stream"] let pipe = Pipe() process.standardOutput = pipe process.standardError = FileHandle.nullDevice pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in let chunk = handle.availableData guard !chunk.isEmpty else { return } Task { @MainActor in self?.consume(chunk) } } process.terminationHandler = { [weak self] _ in Task { @MainActor in self?.handleTermination() } } do { try process.run() self.process = process isRunning = true log.notice("MediaRemote-Adapter gestartet") } catch { log.error("Adapter konnte nicht starten: \(error.localizedDescription, privacy: .public)") scheduleRestart() } } public func stop() { restartTimer?.invalidate() restartTimer = nil (process?.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil process?.terminationHandler = nil process?.terminate() process = nil isRunning = false } private func handleTermination() { isRunning = false process = nil // Der Adapter beendet sich auch im Normalbetrieb gelegentlich. Ein // Neustart mit wachsendem Abstand hält ihn am Leben, ohne bei einem // echten Defekt in einer Schleife zu landen. scheduleRestart() } private func scheduleRestart() { guard restartAttempts < 5 else { log.error("Adapter startet nicht mehr — Kaskade fällt auf AppleScript zurück") onUpdate?(nil) return } let delay = pow(2.0, Double(restartAttempts)) restartAttempts += 1 restartTimer?.invalidate() restartTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in MainActor.assumeIsolated { [weak self] in self?.start() } } } // MARK: - Datenstrom private func consume(_ chunk: Data) { buffer.append(chunk) // Zeilenweise: ein Lesevorgang kann mehrere Zeilen oder eine halbe // enthalten. Cover kommen base64-kodiert und sprengen einen Puffer // schnell, deshalb wird der Rest aufgehoben statt verworfen. while let newline = buffer.firstIndex(of: 0x0A) { let lineData = buffer[buffer.startIndex..