Commit Graph

9 Commits

Author SHA1 Message Date
徐翔宇
d5934b06b0 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 15:19:40 +08:00
徐翔宇
113dd31275 v2.0.5: adaptive polling — Apple Music 0.8s when playing
v2.0.4's 15s safety-net poll broke Apple Music latency because macOS 14+
Music.app doesn't reliably broadcast com.apple.Music.playerInfo, and
MediaRemote is 15.4-gated. With no event source actually firing, 15s
between polls = 15s track-change lag.

Poll interval is now computed from stickySource + isPlaying:
- Apple Music playing     → 0.8s (no reliable event source)
- Chrome playing          → 1.2s (no event source, web audio too)
- Spotify playing         → 3.0s (playerInfo broadcast is fast,
                                   poll is just backup)
- MediaRemote playing     → 3.0s (MR notifications cover it)
- Idle / nothing playing  → 10.0s (NSWorkspace launch observer will
                                    wake us instantly)

rearmPoll() is called after every stickySource change + after the
optimistic isPlaying flip in togglePlayPause, so the cadence adapts
within a single RunLoop tick. Cheap: if the new interval equals the
current one within 0.01s, skip the Timer re-alloc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:07:56 +08:00
徐翔宇
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
徐翔宇
63885fe121 v2.0.3: compact panel via host size hint + Apple Music artwork + faster poll
Host-facing:
- Info.plist requests a 440x340 expanded panel via the new
  MioPluginPreferredWidth/MioPluginPreferredHeight keys (MioIsland
  v2.1.8+). Old hosts ignore the keys and use their default.

UI:
- Fix vertical stretching of the playing card. Outer ZStack now centers
  children instead of wrapping in a maxHeight:.infinity frame which was
  letting an inner Spacer propagate fill-height up to the top-level VStack.
- Hero HStack clipped to album art height (128pt) so the meta column
  can't bleed a fill-height hint upward either.

Data:
- Apple Music artwork is now fetched via a temp file (write artwork data
  of current track to /tmp, load NSImage from disk). First-class cover art
  instead of the generic music.note placeholder.
- apply(appleScript:) clears albumArt when the track identity changes so
  the next refresh reloads cover art for the new track.

Latency:
- Poll interval 3s → 1.5s. Track changes typically reflect within 2s.
- Also subscribe to the legacy com.apple.iTunes.playerInfo distributed
  notification in addition to com.apple.Music.playerInfo — some builds of
  Music.app still emit the iTunes name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:42:14 +08:00
徐翔宇
c67ddd0024 v2.0.1: compact UI + AppleScript timeouts
UI polish (ExpandedView rewrite):
- Horizontal hero row: 128×128 album art on the left, title/artist/
  album + source badge on the right. Half the vertical footprint of
  v2.0.0 at the same info density.
- Dropped the "NOW PLAYING" eyebrow (redundant with the source badge).
- Tightened outer padding 28 → 20, inter-section spacing 22-28 → 16.
- Play button 56 → 48, prev/next 44 → 36; still 44pt tap targets via
  the invisible hover frame.

AppleScript timeout fix (the real bug, unrelated to UI):
- Every fetch() script now wraps the `tell application` block in
  `with timeout of N seconds` (2s for Spotify/Music, 3s for Chrome).
- Music.app hanging was stalling the entire source router for 120s
  (default AppleEvent timeout), freezing the UI on stale Spotify data.
- runAppleScript() suppresses error -1712 (errAETimeout) alongside
  existing -600 / -1728 — expected, not noisy.

Info.plist: CFBundleShortVersionString 2.0.0 → 2.0.1,
CFBundleVersion 2 → 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:09:29 +08:00
徐翔宇
64daaa3371 v2.0.0: full rewrite with multi-source NowPlaying
Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+)
with a layered design that handles four playback sources with sticky source
priority routing:

  NowPlayingState (orchestrator, @MainActor, 3s poll + notifications)
    ├─ MediaRemote (private framework, dlopen)
    ├─ Spotify AppleScript (desktop)
    ├─ Apple Music AppleScript (desktop)
    └─ Chrome JS injection (YouTube / SoundCloud / web music)

UI:
  - Large album art with color-extracted gradient background
  - Title / artist / album + source badge
  - Draggable seek bar with hover-grow affordance
  - Prev / Play·Pause (56pt lime button) / Next controls
  - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing
  - Bi-lingual (zh / en), follows host appLanguage

Graceful degradation:
  - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required)
  - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version"
  - Empty state with hint to play something in supported apps

Build layout:
  Sources/              root (MioPlugin.swift contract + MusicPlugin principal)
  Sources/sources/      data sources
  Sources/ui/           SwiftUI views
  Sources/support/      ChineseAppDetector / HostVersionCheck / Localization

build.sh now recursively finds .swift under Sources/.

Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on
v1.0.0 can upgrade in place via the plugin store.

Requires: MioIsland host >= v2.1.7 for full functionality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:27:21 +08:00
xmqywx
68ca936944 chore: add .gitignore and clean build/ from history 2026-04-13 09:33:19 +08:00
xmqywx
93078fb2d9 docs: add README.md and README.zh-CN.md 2026-04-12 01:09:21 +08:00
xmqywx
d044a6f0c0 feat: MioIsland music player plugin — reads system NowPlaying 2026-04-11 23:37:11 +08:00