Files
onyx/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Guido Schmit 433cdcc3dd Phase 4: Medien-Widget mit dreistufiger Kaskade
Adapter für alles inklusive Browser, AppleScript für Spotify und Musik,
Medientasten als letzte Reserve. Die Reihenfolge ist keine Vorliebe, sondern
Absicherung: Apple hat die Now-Playing-Schnittstelle in macOS 15.4 schon
einmal zugemacht. Fällt der Adapter aus, verliert das Widget seine Anzeige —
aber nicht seine Funktion. Deshalb bleiben die Transporttasten auch dann
bedienbar, wenn gar keine Metadaten da sind.

Der Fortschritt zählt zwischen den Ereignissen selbst hoch. Spike B hatte
gezeigt, dass elapsedTime nicht laufend nachgeliefert wird: ein Scrubber, der
stur den letzten Wert anzeigt, steht still, während der Titel läuft. Gerechnet
wird aus asOf und playbackRate, begrenzt auf die Titellänge — sonst zeigt die
Anzeige nach dem Ende weiter steigende Zahlen.

Teilmeldungen (diff = true) ergänzen den Stand, statt ihn zu ersetzen. Eine
Meldung mit nur der Position würde sonst Titel, Interpret und Cover löschen.

In AnyDecodable wird Bool vor Double geprüft. Andernfalls wird "playing": true
zu 1 und die Wiedergabe gilt für immer als angehalten.

Play/Pause schaltet sofort um, statt auf das nächste Ereignis zu warten. Ein
Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt an; das echte
Ereignis korrigiert den Stand ohnehin.

Der Adapter liegt als Quellcode unter Vendor/ (BSD-3, Stand in
ONYX-VERSION.txt) und wird beim Bauen kompiliert, statt als fertige Binary
eingecheckt zu werden. Er wird bewusst nicht gelinkt: geladen wird er von
/usr/bin/perl, im eigenen Prozess greift Apples Entitlement-Prüfung.

124 Tests grün.
2026-08-10 21:02:13 +02:00

167 lines
6.1 KiB
Swift

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..<newline]
buffer.removeSubrange(buffer.startIndex...newline)
guard let line = String(data: lineData, encoding: .utf8), !line.isEmpty else { continue }
restartAttempts = 0
if let updated = AdapterPayload.apply(line: line, to: current) {
current = updated
onUpdate?(updated)
} else if line.contains("\"payload\":{}") {
current = nil
onUpdate?(nil)
}
}
}
// MARK: - Steuerung
public func send(_ command: MediaCommand) {
switch command {
case .playPause: run(["send", String(Command.togglePlayPause.rawValue)])
case .next: run(["send", String(Command.next.rawValue)])
case .previous: run(["send", String(Command.previous.rawValue)])
case .seek(let position):
// Der Adapter erwartet Mikrosekunden.
run(["seek", String(Int(position * 1_000_000))])
}
}
/// Befehle laufen als eigener, kurzlebiger Prozess.
///
/// Den Datenstrom dafür zu unterbrechen wäre der falsche Weg: er würde bei
/// jedem Tastendruck neu aufgebaut, und in der Lücke fehlen Ereignisse.
private func run(_ arguments: [String]) {
guard let resources = Self.resources else { return }
let process = Process()
process.executableURL = perl
process.arguments = [resources.script.path, resources.framework.path] + arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try? process.run()
}
}