From 33887571853c6916112bc6dabe9df454e0c75420 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 13:37:43 -0400 Subject: [PATCH] fix(macos): hard-fail bundle without mpv, PATH fallback, CI mpv install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build.ts: darwin compile now exits 1 when mpv is absent — CI can no longer ship a PodTui.app without its bundled player (0.4.0 did, killing Now Playing attribution). - audio-player.ts: the bundled mpv is probed (--version) at first resolve; it links against brew's dylibs, and a Homebrew ffmpeg major upgrade can break it — fall back to PATH mpv so audio survives (icon degrades to blank instead of playback dying). Probed once per process. - release.yml: brew install mpv on darwin runners; smoke test now asserts the tarball's PodTui.app has a launchable mpv signed with the com.mikefreno.podtui identifier. --- .github/workflows/release.yml | 15 ++++++++- build.ts | 8 +++-- src/utils/audio-player.ts | 59 +++++++++++++++++++++++++++++++++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fbd16a..bc02803 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,10 @@ jobs: - name: Install fftw (cavacore build dependency) run: | if uname -s | grep -qi darwin; then - brew install fftw + # mpv is required for the release bundle: build.ts copies it into + # PodTui.app (signed with the podtui bundle identifier) so macOS + # Now Playing shows the PodTui icon instead of a blank placeholder. + brew install fftw mpv else sudo apt-get update sudo apt-get install -y libfftw3-dev @@ -76,6 +79,16 @@ jobs: printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml" cd "$SMOKE_DIR" ./podtui-*/podtui --version + # macOS tarballs must ship PodTui.app with a working bundled mpv + # carrying the podtui bundle identifier — otherwise Now Playing + # attribution silently regresses to a blank icon. + if [ "${{ matrix.plat }}" = "darwin" ]; then + MPV=./podtui-*/PodTui.app/Contents/MacOS/mpv + test -x $MPV || { echo "PodTui.app missing bundled mpv"; exit 1; } + $MPV --version >/dev/null || { echo "bundled mpv does not launch"; exit 1; } + codesign -dvv $MPV 2>&1 | grep -q "Identifier=com.mikefreno.podtui" \ + || { echo "bundled mpv lacks podtui signing identifier"; exit 1; } + fi - name: Upload artifact uses: actions/upload-artifact@v6 diff --git a/build.ts b/build.ts index 1b005fd..3cd9d4d 100644 --- a/build.ts +++ b/build.ts @@ -155,9 +155,13 @@ if (COMPILE) { if (mpvPath) { copyFileSync(mpvPath, join(macosDir, "mpv")); } else { - console.warn( - "Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)", + // A darwin release tarball without a bundled mpv silently ships + // without Now Playing attribution (blank icon). Fail loudly so CI + // can't produce it — the runner must have mpv installed. + console.error( + "Error: mpv not found in PATH — PodTui.app requires a bundled mpv for macOS Now Playing attribution (brew install mpv on the build machine)", ); + process.exit(1); } const icnsSrc = join("assets", "App Icon", "AppIcon.icns"); diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index 9eb2ff6..1556677 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -40,6 +40,15 @@ export interface AudioBackend { getPosition(): Promise; getDuration(): Promise; isPlaying(): boolean; + /** Live pause state: `true` paused, `false` playing, `undefined` when + * the read failed (callers keep the last known state). Unlike + * `isPlaying()` — which reflects only commands PodTUI sent — this + * reflects the player's real state, including pauses initiated + * OUTSIDE PodTUI (system sleep/lock, AirPod removal, device swap, + * OS media keys, the Now Playing center). */ + getPauseState(): Promise; + /** True while the player process is running (regardless of pause). */ + isAlive(): boolean; dispose(): void; } @@ -80,16 +89,39 @@ function mpvSocketPath(): string { * (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle * makes macOS attribute its Now Playing session to PodTui — source-app icon * and name in Control Center — instead of a blank placeholder for an - * unbundled binary. Falls back to PATH so dev runs and Linux keep working. + * unbundled binary. + * + * The bundled copy is verified to actually launch: it links against brew's + * dylibs by absolute path, and a Homebrew ffmpeg major upgrade can break it + * (dylib gone → immediate non-zero exit). If the bundled binary can't run, + * fall back to PATH mpv so audio keeps working — the icon degrades to blank + * rather than playback dying. Probed once per process. */ +let resolvedMpv: string | null | undefined; // undefined = not yet probed + +function mpvLaunches(binary: string): boolean { + try { + const proc = Bun.spawnSync([binary, "--version"], { timeout: 3000 }); + return proc.exitCode === 0; + } catch { + return false; + } +} + function resolveMpvBinary(): string | null { + if (resolvedMpv !== undefined) return resolvedMpv; + let resolved: string | null = null; try { const bundled = join(dirname(process.execPath), "mpv"); - if (existsSync(bundled)) return bundled; + if (existsSync(bundled) && mpvLaunches(bundled)) { + resolved = bundled; + } } catch { /* process.execPath unusable — fall through to PATH */ } - return which("mpv"); + if (!resolved) resolved = which("mpv"); + resolvedMpv = resolved; + return resolved; } // ── mpv Backend ────────────────────────────────────────────────────── @@ -104,6 +136,7 @@ export class MpvBackend implements AudioBackend { private _duration = 0; private _volume = 100; private _speed = 1; + private _exited = false; async play(url: string, opts?: PlayOptions): Promise { await this.stop(); @@ -151,6 +184,7 @@ export class MpvBackend implements AudioBackend { }); this._playing = true; + this._exited = false; this._position = opts?.startPosition ?? 0; this._volume = Math.round((opts?.volume ?? 1) * 100); this._speed = opts?.speed ?? 1; @@ -165,6 +199,7 @@ export class MpvBackend implements AudioBackend { this.proc.exited .then(() => { this._playing = false; + this._exited = true; }) .catch(() => {}); } @@ -355,6 +390,17 @@ export class MpvBackend implements AudioBackend { return this._playing; } + async getPauseState(): Promise { + if (!this.isAlive()) return undefined; + const p = await this.getProperty("pause"); + if (p === undefined) return undefined; + return p === 1; + } + + isAlive(): boolean { + return this.proc !== null && !this._exited; + } + dispose(): void { this.stop(); } @@ -380,6 +426,13 @@ class NoopBackend implements AudioBackend { isPlaying(): boolean { return false; } + async getPauseState(): Promise { + // Nothing plays on the no-op backend — never externally paused. + return false; + } + isAlive(): boolean { + return false; + } dispose(): void {} }