mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
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>
413 lines
16 KiB
Swift
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 {}
|