904b9b3d-c0eb-42f3-acef-958.../Sources/sources/MediaRemoteAdapterSource.swift
徐翔宇 fbc64caec5 v2.2.2: fix UI hang on plugin install — bootstrap off main queue
Symptom: installing v2.2.1 caused Mio Island to freeze ("卡崩") on launch.
Not a true crash, just the main runloop stuck long enough to trip the
"app not responding" state.

Root cause: MediaRemoteAdapterSource.spawn() scheduled bootstrapGet()
on `DispatchQueue.main.asyncAfter(+0.3)`. bootstrapGet runs a Perl
subprocess that dlopens MediaRemoteAdapter.framework then calls
`get` — that cold path takes 500ms to 1s in the worst case. During
that entire window, `proc.waitUntilExit()` blocks the main thread.
No UI events drain, SwiftUI drops frames, window looks hung.

Fix:
- Move the `DispatchQueue.main.asyncAfter` to
  `DispatchQueue.global(qos: .userInitiated).asyncAfter` so the Perl
  cold path runs on a background queue.
- Since `currentInfo` is now mutated from both queues (bg in bootstrap,
  main in parseLine/stream), hop the merge + onUpdate back onto main
  after we parse the JSON on bg. Single writer, no data race.
- parseLine is unchanged — still runs on main via the FileHandle
  readabilityHandler hop.

Verified 30s alive, debug log shows bootstrap + stream rx both
emitting correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:05:01 +08:00

413 lines
16 KiB
Swift

