revert(macos): drop PodTui.app / bundled-mpv Now Playing attribution

The pinned mpv binary breaks on brew ffmpeg major drift (libavcodec.62 vs
.63 shipped a dead player); making it self-contained costs ~100MB for an
app icon. Removing the whole machinery: build.ts app-bundle assembly,
audio-player bundled-binary resolver + probe (spawns PATH mpv again),
CI mpv install + bundle smoke checks, AppIcon.icns, README note.
Cover-art staging (curl + --cover-art-files) is unrelated and stays.
This commit is contained in:
2026-08-11 23:09:44 -04:00
parent 3d6d4918bc
commit 33af131b77
5 changed files with 7 additions and 192 deletions

View File

@@ -50,10 +50,7 @@ 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
# mpv is required for the release bundle: build.ts copies it into brew install fftw
# 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
@@ -79,16 +76,6 @@ 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

@@ -54,14 +54,6 @@ Linux (arm64/x64). Pick whichever fits your platform.
brew install mikefreno/tap/podtui brew install mikefreno/tap/podtui
``` ```
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
through a copy of mpv that lives **inside the bundle**, so macOS attributes
the Now Playing session to PodTui — the Control Center / lock-screen entry
shows the PodTui name and icon, and podcast cover art as its artwork —
rather than a blank placeholder for an unbundled binary. Installers can drop
`PodTui.app` into `/Applications`; the `podtui` entry point should point at
`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used.
### 2. Standalone tarball (all platforms) ### 2. Standalone tarball (all platforms)
Grab `podtui-<platform>-<arch>.tar.gz` from the latest Grab `podtui-<platform>-<arch>.tar.gz` from the latest

Binary file not shown.

124
build.ts
View File

@@ -131,130 +131,6 @@ if (COMPILE) {
} }
} }
// macOS app bundle: PodTui.app. We run our audio backend (mpv) from
// INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now
// Playing session to PodTui — the source-app icon + name in Control
// Center / lock screen — instead of a blank placeholder for an
// unbundled binary. AudioPlayer's resolver prefers this sibling.
if (platform === "darwin") {
const appRoot = join(tarRoot, "PodTui.app");
const macosDir = join(appRoot, "Contents", "MacOS");
const resDir = join(appRoot, "Contents", "Resources");
mkdirSync(macosDir, { recursive: true });
mkdirSync(resDir, { recursive: true });
copyFileSync(outfile, join(macosDir, "podtui"));
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(macosDir, lib));
}
const mpvResolve = Bun.spawnSync(["which", "mpv"]);
const mpvPath =
mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : "";
if (mpvPath) {
copyFileSync(mpvPath, join(macosDir, "mpv"));
} else {
// 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");
if (existsSync(icnsSrc)) {
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
} else {
console.warn(
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
);
}
// Version for the bundle comes from src/index.tsx (single source of
// truth — release.yml requires bumping it in the tag commit).
const srcIndex = await Bun.file(join("src", "index.tsx")).text();
const versionMatch = srcIndex.match(/const VERSION = "([^"]+)"/);
const bundleVersion = versionMatch?.[1];
if (!bundleVersion) {
console.error("Error: could not read VERSION from src/index.tsx");
process.exit(1);
}
Bun.write(
join(appRoot, "Contents", "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>PodTui</string>
<key>CFBundleDisplayName</key>
<string>PodTui</string>
<key>CFBundleIdentifier</key>
<string>com.mikefreno.podtui</string>
<key>CFBundleExecutable</key>
<string>podtui</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleShortVersionString</key>
<string>${bundleVersion}</string>
<key>CFBundleVersion</key>
<string>${bundleVersion}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
</dict>
</plist>
`,
);
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
// ID cert for release builds); default ad-hoc.
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
const sign = Bun.spawnSync([
"codesign",
"--force",
"--deep",
"-s",
signIdentity,
appRoot,
]);
if (sign.exitCode !== 0) {
console.warn(
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
);
}
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
// resolves the Now Playing client from the registering process's
// code-signing identifier — without an explicit --identifier codesign
// stamps "mpv" (its basename) and the audio center shows a blank
// placeholder. Must run after the bundle sign above (a later bundle
// re-seal would re-derive the basename identifier).
const signMpv = Bun.spawnSync([
"codesign",
"--force",
"-s",
signIdentity,
"--identifier",
"com.mikefreno.podtui",
join(macosDir, "mpv"),
]);
if (signMpv.exitCode !== 0) {
console.warn(
`Warning: nested mpv signing failed (${signMpv.stderr
.toString()
.trim()}) — Now Playing attribution won't work`,
);
}
console.log(`App bundle: ${appRoot}`);
}
const tar = Bun.spawnSync([ const tar = Bun.spawnSync([
"tar", "tar",
"-czf", "-czf",

View File

@@ -29,7 +29,7 @@
import { platform } from "os"; import { platform } from "os";
import { existsSync, unlinkSync } from "fs"; import { existsSync, unlinkSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { dirname, join } from "path"; import { join } from "path";
import type { Socket, Subprocess } from "bun"; import type { Socket, Subprocess } from "bun";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
@@ -115,53 +115,13 @@ function mpvSocketPath(): string {
// Per-instance, not just per-pid: tests (and backend switching) create // Per-instance, not just per-pid: tests (and backend switching) create
// several MpvBackend objects in ONE bun process — a pid-only path makes // several MpvBackend objects in ONE bun process — a pid-only path makes
// every daemon bind the same socket, so later daemons unlink the path // every daemon bind the same socket, so later daemons unlink the path
// out from under earlier ones and IPC cross-talks between backends. // out from under each other.
return join( return join(
tmpdir(), tmpdir(),
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`, `podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
); );
} }
/**
* mpv executable to use. Prefers a sibling `mpv` inside the app 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
* and name in Control Center — instead of a blank placeholder for an
* 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) && mpvLaunches(bundled)) {
resolved = bundled;
}
} catch {
/* process.execPath unusable — fall through to PATH */
}
if (!resolved) resolved = which("mpv");
resolvedMpv = resolved;
return resolved;
}
// ── mpv JSON IPC connection ───────────────────────────────────────── // ── mpv JSON IPC connection ─────────────────────────────────────────
// //
// One persistent Unix-socket connection to the resident mpv daemon. Lines // One persistent Unix-socket connection to the resident mpv daemon. Lines
@@ -377,7 +337,7 @@ export class MpvBackend implements AudioBackend {
this.proc = Bun.spawn( this.proc = Bun.spawn(
[ [
resolveMpvBinary() ?? "mpv", "mpv",
"--no-video", "--no-video",
"--no-terminal", "--no-terminal",
"--really-quiet", "--really-quiet",
@@ -799,7 +759,7 @@ export interface DetectedPlayer {
export function detectPlayers(): DetectedPlayer[] { export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = []; const players: DetectedPlayer[] = [];
const mpvPath = resolveMpvBinary(); const mpvPath = which("mpv");
if (mpvPath) { if (mpvPath) {
players.push({ players.push({
name: "mpv", name: "mpv",
@@ -833,13 +793,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend; if (backend) return backend;
} }
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend(); return which("mpv") ? new MpvBackend() : new NoopBackend();
} }
function createBackendByName(name: BackendName): AudioBackend | null { function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) { switch (name) {
case "mpv": case "mpv":
return resolveMpvBinary() ? new MpvBackend() : null; return which("mpv") ? new MpvBackend() : null;
case "none": case "none":
return new NoopBackend(); return new NoopBackend();
} }