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.
167 lines
6.1 KiB
Swift
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()
|
|
}
|
|
}
|