mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
Critical fix · adapter stream was silently empty
v2.2.0 parsed the stream subprocess's JSON at the wrong layer. The
`stream` mode wraps every emit as:
{"type":"data","diff":<bool>,"payload":{title,...}}
but Swift was decoding as AdapterStreamPayload directly (the shape
used by `get`, which is flat). Result: every `stream rx` had
title="" because the real data was nested inside payload, so
hasTrack was always false and onUpdate never fired. Users saw
"nothing playing" even while Apple Music was running.
New AdapterStreamEnvelope decodes the wrapper, extracts payload,
and also honours `diff: false` to reset currentInfo before merging
(stale fields from the previous track were otherwise leaking).
Added bootstrap path · cold start with music already playing
When the adapter subprocess is spawned AFTER Apple Music is already
playing, the stream's initial emit can be an empty baseline. A
parallel one-shot `perl adapter.pl get` at spawn+300ms catches the
current track immediately.
Added file-based debug log at /tmp/mio-plugin-music-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. A line-oriented
log at a fixed path is the one channel that's always readable post-
mortem. Lines include stream rx / bootstrap / parse failures.
Real lyrics · LRCLIB integration
- New LyricsService fetches synced lyrics via
https://lrclib.net/api/get (exact) + /search (fallback), parses
LRC format with regex [mm:ss.xx]. In-memory LRU cache (32 entries,
1-hour TTL). Negative-caches "not found" so obscure tracks don't
re-hit the API every render.
- NowPlayingState gains syncedLyrics + currentLyricIndex @Published.
applyAdapterUpdate detects track changes and refreshes lyrics
detached; the 1s playback timer updates currentLyricIndex.
- DesktopLyricsViews replaces the placeholder text with real lyric
text from syncedLyrics[currentLyricIndex ± 1]. Falls back to
sensible dots when no lyrics loaded / instrumentals.
Bonus · robust vinyl spin via TimelineView
withAnimation(.linear.repeatForever) loses the animation when
SwiftUI re-creates the view (window hide/show, style switch).
Replaced with TimelineView driven by wall-clock — angle =
(elapsed * 45°/s) % 360. Smooth across hours, no drift, pauses
correctly via `paused: !isPlaying`.
Borrowed approach from Atoll (github.com/Ebullioscopic/Atoll)
MusicManager.swift:756-935: same LRCLIB endpoints, same LRC regex
shape, same per-second sync model. Credited in LICENSE-THIRD-PARTY.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
403 lines
16 KiB
Swift
403 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.
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
self?.bootstrapGet()
|
|
}
|
|
} catch {
|
|
debugLog("adapter spawn failed: \(error)")
|
|
scheduleRestart()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
// `get` emits one JSON object to stdout.
|
|
if let payload = try? JSONDecoder().decode(AdapterStreamPayload.self, from: data) {
|
|
merge(payload)
|
|
debugLog("bootstrap get · title=\(currentInfo.title) playing=\(currentInfo.isPlaying)")
|
|
if currentInfo.hasTrack {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.onUpdate?(self.currentInfo)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
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 {}
|