904b9b3d-c0eb-42f3-acef-958.../Sources/sources/AppleMusicAppleScript.swift
徐翔宇 336b2266e8 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 15:02:36 +08:00

147 lines
5.4 KiB
Swift

//
// 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 {
static let bundleId = "com.apple.Music"
private static let sourceName = "Apple Music"
/// 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
}
}
// MARK: - Fetch
static func fetch() async -> AppleScriptTrackInfo? {
// 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.
let script = """
tell application "System Events"
if not (exists process "Music") then return "NOT_RUNNING"
end tell
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
"""
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
)
}
// 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
}
// 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"
)
}
}