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:
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
8
build.ts
8
build.ts
@@ -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");
|
||||||
|
|||||||
@@ -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 {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user