//
// MediaRemoteAdapterSource.swift
// MioIsland Music Plugin
//
// Bypasses the macOS 15.4+ MRMediaRemoteGetNowPlayingInfo entitlement gate
// by running `mediaremote-adapter.pl` (BSD-3-Clause, by Jonas van den Berg)
// as a subprocess. The Perl script DynaLoader-loads the bundled
// MediaRemoteAdapter.framework binary, which in turn links against Apple's
// MediaRemote private framework. Because the entitlement check fires on the
// CALLING symbol which on Apple's side is MR internals, not our process
// the gate is skipped and we get the full now-playing payload.
//
// The subprocess emits one JSON object per state change to stdout (diff
// mode), debounced 50ms. We consume it line-by-line via a NSFileHandle read
// observer and update the MediaRemoteInfo callback on the main queue.
//
// Lifecycle:
// - start() spawns the subprocess exactly once.
// - On SIGPIPE / stdout EOF / non-zero exit, we retry after a 2-second
// delay. After 3 consecutive crashes within 60s, we stop retrying and
// let NowPlayingState fall back to the legacy source chain.
// - stop() sends SIGTERM + waits up to 2s + SIGKILL if still alive.
//
// Credits: MediaRemoteAdapter.framework + mediaremote-adapter.pl
// Copyright (c) 2025 Jonas van den Berg. BSD-3-Clause.
// Bundled under Resources/mediaremote-adapter/ in this plugin.
//
import AppKit
import Foundation
// MARK: - Stream payload (subset of adapter output)
/// The track-level payload. Only the keys we consume are decoded;
/// adapter also emits `composer`, `contentItemIdentifier`,
/// `radioStationHash`, `timestamp` etc. which we ignore.
private struct AdapterStreamPayload: Decodable {
var title: String?
var artist: String?
var album: String?
var duration: Double?
var elapsedTime: Double?
var playbackRate: Double?
var playing: Bool?
var bundleIdentifier: String?
/// Base64-encoded artwork data. JSONDecoder decodes Data from base64
/// automatically via its default strategy.
var artworkData: Data?
}
/// Envelope that wraps every line emitted by `stream` mode. Structure is:
/// `{"type":"data","diff":<bool>,"payload":{...}}`. `diff: false` means
/// this is a full state snapshot (initial baseline OR after track change);
/// `diff: true` means only the changed fields are in payload. `get` mode
/// emits the payload directly without this envelope.
private struct AdapterStreamEnvelope: Decodable {
var type: String?
var diff: Bool?
var payload: AdapterStreamPayload?
}
// MARK: - Source
final class MediaRemoteAdapterSource {
// Configuration
private let scriptPath: String
private let frameworkPath: String
private let debounceMs: Int
// Callback to NowPlayingState
/// Called on the main queue whenever the subprocess emits a payload
/// that results in a usable MediaRemoteInfo. Called with nil when the
/// subprocess dies and restart is disabled.
var onUpdate: ((MediaRemoteInfo) -> Void)?
// Process state
private var process: Process?
private var stdoutHandle: FileHandle?
private var stderrHandle: FileHandle?
private var lineBuffer = Data()
// Aggregated "current state" adapter sends diffs, so we merge them
// ourselves. Apple Music frequently sends a playbackRate-only diff
// when the user pauses, so we need to remember title/artist from earlier.
private var currentInfo = MediaRemoteInfo()
// Crash / restart tracking
private var crashTimestamps: [Date] = []
private let maxCrashesPer60s = 3
private var restartWorkItem: DispatchWorkItem?
private var stopped = false
// MARK: - Init
/// Initialises the source with paths resolved from the plugin bundle.
/// Returns nil if either path is missing caller should fall back to
/// the legacy chain.
init?() {
// Resolve bundle that contains THIS source's compiled class. Using
// Bundle(for:) instead of Bundle.main because the plugin loads into
// the host's address space Bundle.main is the host, not us.
let bundle = Bundle(for: PathResolverToken.self)
guard let script = bundle.path(forResource: "mediaremote-adapter",
ofType: "pl",
inDirectory: "mediaremote-adapter")
?? bundle.path(forResource: "mediaremote-adapter", ofType: "pl")
else {
NSLog("[mio-plugin-music] adapter script not found in bundle")
return nil
}
let resourcesRoot = (script as NSString).deletingLastPathComponent
let framework = (resourcesRoot as NSString)
.appendingPathComponent("MediaRemoteAdapter.framework")
guard FileManager.default.fileExists(atPath: framework) else {
NSLog("[mio-plugin-music] adapter framework not found at \(framework)")
return nil
}
self.scriptPath = script
self.frameworkPath = framework
self.debounceMs = 50
}
deinit {
stop()
}
// MARK: - Lifecycle
func start() {
stopped = false
spawn()
}
func stop() {
stopped = true
restartWorkItem?.cancel()
restartWorkItem = nil
terminateProcess()
}
private func terminateProcess() {
guard let proc = process else { return }
process = nil
stdoutHandle?.readabilityHandler = nil
stdoutHandle = nil
stderrHandle?.readabilityHandler = nil
stderrHandle = nil
if proc.isRunning {
proc.terminate()
// Give it 500ms to exit cleanly, then force.
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
if proc.isRunning {
kill(proc.processIdentifier, SIGKILL)
}
}
}
}
// MARK: - Spawn
private func spawn() {
guard !stopped else { return }
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
proc.arguments = [
scriptPath,
frameworkPath,
"stream",
"--debounce=\(debounceMs)"
]
// Minimize inherited env Perl / DynaLoader doesn't need our full
// shell environment. Keep PATH so Perl can find its own modules.
proc.environment = [
"PATH": "/usr/bin:/bin",
"LANG": "en_US.UTF-8"
]
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = errPipe
proc.terminationHandler = { [weak self] p in
DispatchQueue.main.async { self?.handleTermination(status: p.terminationStatus) }
}
stdoutHandle = outPipe.fileHandleForReading
stderrHandle = errPipe.fileHandleForReading
stdoutHandle?.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
DispatchQueue.main.async { self?.ingestStdout(data) }
}
stderrHandle?.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
if let str = String(data: data, encoding: .utf8) {
NSLog("[mio-plugin-music] adapter stderr: \(str.trimmingCharacters(in: .whitespacesAndNewlines))")
}
_ = self
}
do {
try proc.run()
process = proc
debugLog("adapter spawned pid=\(proc.processIdentifier)")
// Bootstrap pull current state via one-shot `get`. Covers the
// case where the stream subprocess started BEFORE any music app
// was opened; in that case the initial stream emit is null/empty,
// and no diff comes until something changes. A parallel `get`
// catches whatever is playing right now.
//
// CRITICAL: runs on a BACKGROUND queue because bootstrapGet()
// calls Process.waitUntilExit() which blocks synchronously for
// 500ms-1s (Perl boot + framework load). Running that on main
// freezes the whole UI looked like a crash/hang on launch.
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.bootstrapGet()
}
} catch {
debugLog("adapter spawn failed: \(error)")
scheduleRestart()
}
}
/// Runs on a BACKGROUND queue. Parses JSON on bg, hops to main for
/// the merge + onUpdate so `currentInfo` is only ever mutated on the
/// main queue (same queue as stream's parseLine).
private func bootstrapGet() {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
proc.arguments = [scriptPath, frameworkPath, "get"]
proc.environment = ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]
let outPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = FileHandle(forWritingAtPath: "/dev/null")
do {
try proc.run()
proc.waitUntilExit()
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
guard !data.isEmpty else {
debugLog("bootstrap get returned empty")
return
}
guard let payload = try? JSONDecoder().decode(AdapterStreamPayload.self, from: data) else {
debugLog("bootstrap get · parse failed")
return
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.merge(payload)
self.debugLog("bootstrap get · title=\(self.currentInfo.title) playing=\(self.currentInfo.isPlaying)")
if self.currentInfo.hasTrack {
self.onUpdate?(self.currentInfo)
}
}
} catch {
// debugLog runs main-ok from any queue, just logs.
debugLog("bootstrap get failed: \(error)")
}
}
// MARK: - Stdout ingestion
private func ingestStdout(_ chunk: Data) {
lineBuffer.append(chunk)
// Adapter emits newline-delimited JSON. Parse as many complete
// lines as the buffer currently holds.
while let nlRange = lineBuffer.firstRange(of: Data([0x0A])) {
let lineData = lineBuffer.prefix(upTo: nlRange.lowerBound)
lineBuffer.removeSubrange(0 ..< nlRange.upperBound)
guard !lineData.isEmpty else { continue }
parseLine(Data(lineData))
}
}
private func parseLine(_ data: Data) {
do {
let env = try JSONDecoder().decode(AdapterStreamEnvelope.self, from: data)
guard env.type == "data" else {
debugLog("non-data envelope: \(env.type ?? "nil")")
return
}
guard let payload = env.payload else { return }
// Full snapshot (diff=false) reset, then merge, so stale
// fields from the previous track don't leak. Diff (default
// or true) merge only the provided fields.
if env.diff == false {
currentInfo = MediaRemoteInfo()
}
merge(payload)
debugLog("stream rx · diff=\(env.diff ?? true) title=\(currentInfo.title) artist=\(currentInfo.artist) playing=\(currentInfo.isPlaying) hasTrack=\(currentInfo.hasTrack)")
if currentInfo.hasTrack {
onUpdate?(currentInfo)
}
} catch {
if let preview = String(data: data.prefix(80), encoding: .utf8),
!preview.hasPrefix("{") && !preview.hasPrefix("null") {
debugLog("unparseable line: \(preview)")
}
}
}
/// File-based debug log NSLog / os_log are unreliably filtered on
/// macOS 15, and we can't attach Xcode to a plugin loaded from a
/// signed host. Writing a line-oriented log to /tmp is the one
/// channel that always works for post-mortem inspection.
private func debugLog(_ msg: String) {
let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(msg)\n"
let path = "/tmp/mio-plugin-music-debug.log"
if let data = line.data(using: .utf8) {
if FileManager.default.fileExists(atPath: path),
let h = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) {
try? h.seekToEnd()
try? h.write(contentsOf: data)
try? h.close()
} else {
try? data.write(to: URL(fileURLWithPath: path))
}
}
}
/// Merge an adapter diff into `currentInfo`. Only overwrite fields that
/// the payload explicitly provided leave the rest at their previous
/// value so a "just the elapsed time changed" diff doesn't erase title.
private func merge(_ payload: AdapterStreamPayload) {
if let title = payload.title { currentInfo.title = title }
if let artist = payload.artist { currentInfo.artist = artist }
if let album = payload.album { currentInfo.album = album }
if let duration = payload.duration { currentInfo.duration = duration }
if let elapsed = payload.elapsedTime { currentInfo.elapsedTime = elapsed }
if let rate = payload.playbackRate { currentInfo.playbackRate = rate }
if let playing = payload.playing {
currentInfo.isPlaying = playing
} else if let rate = payload.playbackRate {
// Some diffs only ship playbackRate; derive isPlaying.
currentInfo.isPlaying = rate > 0
}
if let bid = payload.bundleIdentifier { currentInfo.bundleIdentifier = bid }
if let art = payload.artworkData, !art.isEmpty {
currentInfo.artwork = NSImage(data: art)
}
}
// MARK: - Termination / restart
private func handleTermination(status: Int32) {
NSLog("[mio-plugin-music] adapter terminated (status=\(status))")
stdoutHandle?.readabilityHandler = nil
stdoutHandle = nil
stderrHandle?.readabilityHandler = nil
stderrHandle = nil
process = nil
currentInfo = MediaRemoteInfo()
lineBuffer.removeAll()
scheduleRestart()
}
private func scheduleRestart() {
guard !stopped else { return }
let now = Date()
crashTimestamps.append(now)
crashTimestamps.removeAll { now.timeIntervalSince($0) > 60 }
if crashTimestamps.count > maxCrashesPer60s {
NSLog("[mio-plugin-music] adapter crashed \(crashTimestamps.count) times in 60s — giving up")
return
}
// Exponential-ish backoff: 1s, 2s, 4s by crash count within the window.
let delay = min(4.0, pow(2.0, Double(crashTimestamps.count - 1)))
let work = DispatchWorkItem { [weak self] in self?.spawn() }
restartWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
}
// MARK: - Transport (fire-and-forget short-lived subprocess)
/// Send a MediaRemote command ID. Uses a short-lived subprocess
/// rather than a persistent control channel keeps the architecture
/// simple and matches how Atoll does it.
/// Known commands (MRCommand IDs per adapter Perl examples):
/// 0=play, 1=pause, 2=togglePlayPause, 3=stop, 4=next, 5=previous
func sendCommand(_ id: Int) {
runOneShot(["send", String(id)])
}
/// Seek to position in seconds. Adapter takes microseconds, so *1e6.
func seek(_ seconds: Double) {
let micros = Int64(max(0, seconds) * 1_000_000)
runOneShot(["seek", String(micros)])
}
private func runOneShot(_ args: [String]) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
proc.arguments = [scriptPath, frameworkPath] + args
proc.environment = ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]
let devnull = FileHandle(forWritingAtPath: "/dev/null")
proc.standardOutput = devnull
proc.standardError = devnull
do {
try proc.run()
} catch {
NSLog("[mio-plugin-music] adapter one-shot failed: \(error)")
}
}
}
// Dummy class used only as a `Bundle(for:)` anchor so we can find our own
// plugin bundle without relying on Bundle.main (which is the host app).
private final class PathResolverToken {}