2026-04-18 18:27:21 +00:00
|
|
|
//
|
|
|
|
|
// AppleMusicAppleScript.swift
|
|
|
|
|
// MioIsland Music Plugin
|
|
|
|
|
//
|
|
|
|
|
// AppleScript bridge for the Music.app (macOS 10.15+). Compared to Spotify
|
|
|
|
|
// the duration field is already in seconds, and artwork is exposed as raw
|
|
|
|
|
// data rather than a URL so we have to ask for the "data size" and then
|
|
|
|
|
// fish the bytes out separately. For simplicity we skip artwork here and
|
|
|
|
|
// let MediaRemote (when available) or a future enhancement provide covers.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import AppKit
|
|
|
|
|
|
|
|
|
|
enum AppleMusicAppleScript {
|
2026-04-19 12:42:14 +00:00
|
|
|
static let bundleId = "com.apple.Music"
|
2026-04-18 18:27:21 +00:00
|
|
|
private static let sourceName = "Apple Music"
|
|
|
|
|
|
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
|
|
|
/// Fast check: is Music.app actually running? When false, skip
|
|
|
|
|
/// AppleScript — the 2s `with timeout` still trips but that's two
|
|
|
|
|
/// wasted seconds per refresh when the user doesn't use Apple Music.
|
|
|
|
|
static var isRunning: Bool {
|
|
|
|
|
NSWorkspace.shared.runningApplications.contains {
|
|
|
|
|
$0.bundleIdentifier == bundleId
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
// MARK: - Fetch
|
|
|
|
|
|
|
|
|
|
static func fetch() async -> AppleScriptTrackInfo? {
|
2026-04-19 03:09:29 +00:00
|
|
|
// Music.app occasionally stalls its AppleEvent handler (observed in
|
|
|
|
|
// macOS 15.x when the app is mid-sync). Without an explicit timeout
|
|
|
|
|
// each fetch inherits the 120-second default, which freezes the whole
|
|
|
|
|
// source router for 2 minutes. `with timeout of 2 seconds` raises
|
|
|
|
|
// errAETimeout (-1712) if Music doesn't respond quickly, and our
|
|
|
|
|
// Swift layer turns that into nil so the router can move on.
|
2026-04-18 18:27:21 +00:00
|
|
|
let script = """
|
|
|
|
|
tell application "System Events"
|
|
|
|
|
if not (exists process "Music") then return "NOT_RUNNING"
|
|
|
|
|
end tell
|
2026-04-19 03:09:29 +00:00
|
|
|
with timeout of 2 seconds
|
|
|
|
|
tell application "Music"
|
|
|
|
|
if player state is playing or player state is paused then
|
|
|
|
|
set trackName to name of current track
|
|
|
|
|
set trackArtist to artist of current track
|
|
|
|
|
set trackAlbum to album of current track
|
|
|
|
|
set trackDuration to duration of current track
|
|
|
|
|
set trackPosition to player position
|
|
|
|
|
set stateString to "PAUSED"
|
|
|
|
|
if player state is playing then set stateString to "PLAYING"
|
|
|
|
|
return stateString & "||" & trackName & "||" & trackArtist & "||" & trackAlbum & "||" & trackDuration & "||" & trackPosition
|
|
|
|
|
else
|
|
|
|
|
return "NOT_PLAYING"
|
|
|
|
|
end if
|
|
|
|
|
end tell
|
|
|
|
|
end timeout
|
2026-04-18 18:27:21 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
guard let raw = await runAppleScript(script, tag: "music") else { return nil }
|
|
|
|
|
if raw == "NOT_RUNNING" || raw == "NOT_PLAYING" { return nil }
|
|
|
|
|
|
|
|
|
|
let parts = raw.components(separatedBy: "||")
|
|
|
|
|
guard parts.count >= 6 else { return nil }
|
|
|
|
|
|
|
|
|
|
return AppleScriptTrackInfo(
|
|
|
|
|
title: parts[1],
|
|
|
|
|
artist: parts[2],
|
|
|
|
|
album: parts[3],
|
|
|
|
|
duration: TimeInterval(parts[4]) ?? 0,
|
|
|
|
|
elapsedTime: TimeInterval(parts[5]) ?? 0,
|
|
|
|
|
isPlaying: parts[0] == "PLAYING",
|
|
|
|
|
artworkURL: nil,
|
|
|
|
|
source: sourceName,
|
|
|
|
|
bundleId: bundleId
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 12:42:14 +00:00
|
|
|
// MARK: - Artwork
|
|
|
|
|
|
|
|
|
|
/// Apple Music stores artwork as embedded raw data (PNG/JPEG) rather than
|
|
|
|
|
/// a URL. The cheapest way to pull it via AppleScript is to write the
|
|
|
|
|
/// bytes to a temp file and load NSImage from it. The script writes to
|
|
|
|
|
/// /tmp/mio-apple-music-art.dat (fixed path — overwrites each call).
|
|
|
|
|
static func fetchArtwork() async -> NSImage? {
|
|
|
|
|
let tmpPath = "/tmp/mio-plugin-music-current-art.dat"
|
|
|
|
|
let script = """
|
|
|
|
|
tell application "System Events"
|
|
|
|
|
if not (exists process "Music") then return "NOT_RUNNING"
|
|
|
|
|
end tell
|
|
|
|
|
with timeout of 3 seconds
|
|
|
|
|
tell application "Music"
|
|
|
|
|
if player state is stopped then return "STOPPED"
|
|
|
|
|
try
|
|
|
|
|
set artData to data of artwork 1 of current track
|
|
|
|
|
set f to open for access POSIX file "\(tmpPath)" with write permission
|
|
|
|
|
set eof f to 0
|
|
|
|
|
write artData to f
|
|
|
|
|
close access f
|
|
|
|
|
return "OK"
|
|
|
|
|
on error errMsg
|
|
|
|
|
try
|
|
|
|
|
close access POSIX file "\(tmpPath)"
|
|
|
|
|
end try
|
|
|
|
|
return "NO_ARTWORK"
|
|
|
|
|
end try
|
|
|
|
|
end tell
|
|
|
|
|
end timeout
|
|
|
|
|
"""
|
|
|
|
|
guard let raw = await runAppleScript(script, tag: "music-art"),
|
|
|
|
|
raw == "OK" else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
let url = URL(fileURLWithPath: tmpPath)
|
|
|
|
|
return await Task.detached { NSImage(contentsOf: url) }.value
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 18:27:21 +00:00
|
|
|
// MARK: - Controls
|
|
|
|
|
|
|
|
|
|
static func togglePlay() {
|
|
|
|
|
runAppleScriptFireAndForget(
|
|
|
|
|
"tell application \"Music\" to playpause",
|
|
|
|
|
tag: "music-toggle"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func next() {
|
|
|
|
|
runAppleScriptFireAndForget(
|
|
|
|
|
"tell application \"Music\" to next track",
|
|
|
|
|
tag: "music-next"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func previous() {
|
|
|
|
|
runAppleScriptFireAndForget(
|
|
|
|
|
"tell application \"Music\" to previous track",
|
|
|
|
|
tag: "music-prev"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func seek(to time: TimeInterval) {
|
|
|
|
|
let clamped = max(0, time)
|
|
|
|
|
runAppleScriptFireAndForget(
|
|
|
|
|
"tell application \"Music\" to set player position to \(clamped)",
|
|
|
|
|
tag: "music-seek"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|