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>
Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+)
with a layered design that handles four playback sources with sticky source
priority routing:
NowPlayingState (orchestrator, @MainActor, 3s poll + notifications)
├─ MediaRemote (private framework, dlopen)
├─ Spotify AppleScript (desktop)
├─ Apple Music AppleScript (desktop)
└─ Chrome JS injection (YouTube / SoundCloud / web music)
UI:
- Large album art with color-extracted gradient background
- Title / artist / album + source badge
- Draggable seek bar with hover-grow affordance
- Prev / Play·Pause (56pt lime button) / Next controls
- Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing
- Bi-lingual (zh / en), follows host appLanguage
Graceful degradation:
- Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required)
- QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version"
- Empty state with hint to play something in supported apps
Build layout:
Sources/ root (MioPlugin.swift contract + MusicPlugin principal)
Sources/sources/ data sources
Sources/ui/ SwiftUI views
Sources/support/ ChineseAppDetector / HostVersionCheck / Localization
build.sh now recursively finds .swift under Sources/.
Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on
v1.0.0 can upgrade in place via the plugin store.
Requires: MioIsland host >= v2.1.7 for full functionality.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>