2026-04-18 18:27:21 +00:00
|
|
|
|
//
|
|
|
|
|
|
// NowPlayingState.swift
|
|
|
|
|
|
// MioIsland Music Plugin
|
|
|
|
|
|
//
|
|
|
|
|
|
// Single source of truth consumed by the SwiftUI layer. Aggregates four
|
|
|
|
|
|
// backend sources, in priority order:
|
|
|
|
|
|
//
|
|
|
|
|
|
// 1. The most recently successful source (sticky preference so we do not
|
|
|
|
|
|
// thrash between Spotify / Music / Chrome on every poll).
|
|
|
|
|
|
// 2. MediaRemote (private framework; falls back on macOS 15.4+ where it
|
|
|
|
|
|
// returns an empty dictionary without a special entitlement).
|
|
|
|
|
|
// 3. Spotify desktop via AppleScript.
|
|
|
|
|
|
// 4. Apple Music via AppleScript.
|
|
|
|
|
|
// 5. Google Chrome tab via JS injection.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Also checks:
|
|
|
|
|
|
// - Host version (must be ≥ 2.1.7 for NSAppleEventsUsageDescription).
|
|
|
|
|
|
// - Chinese desktop players (QQ 音乐 / 网易云 / 酷狗) so we can show a
|
|
|
|
|
|
// "desktop unsupported, use web" state instead of empty UI.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Timing:
|
|
|
|
|
|
// - 3 second poll timer drives periodic refresh.
|
|
|
|
|
|
// - MediaRemote notifications (when available) trigger immediate refresh.
|
|
|
|
|
|
// - A 1 second local timer advances elapsedTime while isPlaying is true.
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
import AppKit
|
|
|
|
|
|
import Combine
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Source enum
|
|
|
|
|
|
|
|
|
|
|
|
enum NowPlayingSourceKind: String {
|
|
|
|
|
|
case none
|
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
|
|
|
|
/// Atoll-style MediaRemoteAdapter subprocess stream — bypasses the
|
|
|
|
|
|
/// macOS 15.4+ entitlement gate and gives us real-time system Now
|
|
|
|
|
|
/// Playing with artwork, duration, and elapsed time.
|
|
|
|
|
|
case mediaRemoteAdapter
|
2026-04-18 18:27:21 +00:00
|
|
|
|
case mediaRemote
|
|
|
|
|
|
case spotify
|
|
|
|
|
|
case appleMusic
|
|
|
|
|
|
case chrome
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - State
|
|
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
|
final class NowPlayingState: ObservableObject {
|
|
|
|
|
|
static let shared = NowPlayingState()
|
|
|
|
|
|
|
|
|
|
|
|
// Track info
|
|
|
|
|
|
@Published var title: String = ""
|
|
|
|
|
|
@Published var artist: String = ""
|
|
|
|
|
|
@Published var album: String = ""
|
|
|
|
|
|
@Published var albumArt: NSImage?
|
|
|
|
|
|
|
|
|
|
|
|
/// Populated by Worker B's AlbumArtColorExtractor once albumArt changes.
|
|
|
|
|
|
@Published var albumArtColor: NSColor?
|
|
|
|
|
|
|
|
|
|
|
|
@Published var isPlaying: Bool = false
|
|
|
|
|
|
@Published var duration: TimeInterval = 0
|
|
|
|
|
|
@Published var elapsedTime: TimeInterval = 0
|
|
|
|
|
|
|
|
|
|
|
|
/// Human readable source label ("Spotify" / "Apple Music" / "YouTube" / …)
|
|
|
|
|
|
@Published var sourceName: String = ""
|
|
|
|
|
|
|
|
|
|
|
|
/// Bundle identifier of the app that owns the current playback, for
|
|
|
|
|
|
/// NSWorkspace icon lookups by the UI layer.
|
|
|
|
|
|
@Published var sourceBundleId: String = ""
|
|
|
|
|
|
|
|
|
|
|
|
/// False when Mio Island host is older than HostVersionCheck.minRequired.
|
|
|
|
|
|
/// UI should show an upgrade banner and skip AppleScript sources.
|
|
|
|
|
|
@Published var hostVersionOK: Bool = true
|
|
|
|
|
|
|
|
|
|
|
|
/// Non-nil when a Chinese desktop player is running. UI shows a "桌面端
|
|
|
|
|
|
/// 暂不支持,请使用网页版" hint.
|
|
|
|
|
|
@Published var chineseAppDetected: 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
|
|
|
|
/// Parsed synced lyrics from LRCLIB after every track change. Empty
|
|
|
|
|
|
/// array = we tried, nothing found (instrumentals / obscure tracks).
|
|
|
|
|
|
@Published var syncedLyrics: [LyricLine] = []
|
|
|
|
|
|
|
|
|
|
|
|
/// Index into `syncedLyrics` for the current playhead. -1 means no
|
|
|
|
|
|
/// lyrics loaded OR elapsedTime < first line's timestamp. Updated
|
|
|
|
|
|
/// by the playback timer every second.
|
|
|
|
|
|
@Published var currentLyricIndex: Int = -1
|
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
|
// MARK: - Derived
|
|
|
|
|
|
|
|
|
|
|
|
var progress: Double {
|
|
|
|
|
|
guard duration > 0 else { return 0 }
|
|
|
|
|
|
return max(0, min(1, elapsedTime / duration))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var formattedElapsed: String { Self.format(elapsedTime) }
|
|
|
|
|
|
var formattedDuration: String { Self.format(duration) }
|
|
|
|
|
|
|
|
|
|
|
|
private static func format(_ t: TimeInterval) -> String {
|
|
|
|
|
|
guard t.isFinite, t >= 0 else { return "0:00" }
|
|
|
|
|
|
let total = Int(t)
|
|
|
|
|
|
let minutes = total / 60
|
|
|
|
|
|
let seconds = total % 60
|
|
|
|
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Private
|
|
|
|
|
|
|
|
|
|
|
|
private let mediaRemote = MediaRemoteSource()
|
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
|
|
|
|
/// Atoll-style subprocess adapter. Optional because the bundle may be
|
|
|
|
|
|
/// missing the Resources/mediaremote-adapter payload (dev builds, old
|
|
|
|
|
|
/// plugin versions). When non-nil, it becomes the primary source and
|
|
|
|
|
|
/// most of the legacy polling / AppleScript chain stays dormant.
|
|
|
|
|
|
private let mediaRemoteAdapter: MediaRemoteAdapterSource? = MediaRemoteAdapterSource()
|
2026-04-18 18:27:21 +00:00
|
|
|
|
private var pollTimer: Timer?
|
|
|
|
|
|
private var playbackTimer: Timer?
|
|
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
|
|
private var stickySource: NowPlayingSourceKind = .none
|
|
|
|
|
|
private var lastChromeTabURL: String = ""
|
|
|
|
|
|
private var isRunning = false
|
|
|
|
|
|
private var refreshInFlight = false
|
|
|
|
|
|
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
/// macOS 15.4+ gates MRMediaRemoteGetNowPlayingInfo behind a private
|
|
|
|
|
|
/// entitlement. When the call returns an empty dict we mark the API
|
|
|
|
|
|
/// as blocked and skip it for 60 seconds before retrying (macOS minor
|
|
|
|
|
|
/// updates can flip the entitlement state, so we don't mark "blocked
|
|
|
|
|
|
/// forever"). Saves ~50ms per refresh when blocked, but more importantly
|
|
|
|
|
|
/// lets the router hit AppleScript on the first pass instead of the
|
|
|
|
|
|
/// second — ~1s faster cold start on restricted systems.
|
|
|
|
|
|
private var mediaRemoteBlockedUntil: Date?
|
|
|
|
|
|
|
|
|
|
|
|
/// NSWorkspace observers for app launch/terminate. When a music app
|
|
|
|
|
|
/// opens or closes, refresh immediately — these events beat the poll
|
|
|
|
|
|
/// timer by several seconds.
|
|
|
|
|
|
private var workspaceObservers: [NSObjectProtocol] = []
|
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
|
private init() {}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
|
|
|
|
|
|
|
|
func start() {
|
|
|
|
|
|
guard !isRunning else { return }
|
|
|
|
|
|
isRunning = true
|
|
|
|
|
|
NSLog("[mio-plugin-music] NowPlayingState.start")
|
|
|
|
|
|
|
|
|
|
|
|
hostVersionOK = HostVersionCheck.isOK()
|
|
|
|
|
|
chineseAppDetected = ChineseAppDetector.detectRunning()
|
|
|
|
|
|
|
|
|
|
|
|
mediaRemote.registerForNotifications { [weak self] in
|
|
|
|
|
|
Task { @MainActor in self?.refresh() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// Start the Atoll-style adapter subprocess if bundled. This is
|
|
|
|
|
|
// the PRIMARY low-latency source — on 15.4+ it's the only one that
|
|
|
|
|
|
// actually produces live data without AppleScript polling. When
|
|
|
|
|
|
// it emits, we short-circuit the router entirely.
|
|
|
|
|
|
if let adapter = mediaRemoteAdapter {
|
|
|
|
|
|
adapter.onUpdate = { [weak self] info in
|
|
|
|
|
|
Task { @MainActor in self?.applyAdapterUpdate(info) }
|
|
|
|
|
|
}
|
|
|
|
|
|
adapter.start()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
|
// Observe Spotify distributed notifications for instant reaction.
|
|
|
|
|
|
DistributedNotificationCenter.default().addObserver(
|
|
|
|
|
|
self,
|
|
|
|
|
|
selector: #selector(spotifyStateChanged),
|
|
|
|
|
|
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
|
|
|
|
|
|
object: nil
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-19 12:42:14 +00:00
|
|
|
|
// Observe Apple Music. macOS 15+ Music.app emits
|
|
|
|
|
|
// com.apple.Music.playerInfo; older iTunes emitted
|
|
|
|
|
|
// com.apple.iTunes.playerInfo. Register both so track changes are
|
|
|
|
|
|
// picked up instantly regardless of which one the current build
|
|
|
|
|
|
// broadcasts.
|
2026-04-18 18:27:21 +00:00
|
|
|
|
DistributedNotificationCenter.default().addObserver(
|
|
|
|
|
|
self,
|
|
|
|
|
|
selector: #selector(musicStateChanged),
|
|
|
|
|
|
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
|
|
|
|
|
object: nil
|
|
|
|
|
|
)
|
2026-04-19 12:42:14 +00:00
|
|
|
|
DistributedNotificationCenter.default().addObserver(
|
|
|
|
|
|
self,
|
|
|
|
|
|
selector: #selector(musicStateChanged),
|
|
|
|
|
|
name: NSNotification.Name("com.apple.iTunes.playerInfo"),
|
|
|
|
|
|
object: nil
|
|
|
|
|
|
)
|
2026-04-18 18:27:21 +00:00
|
|
|
|
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
// Observe app launch / terminate — when Spotify or Music opens, we
|
|
|
|
|
|
// want to detect it within the same RunLoop tick rather than waiting
|
|
|
|
|
|
// out the 15s safety-net poll.
|
|
|
|
|
|
let wsCenter = NSWorkspace.shared.notificationCenter
|
|
|
|
|
|
let trackedBundleIds: Set<String> = [
|
|
|
|
|
|
SpotifyAppleScript.bundleId,
|
|
|
|
|
|
AppleMusicAppleScript.bundleId,
|
|
|
|
|
|
ChromeWebSource.bundleId,
|
|
|
|
|
|
]
|
|
|
|
|
|
let launchToken = wsCenter.addObserver(
|
|
|
|
|
|
forName: NSWorkspace.didLaunchApplicationNotification,
|
|
|
|
|
|
object: nil,
|
|
|
|
|
|
queue: .main
|
|
|
|
|
|
) { [weak self] note in
|
|
|
|
|
|
guard
|
|
|
|
|
|
let bid = (note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier,
|
|
|
|
|
|
trackedBundleIds.contains(bid)
|
|
|
|
|
|
else { return }
|
|
|
|
|
|
Task { @MainActor in self?.refresh() }
|
|
|
|
|
|
}
|
|
|
|
|
|
let terminateToken = wsCenter.addObserver(
|
|
|
|
|
|
forName: NSWorkspace.didTerminateApplicationNotification,
|
|
|
|
|
|
object: nil,
|
|
|
|
|
|
queue: .main
|
|
|
|
|
|
) { [weak self] note in
|
|
|
|
|
|
guard
|
|
|
|
|
|
let bid = (note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier,
|
|
|
|
|
|
trackedBundleIds.contains(bid)
|
|
|
|
|
|
else { return }
|
|
|
|
|
|
Task { @MainActor in self?.refresh() }
|
|
|
|
|
|
}
|
|
|
|
|
|
workspaceObservers = [launchToken, terminateToken]
|
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
|
startPolling()
|
|
|
|
|
|
refresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func stop() {
|
|
|
|
|
|
guard isRunning else { return }
|
|
|
|
|
|
isRunning = false
|
|
|
|
|
|
NSLog("[mio-plugin-music] NowPlayingState.stop")
|
|
|
|
|
|
|
|
|
|
|
|
pollTimer?.invalidate()
|
|
|
|
|
|
pollTimer = nil
|
|
|
|
|
|
playbackTimer?.invalidate()
|
|
|
|
|
|
playbackTimer = nil
|
|
|
|
|
|
DistributedNotificationCenter.default().removeObserver(self)
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
|
|
|
|
|
|
let wsCenter = NSWorkspace.shared.notificationCenter
|
|
|
|
|
|
for token in workspaceObservers {
|
|
|
|
|
|
wsCenter.removeObserver(token)
|
|
|
|
|
|
}
|
|
|
|
|
|
workspaceObservers.removeAll()
|
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
|
|
|
|
|
|
|
|
|
|
mediaRemoteAdapter?.stop()
|
2026-04-18 18:27:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@objc private func spotifyStateChanged() {
|
|
|
|
|
|
Task { @MainActor in self.refresh() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@objc private func musicStateChanged() {
|
|
|
|
|
|
Task { @MainActor in self.refresh() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Polling
|
|
|
|
|
|
|
|
|
|
|
|
private func startPolling() {
|
2026-04-20 07:07:56 +00:00
|
|
|
|
rearmPoll()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Adaptive poll interval — the event-driven fast paths aren't uniformly
|
|
|
|
|
|
/// reliable across players on modern macOS:
|
|
|
|
|
|
/// - Spotify: com.spotify.client.PlaybackStateChanged fires instantly
|
|
|
|
|
|
/// on every track change → 10s safety-net is plenty.
|
|
|
|
|
|
/// - Apple Music: com.apple.Music.playerInfo is NOT reliably broadcast
|
|
|
|
|
|
/// on macOS 14+ (Apple stopped posting it in many builds). Combined
|
|
|
|
|
|
/// with MediaRemote's 15.4+ entitlement gate, there is literally no
|
|
|
|
|
|
/// event source left, so we have to poll. 0.8s gets track changes
|
|
|
|
|
|
/// visible inside 1s which is the best we can do without the
|
|
|
|
|
|
/// Atoll-style adapter framework.
|
|
|
|
|
|
/// - Chrome / web players: no notifications at all. 1.2s poll is a
|
|
|
|
|
|
/// reasonable tradeoff between latency and CPU.
|
|
|
|
|
|
/// - Idle / nothing playing: 10s is fine — the NSWorkspace launch
|
|
|
|
|
|
/// observer will wake us instantly when a music app opens.
|
|
|
|
|
|
/// Recomputed and re-armed every time `stickySource` or `isPlaying`
|
|
|
|
|
|
/// changes, so the plugin idles cheaply until it has something to track.
|
|
|
|
|
|
private var currentPollInterval: TimeInterval = 10.0
|
|
|
|
|
|
|
|
|
|
|
|
private func adaptivePollInterval() -> TimeInterval {
|
|
|
|
|
|
switch stickySource {
|
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
|
|
|
|
// Adapter subprocess pushes data in real time — poll only as a
|
|
|
|
|
|
// last-resort safety net in case the subprocess silently wedges.
|
|
|
|
|
|
case .mediaRemoteAdapter: return 30.0
|
2026-04-20 07:07:56 +00:00
|
|
|
|
case .appleMusic where isPlaying: return 0.8
|
|
|
|
|
|
case .chrome where isPlaying: return 1.2
|
|
|
|
|
|
case .spotify where isPlaying: return 3.0 // event-driven, poll is just backup
|
|
|
|
|
|
case .mediaRemote where isPlaying: return 3.0
|
|
|
|
|
|
default: return 10.0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func rearmPoll() {
|
|
|
|
|
|
let newInterval = adaptivePollInterval()
|
|
|
|
|
|
// Avoid invalidating the timer on every refresh when the interval
|
|
|
|
|
|
// didn't actually change — Timer allocs aren't free and the router
|
|
|
|
|
|
// calls rearmPoll() after every successful fetch.
|
|
|
|
|
|
if let t = pollTimer, t.isValid, abs(newInterval - currentPollInterval) < 0.01 {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-18 18:27:21 +00:00
|
|
|
|
pollTimer?.invalidate()
|
2026-04-20 07:07:56 +00:00
|
|
|
|
currentPollInterval = newInterval
|
|
|
|
|
|
pollTimer = Timer.scheduledTimer(withTimeInterval: newInterval, repeats: true) { [weak self] _ in
|
2026-04-18 18:27:21 +00:00
|
|
|
|
Task { @MainActor in self?.refresh() }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Source router
|
|
|
|
|
|
|
|
|
|
|
|
private func refresh() {
|
|
|
|
|
|
guard !refreshInFlight else { return }
|
|
|
|
|
|
refreshInFlight = true
|
|
|
|
|
|
|
|
|
|
|
|
// Refresh Chinese app detection each pass; user may launch/quit them.
|
|
|
|
|
|
chineseAppDetected = ChineseAppDetector.detectRunning()
|
|
|
|
|
|
|
|
|
|
|
|
let allowAppleScript = hostVersionOK
|
|
|
|
|
|
|
|
|
|
|
|
Task { [weak self] in
|
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
|
await self.routeSources(allowAppleScript: allowAppleScript)
|
|
|
|
|
|
await MainActor.run { self.refreshInFlight = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func routeSources(allowAppleScript: Bool) async {
|
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
|
|
|
|
// Adapter short-circuit: when the subprocess is the sticky source
|
|
|
|
|
|
// and we already have a track from it, there's nothing to do here —
|
|
|
|
|
|
// new data will arrive via `applyAdapterUpdate(_:)` whenever it
|
|
|
|
|
|
// actually changes. Polling on top of an event-driven source just
|
|
|
|
|
|
// wastes AppleScript round-trips.
|
|
|
|
|
|
if stickySource == .mediaRemoteAdapter,
|
|
|
|
|
|
!title.isEmpty,
|
|
|
|
|
|
mediaRemoteAdapter != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
// Running-app snapshot — read once per pass so we don't hit the
|
|
|
|
|
|
// workspace API four times.
|
|
|
|
|
|
let spotifyRunning = SpotifyAppleScript.isRunning
|
|
|
|
|
|
let musicRunning = AppleMusicAppleScript.isRunning
|
|
|
|
|
|
let chromeRunning = ChromeWebSource.isRunning
|
|
|
|
|
|
|
|
|
|
|
|
// MediaRemote gate: on macOS 15.4+ the call returns an empty dict
|
|
|
|
|
|
// without entitlement. Cache that for 60s so we don't keep eating
|
|
|
|
|
|
// an IPC round-trip per refresh.
|
|
|
|
|
|
let now = Date()
|
|
|
|
|
|
let mrBlocked: Bool
|
|
|
|
|
|
if let until = mediaRemoteBlockedUntil, until > now {
|
|
|
|
|
|
mrBlocked = true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
mrBlocked = false
|
2026-04-18 18:27:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
// Sticky-source fast path — if the last successful source is still
|
|
|
|
|
|
// a live candidate, try it alone first. One AppleScript round-trip
|
|
|
|
|
|
// when music is playing = lowest possible latency path.
|
|
|
|
|
|
if stickySource != .none, isCandidateLive(
|
|
|
|
|
|
stickySource,
|
|
|
|
|
|
spotifyRunning: spotifyRunning,
|
|
|
|
|
|
musicRunning: musicRunning,
|
|
|
|
|
|
chromeRunning: chromeRunning,
|
|
|
|
|
|
mrBlocked: mrBlocked,
|
|
|
|
|
|
allowAppleScript: allowAppleScript
|
|
|
|
|
|
) {
|
|
|
|
|
|
if let used = await tryFetch(stickySource) {
|
2026-04-18 18:27:21 +00:00
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.stickySource = used
|
|
|
|
|
|
self.updatePlaybackTimer()
|
2026-04-20 07:07:56 +00:00
|
|
|
|
self.rearmPoll()
|
2026-04-18 18:27:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
// Parallel fallback probing. `async let` fans out all live candidates
|
|
|
|
|
|
// concurrently — cold start used to serialize: try MR (~50ms, miss on
|
|
|
|
|
|
// 15.4+) → try Spotify AppleScript (~100-2000ms) → try Music (~100-2000ms)
|
|
|
|
|
|
// → try Chrome (~200ms+). Worst case ~6s. Now they all race and we
|
|
|
|
|
|
// use the first non-nil result by priority.
|
|
|
|
|
|
async let mrResult: MediaRemoteInfo? = mrBlocked ? nil : mediaRemoteFetch()
|
|
|
|
|
|
async let spotifyResult: AppleScriptTrackInfo? = (allowAppleScript && spotifyRunning)
|
|
|
|
|
|
? SpotifyAppleScript.fetch() : nil
|
|
|
|
|
|
async let musicResult: AppleScriptTrackInfo? = (allowAppleScript && musicRunning)
|
|
|
|
|
|
? AppleMusicAppleScript.fetch() : nil
|
|
|
|
|
|
async let chromeResult: ChromeTrackInfo? = (allowAppleScript && chromeRunning)
|
|
|
|
|
|
? ChromeWebSource.fetch() : nil
|
|
|
|
|
|
|
|
|
|
|
|
let mr = await mrResult
|
|
|
|
|
|
let sp = await spotifyResult
|
|
|
|
|
|
let mu = await musicResult
|
|
|
|
|
|
let ch = await chromeResult
|
|
|
|
|
|
|
|
|
|
|
|
// MediaRemote returning empty on 15.4+ marks it blocked for 60s.
|
|
|
|
|
|
if !mrBlocked, mr == nil, mediaRemoteLikelyBlocked() {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.mediaRemoteBlockedUntil = Date().addingTimeInterval(60)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Priority order for picking the winner among the parallel results.
|
|
|
|
|
|
// MediaRemote first (it unifies everything when available). Then
|
|
|
|
|
|
// Spotify > Apple Music > Chrome — Spotify desktop tends to have
|
|
|
|
|
|
// fuller metadata than web, and Apple Music's AppleScript is slower
|
|
|
|
|
|
// so it gets slight demotion when a competing hit exists.
|
|
|
|
|
|
if let info = mr, info.hasTrack {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.apply(mediaRemote: info)
|
|
|
|
|
|
self.stickySource = .mediaRemote
|
|
|
|
|
|
self.updatePlaybackTimer()
|
2026-04-20 07:07:56 +00:00
|
|
|
|
self.rearmPoll()
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if let info = sp, !info.title.isEmpty {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.apply(appleScript: info)
|
|
|
|
|
|
self.stickySource = .spotify
|
|
|
|
|
|
self.updatePlaybackTimer()
|
2026-04-20 07:07:56 +00:00
|
|
|
|
self.rearmPoll()
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
if self.albumArt == nil, let art = await SpotifyAppleScript.fetchArtwork() {
|
|
|
|
|
|
await MainActor.run { self.albumArt = art }
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if let info = mu, !info.title.isEmpty {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.apply(appleScript: info)
|
|
|
|
|
|
self.stickySource = .appleMusic
|
|
|
|
|
|
self.updatePlaybackTimer()
|
2026-04-20 07:07:56 +00:00
|
|
|
|
self.rearmPoll()
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
if self.albumArt == nil, let art = await AppleMusicAppleScript.fetchArtwork() {
|
|
|
|
|
|
await MainActor.run { self.albumArt = art }
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if let info = ch, !info.title.isEmpty {
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.apply(chrome: info)
|
|
|
|
|
|
self.stickySource = .chrome
|
|
|
|
|
|
self.updatePlaybackTimer()
|
2026-04-20 07:07:56 +00:00
|
|
|
|
self.rearmPoll()
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
}
|
|
|
|
|
|
if let artURL = info.artworkURL, let url = URL(string: artURL) {
|
|
|
|
|
|
if let image = await downloadImage(from: url) {
|
|
|
|
|
|
await MainActor.run { self.albumArt = image }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
|
// Nothing returned a hit; clear state.
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
self.clearTrack()
|
|
|
|
|
|
self.stickySource = .none
|
|
|
|
|
|
self.updatePlaybackTimer()
|
2026-04-20 07:07:56 +00:00
|
|
|
|
self.rearmPoll()
|
2026-04-18 18:27:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
/// Whether a source could plausibly produce a hit right now given the
|
|
|
|
|
|
/// running-app snapshot + MediaRemote blocked state. Used to short-circuit
|
|
|
|
|
|
/// the sticky-source fast path — don't probe Spotify if Spotify is closed.
|
|
|
|
|
|
private func isCandidateLive(
|
|
|
|
|
|
_ kind: NowPlayingSourceKind,
|
|
|
|
|
|
spotifyRunning: Bool,
|
|
|
|
|
|
musicRunning: Bool,
|
|
|
|
|
|
chromeRunning: Bool,
|
|
|
|
|
|
mrBlocked: Bool,
|
|
|
|
|
|
allowAppleScript: Bool
|
|
|
|
|
|
) -> Bool {
|
|
|
|
|
|
switch kind {
|
|
|
|
|
|
case .none: return false
|
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
|
|
|
|
case .mediaRemoteAdapter: return false // push-only, not candidate for pull-fetch
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
case .mediaRemote: return !mrBlocked
|
|
|
|
|
|
case .spotify: return allowAppleScript && spotifyRunning
|
|
|
|
|
|
case .appleMusic: return allowAppleScript && musicRunning
|
|
|
|
|
|
case .chrome: return allowAppleScript && chromeRunning
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Bridge the MediaRemote callback-style API to async/await so we can
|
|
|
|
|
|
/// fan it out alongside the AppleScript sources in `routeSources`.
|
|
|
|
|
|
private func mediaRemoteFetch() async -> MediaRemoteInfo? {
|
|
|
|
|
|
await withCheckedContinuation { cont in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
self.mediaRemote.fetchInfo { cont.resume(returning: $0) }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Heuristic for "MediaRemote returned empty because Apple blocked us,
|
|
|
|
|
|
/// not because no one is playing". If at least one of the known player
|
|
|
|
|
|
/// apps is running but MediaRemote came back nil, the cause is almost
|
|
|
|
|
|
/// certainly the 15.4+ entitlement gate.
|
|
|
|
|
|
private func mediaRemoteLikelyBlocked() -> Bool {
|
|
|
|
|
|
SpotifyAppleScript.isRunning ||
|
|
|
|
|
|
AppleMusicAppleScript.isRunning ||
|
|
|
|
|
|
ChromeWebSource.isRunning
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
|
/// Try a single source. Returns the source kind on success, nil on miss.
|
|
|
|
|
|
private func tryFetch(_ kind: NowPlayingSourceKind) async -> NowPlayingSourceKind? {
|
|
|
|
|
|
switch kind {
|
|
|
|
|
|
case .none:
|
|
|
|
|
|
return nil
|
|
|
|
|
|
|
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
|
|
|
|
case .mediaRemoteAdapter:
|
|
|
|
|
|
// Push-only source; pull-fetch is a no-op.
|
|
|
|
|
|
return nil
|
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
|
case .mediaRemote:
|
|
|
|
|
|
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
self.mediaRemote.fetchInfo { cont.resume(returning: $0) }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
guard let info, info.hasTrack else { return nil }
|
|
|
|
|
|
await MainActor.run { self.apply(mediaRemote: info) }
|
|
|
|
|
|
return .mediaRemote
|
|
|
|
|
|
|
|
|
|
|
|
case .spotify:
|
|
|
|
|
|
guard let info = await SpotifyAppleScript.fetch(), !info.title.isEmpty else { return nil }
|
|
|
|
|
|
await MainActor.run { self.apply(appleScript: info) }
|
|
|
|
|
|
if self.albumArt == nil, let art = await SpotifyAppleScript.fetchArtwork() {
|
|
|
|
|
|
await MainActor.run { self.albumArt = art }
|
|
|
|
|
|
}
|
|
|
|
|
|
return .spotify
|
|
|
|
|
|
|
|
|
|
|
|
case .appleMusic:
|
|
|
|
|
|
guard let info = await AppleMusicAppleScript.fetch(), !info.title.isEmpty else { return nil }
|
|
|
|
|
|
await MainActor.run { self.apply(appleScript: info) }
|
2026-04-19 12:42:14 +00:00
|
|
|
|
// Apple Music doesn't expose an artwork URL via AppleScript;
|
|
|
|
|
|
// we dump the raw bytes to /tmp and reload. Only refetch when
|
|
|
|
|
|
// the track identity actually changes to avoid hammering disk.
|
|
|
|
|
|
if self.albumArt == nil, let art = await AppleMusicAppleScript.fetchArtwork() {
|
|
|
|
|
|
await MainActor.run { self.albumArt = art }
|
|
|
|
|
|
}
|
2026-04-18 18:27:21 +00:00
|
|
|
|
return .appleMusic
|
|
|
|
|
|
|
|
|
|
|
|
case .chrome:
|
|
|
|
|
|
guard let info = await ChromeWebSource.fetch(), !info.title.isEmpty else { return nil }
|
|
|
|
|
|
await MainActor.run { self.apply(chrome: info) }
|
|
|
|
|
|
if let artURL = info.artworkURL, let url = URL(string: artURL) {
|
|
|
|
|
|
if let image = await downloadImage(from: url) {
|
|
|
|
|
|
await MainActor.run { self.albumArt = image }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return .chrome
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Apply
|
|
|
|
|
|
|
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
|
|
|
|
/// Called when the Atoll-style subprocess adapter emits a fresh payload.
|
|
|
|
|
|
/// This bypasses the full router — adapter updates are the truest signal
|
|
|
|
|
|
/// we have on 15.4+, so we claim sticky-source and publish straight away.
|
|
|
|
|
|
private func applyAdapterUpdate(_ info: MediaRemoteInfo) {
|
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
|
|
|
|
// Detect track change BEFORE we overwrite the fields.
|
|
|
|
|
|
let trackChanged = (self.title != info.title) || (self.artist != info.artist)
|
|
|
|
|
|
|
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
|
|
|
|
self.title = info.title
|
|
|
|
|
|
self.artist = info.artist
|
|
|
|
|
|
self.album = info.album
|
|
|
|
|
|
self.duration = info.duration
|
|
|
|
|
|
self.elapsedTime = info.elapsedTime
|
|
|
|
|
|
self.isPlaying = info.isPlaying
|
|
|
|
|
|
if let art = info.artwork {
|
|
|
|
|
|
self.albumArt = art
|
|
|
|
|
|
}
|
|
|
|
|
|
// Source name from bundle id for the UI chip. Apple Music → "Apple Music"
|
|
|
|
|
|
// etc. Unknown bundle ids fall back to generic "System Media".
|
|
|
|
|
|
self.sourceBundleId = info.bundleIdentifier
|
|
|
|
|
|
self.sourceName = Self.humanReadableSource(bundleId: info.bundleIdentifier)
|
|
|
|
|
|
self.lastChromeTabURL = ""
|
|
|
|
|
|
self.stickySource = .mediaRemoteAdapter
|
|
|
|
|
|
self.updatePlaybackTimer()
|
|
|
|
|
|
self.rearmPoll()
|
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 trackChanged {
|
|
|
|
|
|
// Drop stale lyrics and fetch fresh ones from LRCLIB.
|
|
|
|
|
|
self.syncedLyrics = []
|
|
|
|
|
|
self.currentLyricIndex = -1
|
|
|
|
|
|
refreshLyrics()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Same track, but possibly a seek or pause/resume — recompute
|
|
|
|
|
|
// the current-lyric index immediately instead of waiting for
|
|
|
|
|
|
// the next playback-timer tick.
|
|
|
|
|
|
updateCurrentLyricIndex()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Pull synced lyrics from LRCLIB for the current track and publish.
|
|
|
|
|
|
/// Runs off the main actor (network I/O) but the @Published update
|
|
|
|
|
|
/// hops back to main. No-op when title/artist are missing — spares
|
|
|
|
|
|
/// LRCLIB a pointless round-trip.
|
|
|
|
|
|
private func refreshLyrics() {
|
|
|
|
|
|
let t = title, a = artist, al = album, d = duration
|
|
|
|
|
|
guard !t.isEmpty, !a.isEmpty else { return }
|
|
|
|
|
|
Task.detached(priority: .utility) { [weak self] in
|
|
|
|
|
|
let lines = await LyricsService.fetch(
|
|
|
|
|
|
artist: a, title: t, album: al, duration: d
|
|
|
|
|
|
)
|
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
|
// Only adopt if the user hasn't moved on to another track
|
|
|
|
|
|
// during the network call (LRCLIB can take 1-2s on misses).
|
|
|
|
|
|
guard self.title == t, self.artist == a else { return }
|
|
|
|
|
|
self.syncedLyrics = lines
|
|
|
|
|
|
self.currentLyricIndex = -1
|
|
|
|
|
|
self.updateCurrentLyricIndex()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Find the lyric line whose timestamp ≤ current elapsedTime. Binary
|
|
|
|
|
|
/// search since lines are sorted. Only publishes if the index actually
|
|
|
|
|
|
/// changed — prevents unnecessary SwiftUI redraws every tick.
|
|
|
|
|
|
func updateCurrentLyricIndex() {
|
|
|
|
|
|
guard !syncedLyrics.isEmpty else {
|
|
|
|
|
|
if currentLyricIndex != -1 { currentLyricIndex = -1 }
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
var newIndex = -1
|
|
|
|
|
|
// Linear scan is fine — typical lyric line counts are 30–80.
|
|
|
|
|
|
for (i, line) in syncedLyrics.enumerated() {
|
|
|
|
|
|
if elapsedTime >= line.timestamp {
|
|
|
|
|
|
newIndex = i
|
|
|
|
|
|
} else {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if newIndex != currentLyricIndex {
|
|
|
|
|
|
currentLyricIndex = newIndex
|
|
|
|
|
|
}
|
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 static func humanReadableSource(bundleId: String) -> String {
|
|
|
|
|
|
switch bundleId {
|
|
|
|
|
|
case "com.apple.Music": return "Apple Music"
|
|
|
|
|
|
case "com.spotify.client": return "Spotify"
|
|
|
|
|
|
case "com.google.Chrome": return "Chrome"
|
|
|
|
|
|
case "com.apple.Safari": return "Safari"
|
|
|
|
|
|
case "com.microsoft.edgemac": return "Edge"
|
|
|
|
|
|
case "com.apple.podcasts": return "Podcasts"
|
|
|
|
|
|
case "com.apple.tv": return "Apple TV"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return bundleId.components(separatedBy: ".").last?.capitalized ?? "System Media"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
|
private func apply(mediaRemote info: MediaRemoteInfo) {
|
|
|
|
|
|
self.title = info.title
|
|
|
|
|
|
self.artist = info.artist
|
|
|
|
|
|
self.album = info.album
|
|
|
|
|
|
self.duration = info.duration
|
|
|
|
|
|
self.elapsedTime = info.elapsedTime
|
|
|
|
|
|
self.isPlaying = info.isPlaying
|
|
|
|
|
|
self.albumArt = info.artwork
|
|
|
|
|
|
self.sourceName = "System Media"
|
|
|
|
|
|
self.sourceBundleId = info.bundleIdentifier
|
|
|
|
|
|
self.lastChromeTabURL = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func apply(appleScript info: AppleScriptTrackInfo) {
|
2026-04-19 12:42:14 +00:00
|
|
|
|
// Track changed → drop cached artwork so the source can refetch
|
|
|
|
|
|
// (Spotify does URL-based, Apple Music does raw-bytes-via-temp-file).
|
|
|
|
|
|
if self.title != info.title || self.artist != info.artist {
|
|
|
|
|
|
self.albumArt = nil
|
|
|
|
|
|
}
|
2026-04-18 18:27:21 +00:00
|
|
|
|
self.title = info.title
|
|
|
|
|
|
self.artist = info.artist
|
|
|
|
|
|
self.album = info.album
|
|
|
|
|
|
self.duration = info.duration
|
|
|
|
|
|
self.elapsedTime = info.elapsedTime
|
|
|
|
|
|
self.isPlaying = info.isPlaying
|
|
|
|
|
|
self.sourceName = info.source
|
|
|
|
|
|
self.sourceBundleId = info.bundleId
|
|
|
|
|
|
self.lastChromeTabURL = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func apply(chrome info: ChromeTrackInfo) {
|
|
|
|
|
|
self.title = info.title
|
|
|
|
|
|
self.artist = info.artist
|
|
|
|
|
|
self.album = ""
|
|
|
|
|
|
self.duration = info.duration
|
|
|
|
|
|
self.elapsedTime = info.elapsedTime
|
|
|
|
|
|
self.isPlaying = info.isPlaying
|
|
|
|
|
|
self.sourceName = info.sourceName
|
|
|
|
|
|
self.sourceBundleId = ChromeWebSource.bundleId
|
|
|
|
|
|
self.lastChromeTabURL = info.tabURL
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func clearTrack() {
|
|
|
|
|
|
title = ""
|
|
|
|
|
|
artist = ""
|
|
|
|
|
|
album = ""
|
|
|
|
|
|
albumArt = nil
|
|
|
|
|
|
isPlaying = false
|
|
|
|
|
|
duration = 0
|
|
|
|
|
|
elapsedTime = 0
|
|
|
|
|
|
sourceName = ""
|
|
|
|
|
|
sourceBundleId = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Playback timer
|
|
|
|
|
|
|
|
|
|
|
|
private func updatePlaybackTimer() {
|
|
|
|
|
|
playbackTimer?.invalidate()
|
|
|
|
|
|
playbackTimer = nil
|
|
|
|
|
|
guard isPlaying, duration > 0 else { return }
|
|
|
|
|
|
playbackTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
guard let self, self.isPlaying else { return }
|
|
|
|
|
|
self.elapsedTime = min(self.elapsedTime + 1.0, self.duration)
|
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
|
|
|
|
self.updateCurrentLyricIndex()
|
2026-04-18 18:27:21 +00:00
|
|
|
|
if self.elapsedTime >= self.duration {
|
|
|
|
|
|
self.playbackTimer?.invalidate()
|
|
|
|
|
|
self.playbackTimer = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Controls
|
|
|
|
|
|
|
|
|
|
|
|
func togglePlayPause() {
|
|
|
|
|
|
// Optimistically flip so the UI feels responsive.
|
|
|
|
|
|
let shouldPlay = !isPlaying
|
|
|
|
|
|
isPlaying = shouldPlay
|
|
|
|
|
|
updatePlaybackTimer()
|
2026-04-20 07:07:56 +00:00
|
|
|
|
rearmPoll() // isPlaying flipped → maybe change poll cadence
|
2026-04-18 18:27:21 +00:00
|
|
|
|
|
|
|
|
|
|
switch stickySource {
|
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
|
|
|
|
case .mediaRemoteAdapter:
|
|
|
|
|
|
mediaRemoteAdapter?.sendCommand(2) // kMRATogglePlayPause
|
2026-04-18 18:27:21 +00:00
|
|
|
|
case .spotify:
|
|
|
|
|
|
SpotifyAppleScript.togglePlay()
|
|
|
|
|
|
case .appleMusic:
|
|
|
|
|
|
AppleMusicAppleScript.togglePlay()
|
|
|
|
|
|
case .chrome:
|
|
|
|
|
|
let url = lastChromeTabURL.isEmpty ? nil : lastChromeTabURL
|
|
|
|
|
|
Task { _ = await ChromeWebSource.togglePlay(shouldPlay: shouldPlay, preferredURL: url) }
|
|
|
|
|
|
case .mediaRemote, .none:
|
|
|
|
|
|
mediaRemote.sendCommand(.togglePlayPause)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Confirm from the real source after a short delay.
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
scheduleRefresh(after: 0.1)
|
2026-04-18 18:27:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func nextTrack() {
|
|
|
|
|
|
switch stickySource {
|
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
|
|
|
|
case .mediaRemoteAdapter:
|
|
|
|
|
|
mediaRemoteAdapter?.sendCommand(4) // kMRANextTrack
|
2026-04-18 18:27:21 +00:00
|
|
|
|
case .spotify:
|
|
|
|
|
|
SpotifyAppleScript.next()
|
|
|
|
|
|
case .appleMusic:
|
|
|
|
|
|
AppleMusicAppleScript.next()
|
|
|
|
|
|
case .chrome:
|
|
|
|
|
|
// Chrome has no generic "next" control across sites.
|
|
|
|
|
|
mediaRemote.sendCommand(.nextTrack)
|
|
|
|
|
|
case .mediaRemote, .none:
|
|
|
|
|
|
mediaRemote.sendCommand(.nextTrack)
|
|
|
|
|
|
}
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
scheduleRefresh(after: 0.1)
|
2026-04-18 18:27:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func previousTrack() {
|
|
|
|
|
|
switch stickySource {
|
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
|
|
|
|
case .mediaRemoteAdapter:
|
|
|
|
|
|
mediaRemoteAdapter?.sendCommand(5) // kMRAPreviousTrack
|
2026-04-18 18:27:21 +00:00
|
|
|
|
case .spotify:
|
|
|
|
|
|
SpotifyAppleScript.previous()
|
|
|
|
|
|
case .appleMusic:
|
|
|
|
|
|
AppleMusicAppleScript.previous()
|
|
|
|
|
|
case .chrome:
|
|
|
|
|
|
mediaRemote.sendCommand(.previousTrack)
|
|
|
|
|
|
case .mediaRemote, .none:
|
|
|
|
|
|
mediaRemote.sendCommand(.previousTrack)
|
|
|
|
|
|
}
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
scheduleRefresh(after: 0.1)
|
2026-04-18 18:27:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func seek(to time: TimeInterval) {
|
|
|
|
|
|
let clamped = max(0, min(time, duration > 0 ? duration : time))
|
|
|
|
|
|
elapsedTime = clamped
|
|
|
|
|
|
updatePlaybackTimer()
|
|
|
|
|
|
|
|
|
|
|
|
switch stickySource {
|
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
|
|
|
|
case .mediaRemoteAdapter:
|
|
|
|
|
|
mediaRemoteAdapter?.seek(clamped)
|
2026-04-18 18:27:21 +00:00
|
|
|
|
case .spotify:
|
|
|
|
|
|
SpotifyAppleScript.seek(to: clamped)
|
|
|
|
|
|
case .appleMusic:
|
|
|
|
|
|
AppleMusicAppleScript.seek(to: clamped)
|
|
|
|
|
|
case .chrome:
|
|
|
|
|
|
let url = lastChromeTabURL.isEmpty ? nil : lastChromeTabURL
|
|
|
|
|
|
Task { _ = await ChromeWebSource.seek(to: clamped, preferredURL: url) }
|
|
|
|
|
|
case .mediaRemote, .none:
|
|
|
|
|
|
mediaRemote.setElapsedTime(clamped)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
v2.0.4: latency razor — event-driven + running-app gate + parallel probing
Target: push state-change detection latency under 200ms in the common case,
and cold start under 2s.
Changes:
1. Event-driven primary path, poll becomes safety-net
- Poll interval 1.5s → 15s. Was firing 40 AppleScript probes per minute
on a Mac that's playing nothing.
- MediaRemote notifications + DistributedNotificationCenter broadcasts
(com.spotify.client.PlaybackStateChanged,
com.apple.Music.playerInfo, com.apple.iTunes.playerInfo)
already handle track changes in <100ms. The 1.5s poll was just
backup, and now 15s is enough backup.
2. NSWorkspace launch/terminate observers
- New observers on NSWorkspace.didLaunchApplicationNotification +
didTerminateApplicationNotification. When Spotify, Apple Music, or
Chrome launches / quits, refresh fires immediately instead of
waiting for the next poll. Beats the old path by up to 15s on
first-launch-of-day scenarios.
3. Running-app gate (NSWorkspace.runningApplications)
- Each source now exposes `static var isRunning` via
NSWorkspace.shared.runningApplications.contains(bundleId).
- Router checks before probing. AppleScript `with timeout of 2 seconds`
still trips when the target app isn't running, so avoiding those
probes saves up to 6s per refresh on a clean Mac.
4. MediaRemote 15.4+ entitlement memoization
- When MRMediaRemoteGetNowPlayingInfo returns an empty dict AND at
least one player app is running (likelyBlocked heuristic), mark
MediaRemote blocked for 60s and skip in the router. Saves ~50ms
per refresh on restricted macOS versions and lets the first-pass
AppleScript probe happen without a preceding MR round-trip.
- Retries every 60s in case the gate state changes (macOS minor
update / user-granted entitlement).
5. Parallel fallback probing
- Old router was serial: MediaRemote → Spotify → Music → Chrome.
Cold start worst-case 4-6s when all three AppleScript sources
trip their 2s timeouts.
- New router uses `async let` to fan out every live candidate
concurrently. First-in-priority-order non-nil result wins.
Cold start worst-case now ≈ slowest single AppleScript probe.
6. Sticky-source fast path survives
- When the last-successful source is still a live candidate
(its app still running, MR still not blocked), try it alone
first. On steady-state playback this is one round-trip per
refresh, same as before.
7. Transport control perceived latency
- scheduleRefresh(after: 0.3) → 0.1 for togglePlay/next/prev/seek.
UI already flips optimistically; the 100ms re-sync is enough
to catch the real app state without feeling laggy.
Reference: Atoll (github.com/Ebullioscopic/Atoll) uses a bundled
mediaremote-adapter framework + Perl stream client to bypass the
macOS 15.4 MediaRemote entitlement gate entirely. That's a bigger
lift and left for a future phase — this commit wrings out the latency
that's achievable without that adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:02:36 +00:00
|
|
|
|
scheduleRefresh(after: 0.1)
|
2026-04-18 18:27:21 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func scheduleRefresh(after delay: TimeInterval) {
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
|
|
|
|
self.refresh()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Shared AppleScript + network helpers (module-level)
|
|
|
|
|
|
|
|
|
|
|
|
/// Background queue dedicated to NSAppleScript. NSAppleScript is documented
|
|
|
|
|
|
/// as thread safe only within a single thread, so we keep all invocations
|
|
|
|
|
|
/// serial on this queue and marshal results back via async continuations.
|
|
|
|
|
|
private let appleScriptQueue = DispatchQueue(
|
|
|
|
|
|
label: "mio-plugin-music.applescript",
|
|
|
|
|
|
qos: .userInitiated
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
/// Execute an AppleScript source string asynchronously. Returns the string
|
|
|
|
|
|
/// value of the result or nil on error. Error numbers are split into:
|
|
|
|
|
|
/// -600 : application is not running (normal, silent)
|
|
|
|
|
|
/// -1728 : Apple Event descriptor error (often benign, silent)
|
|
|
|
|
|
/// other : logged via NSLog with a tag
|
|
|
|
|
|
func runAppleScript(_ source: String, tag: String) async -> String? {
|
|
|
|
|
|
await withCheckedContinuation { continuation in
|
|
|
|
|
|
appleScriptQueue.async {
|
|
|
|
|
|
var errorDict: NSDictionary?
|
|
|
|
|
|
guard let script = NSAppleScript(source: source) else {
|
|
|
|
|
|
continuation.resume(returning: nil)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let result = script.executeAndReturnError(&errorDict)
|
|
|
|
|
|
if let errorDict {
|
|
|
|
|
|
let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
|
2026-04-19 03:09:29 +00:00
|
|
|
|
// Silence known-expected error codes:
|
|
|
|
|
|
// -600 = application not running
|
|
|
|
|
|
// -1712 = errAETimeout (our `with timeout of N seconds` firing)
|
|
|
|
|
|
// -1728 = AEError, generic Apple Event descriptor issue
|
|
|
|
|
|
if num != -600 && num != -1712 && num != -1728 {
|
2026-04-18 18:27:21 +00:00
|
|
|
|
let msg = errorDict[NSAppleScript.errorMessage] as? String ?? "<no message>"
|
|
|
|
|
|
NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
|
|
|
|
|
|
}
|
|
|
|
|
|
continuation.resume(returning: nil)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
continuation.resume(returning: result.stringValue)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Run an AppleScript where we don't care about the return value (transport
|
|
|
|
|
|
/// controls). Errors still respect the -600 / -1728 silence list.
|
|
|
|
|
|
func runAppleScriptFireAndForget(_ source: String, tag: String) {
|
|
|
|
|
|
appleScriptQueue.async {
|
|
|
|
|
|
var errorDict: NSDictionary?
|
|
|
|
|
|
guard let script = NSAppleScript(source: source) else { return }
|
|
|
|
|
|
_ = script.executeAndReturnError(&errorDict)
|
|
|
|
|
|
if let errorDict {
|
|
|
|
|
|
let num = errorDict[NSAppleScript.errorNumber] as? Int ?? 0
|
|
|
|
|
|
if num != -600 && num != -1728 {
|
|
|
|
|
|
let msg = errorDict[NSAppleScript.errorMessage] as? String ?? "<no message>"
|
|
|
|
|
|
NSLog("[mio-plugin-music] AppleScript error [\(tag)] \(num): \(msg)")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Download image data asynchronously. Returns nil on any failure.
|
|
|
|
|
|
func downloadImage(from url: URL) async -> NSImage? {
|
|
|
|
|
|
await withCheckedContinuation { continuation in
|
|
|
|
|
|
URLSession.shared.dataTask(with: url) { data, _, _ in
|
|
|
|
|
|
guard let data, let image = NSImage(data: data) else {
|
|
|
|
|
|
continuation.resume(returning: nil)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
continuation.resume(returning: image)
|
|
|
|
|
|
}.resume()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|