Der Absturzbericht war eindeutig:
Thread 8: com.apple.NSXPCConnection.m-user…onyx.helper
swift_task_isCurrentExecutorWithFlags
closure #3 in FanControl.proxy()
EXC_BREAKPOINT
Die Rückrufe der XPC-Verbindung erben die MainActor-Isolation der Methode,
in der sie stehen. XPC ruft sie aber auf seiner eigenen Warteschlange auf,
Swift 6 prüft das zur Laufzeit und beendet den Prozess. Das
`Task { @MainActor in … }` im Rumpf half nicht: die Prüfung geschieht beim
Betreten des Abschlusses, nicht beim Zugriff.
Alle sieben Rückrufe sind jetzt `@Sendable`. Und weil das eine Fehlerklasse
ist und kein Einzelfall, dieselbe Behandlung für die übrigen Stellen, an
denen ein MainActor-Typ einen Abschluss an eine Systemschnittstelle gibt:
Papierkorb, Vorschaubilder, Adapter-Ende, Darwin-Nachricht.
Dazu drei Dinge aus dem Bericht von eben:
Der Linksklick aufs Menüleistensymbol fuhr das Panel aus — und ging dabei
als Fixieren durch. Danach stand das Panel offen und reagierte auf nichts
mehr. Das war schlechter als das Problem, das es lösen sollte. Ein
Statuselement zeigt bei einem Klick sein Menü; alles andere überrascht.
„Panel öffnen" bleibt draußen.
Ein Klick daneben schließt jetzt auch ein fixiertes Panel. „Klick fixiert"
ist eine gute Regel, aber wer sie nicht kennt, sitzt sonst vor etwas, das
offen steht und nicht reagiert — und sucht den Fehler in der App.
Einstellungs- und Einrichtungsfenster gehen nicht mehr direkt unter der
Notch auf. `center()` setzt oberhalb der Mitte; der Schließknopf landete
damit so weit oben, dass man auf dem Weg dorthin die Notch auslöste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 = { @Sendable [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()
|
|
}
|
|
}
|