fix(macos): hard-fail bundle without mpv, PATH fallback, CI mpv install

- 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.
This commit is contained in:
2026-08-11 13:37:43 -04:00
parent 8049d02457
commit 3388757185
3 changed files with 76 additions and 6 deletions

View File

@@ -50,7 +50,10 @@ jobs:
- name: Install fftw (cavacore build dependency) - name: Install fftw (cavacore build dependency)
run: | run: |
if uname -s | grep -qi darwin; then 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 else
sudo apt-get update sudo apt-get update
sudo apt-get install -y libfftw3-dev sudo apt-get install -y libfftw3-dev
@@ -76,6 +79,16 @@ jobs:
printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml" printf 'preload = ["./definitely-missing.ts"]\n' > "$SMOKE_DIR/bunfig.toml"
cd "$SMOKE_DIR" cd "$SMOKE_DIR"
./podtui-*/podtui --version ./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 - name: Upload artifact
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v6

View File

@@ -155,9 +155,13 @@ if (COMPILE) {
if (mpvPath) { if (mpvPath) {
copyFileSync(mpvPath, join(macosDir, "mpv")); copyFileSync(mpvPath, join(macosDir, "mpv"));
} else { } else {
console.warn( // A darwin release tarball without a bundled mpv silently ships
"Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)", // 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"); const icnsSrc = join("assets", "App Icon", "AppIcon.icns");

View File

@@ -40,6 +40,15 @@ export interface AudioBackend {
getPosition(): Promise<number>; getPosition(): Promise<number>;
getDuration(): Promise<number>; getDuration(): Promise<number>;
isPlaying(): boolean; 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<boolean | undefined>;
/** True while the player process is running (regardless of pause). */
isAlive(): boolean;
dispose(): void; dispose(): void;
} }
@@ -80,16 +89,39 @@ function mpvSocketPath(): string {
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle * (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
* makes macOS attribute its Now Playing session to PodTui — source-app icon * makes macOS attribute its Now Playing session to PodTui — source-app icon
* and name in Control Center — instead of a blank placeholder for an * 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 { function resolveMpvBinary(): string | null {
if (resolvedMpv !== undefined) return resolvedMpv;
let resolved: string | null = null;
try { try {
const bundled = join(dirname(process.execPath), "mpv"); const bundled = join(dirname(process.execPath), "mpv");
if (existsSync(bundled)) return bundled; if (existsSync(bundled) && mpvLaunches(bundled)) {
resolved = bundled;
}
} catch { } catch {
/* process.execPath unusable — fall through to PATH */ /* process.execPath unusable — fall through to PATH */
} }
return which("mpv"); if (!resolved) resolved = which("mpv");
resolvedMpv = resolved;
return resolved;
} }
// ── mpv Backend ────────────────────────────────────────────────────── // ── mpv Backend ──────────────────────────────────────────────────────
@@ -104,6 +136,7 @@ export class MpvBackend implements AudioBackend {
private _duration = 0; private _duration = 0;
private _volume = 100; private _volume = 100;
private _speed = 1; private _speed = 1;
private _exited = false;
async play(url: string, opts?: PlayOptions): Promise<void> { async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop(); await this.stop();
@@ -151,6 +184,7 @@ export class MpvBackend implements AudioBackend {
}); });
this._playing = true; this._playing = true;
this._exited = false;
this._position = opts?.startPosition ?? 0; this._position = opts?.startPosition ?? 0;
this._volume = Math.round((opts?.volume ?? 1) * 100); this._volume = Math.round((opts?.volume ?? 1) * 100);
this._speed = opts?.speed ?? 1; this._speed = opts?.speed ?? 1;
@@ -165,6 +199,7 @@ export class MpvBackend implements AudioBackend {
this.proc.exited this.proc.exited
.then(() => { .then(() => {
this._playing = false; this._playing = false;
this._exited = true;
}) })
.catch(() => {}); .catch(() => {});
} }
@@ -355,6 +390,17 @@ export class MpvBackend implements AudioBackend {
return this._playing; return this._playing;
} }
async getPauseState(): Promise<boolean | undefined> {
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 { dispose(): void {
this.stop(); this.stop();
} }
@@ -380,6 +426,13 @@ class NoopBackend implements AudioBackend {
isPlaying(): boolean { isPlaying(): boolean {
return false; return false;
} }
async getPauseState(): Promise<boolean | undefined> {
// Nothing plays on the no-op backend — never externally paused.
return false;
}
isAlive(): boolean {
return false;
}
dispose(): void {} dispose(): void {}
} }