v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
//
|
|
|
|
|
// 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)
|
|
|
|
|
|
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-20 23:55:04 +00:00
|
|
|
/// The track-level payload. Only the keys we consume are decoded;
|
|
|
|
|
/// adapter also emits `composer`, `contentItemIdentifier`,
|
|
|
|
|
/// `radioStationHash`, `timestamp` etc. which we ignore.
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
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?
|
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-20 23:55:04 +00:00
|
|
|
/// Base64-encoded artwork data. JSONDecoder decodes Data from base64
|
|
|
|
|
/// automatically via its default strategy.
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
var artworkData: Data?
|
|
|
|
|
}
|
|
|
|
|
|
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-20 23:55:04 +00:00
|
|
|
/// 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?
|
|
|
|
|
}
|
|
|
|
|
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
// 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
|
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-20 23:55:04 +00:00
|
|
|
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()
|
|
|
|
|
}
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
} catch {
|
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-20 23:55:04 +00:00
|
|
|
debugLog("adapter spawn failed: \(error)")
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
scheduleRestart()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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-20 23:55:04 +00:00
|
|
|
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)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
// 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 {
|
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-20 23:55:04 +00:00
|
|
|
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()
|
|
|
|
|
}
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
merge(payload)
|
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-20 23:55:04 +00:00
|
|
|
debugLog("stream rx · diff=\(env.diff ?? true) title=\(currentInfo.title) artist=\(currentInfo.artist) playing=\(currentInfo.isPlaying) hasTrack=\(currentInfo.hasTrack)")
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
if currentInfo.hasTrack {
|
|
|
|
|
onUpdate?(currentInfo)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
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-20 23:55:04 +00:00
|
|
|
if let preview = String(data: data.prefix(80), encoding: .utf8),
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
!preview.hasPrefix("{") && !preview.hasPrefix("null") {
|
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-20 23:55:04 +00:00
|
|
|
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))
|
v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.
Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution
New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
from stdout, parses via Codable AdapterStreamPayload, merges diffs
into persistent MediaRemoteInfo so playbackRate-only payloads don't
erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
`perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
5=prev. seek takes microseconds.
NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
subprocess pushes fresh data on every change, polling would be pure
waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
from pull-fetch so the sticky fast-path falls through to parallel
probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
when it's the sticky source.
Build:
- build.sh copies Resources/ into Contents/Resources with preserved
exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
so the nested framework inherits our identity and Gatekeeper loads
it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
latency win: Apple Music track switches now visible <100ms vs prior
800ms adaptive-poll worst case.
Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
shell-out, no network I/O, params passed to framework via ENV
(no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
--deep re-sign with our identity replaces the original ad-hoc cert so
signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
secrets.
- Explicit Process arguments array — no shell interpolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:19:40 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 {}
|