Files
onyx/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Scarriffle 667fb61c96 Absturz beim XPC-Rückruf, Menü zurück, Fenster weg von der Notch
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>
2026-08-11 12:54:32 +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 = { @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()
}
}