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.
This commit is contained in:
166
Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Normal file
166
Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user