904b9b3d-c0eb-42f3-acef-958.../Sources/sources/MediaRemoteAdapterSource.swift
徐翔宇 69776ecec2 v2.2.1: real lyrics via LRCLIB + stream envelope fix + TimelineView vinyl
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>
2026-04-21 07:55:04 +08:00

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 {}