feat(macos): ship PodTui.app bundle so Now Playing shows our icon

mpv owns the macOS Now Playing session (it plays the audio), and an
unbundled binary renders as a blank placeholder for the source-app icon.
macOS has no public API for a third party to claim session ownership
(MPNowPlayingSession is iOS-only; the private MRMediaRemoteSetNowPlayingApplication
was removed from the shared cache), so instead we make the OWNING process
carry our bundle: the darwin tarball now includes PodTui.app with mpv copied
into Contents/MacOS. AudioPlayer resolves the sibling mpv first (falling
back to PATH), LaunchServices attributes the process to com.mikefreno.podtui,
and Control Center shows the PodTui icon + name with podcast cover art.
AppIcon.icns generated from the Xcode icon-composer exports.
This commit is contained in:
2026-08-10 17:56:54 -04:00
parent c52fa14e42
commit a9589e7686
4 changed files with 122 additions and 5 deletions

View File

@@ -54,6 +54,14 @@ 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.

View File

@@ -131,6 +131,91 @@ 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 {
console.warn(
"Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)",
);
}
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",
);
}
// Keep CFBundleShortVersionString in sync with src/index.tsx VERSION.
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>0.3.1</string>
<key>CFBundleVersion</key>
<string>0.3.1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
</dict>
</plist>
`,
);
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
const sign = Bun.spawnSync([
"codesign",
"--force",
"--deep",
"-s",
"-",
appRoot,
]);
if (sign.exitCode !== 0) {
console.warn(
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
);
}
console.log(`App bundle: ${appRoot}`);
}
const tar = Bun.spawnSync([ const tar = Bun.spawnSync([
"tar", "tar",
"-czf", "-czf",

View File

@@ -11,7 +11,7 @@
import { platform } from "os"; import { platform } from "os";
import { existsSync } from "fs"; import { existsSync } from "fs";
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { dirname, join } from "path";
import type { Socket, Subprocess } from "bun"; import type { Socket, Subprocess } from "bun";
// ── Types ──────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────
@@ -48,6 +48,7 @@ export interface PlayOptions {
volume?: number; volume?: number;
speed?: number; speed?: number;
mediaTitle?: string; mediaTitle?: string;
coverArtPath?: string;
} }
// ── Utilities ──────────────────────────────────────────────────────── // ── Utilities ────────────────────────────────────────────────────────
@@ -74,6 +75,23 @@ function mpvSocketPath(): string {
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`); return join(tmpdir(), `podtui-mpv-${process.pid}.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. Falls back to PATH so dev runs and Linux keep working.
*/
function resolveMpvBinary(): string | null {
try {
const bundled = join(dirname(process.execPath), "mpv");
if (existsSync(bundled)) return bundled;
} catch {
/* process.execPath unusable — fall through to PATH */
}
return which("mpv");
}
// ── mpv Backend ────────────────────────────────────────────────────── // ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control. // Uses JSON IPC over a Unix socket for full bidirectional control.
@@ -101,7 +119,7 @@ export class MpvBackend implements AudioBackend {
} }
const args = [ const args = [
"mpv", resolveMpvBinary() ?? "mpv",
"--no-video", "--no-video",
"--no-terminal", "--no-terminal",
"--really-quiet", "--really-quiet",
@@ -114,6 +132,12 @@ export class MpvBackend implements AudioBackend {
args.push(`--force-media-title=${opts.mediaTitle}`); args.push(`--force-media-title=${opts.mediaTitle}`);
} }
if (opts?.coverArtPath) {
// Explicit cover file → albumart track → macOS Now Playing artwork
// (works for remote streams, not just local downloads).
args.push(`--cover-art-files=${opts.coverArtPath}`);
}
if (opts?.startPosition && opts.startPosition > 0) { if (opts?.startPosition && opts.startPosition > 0) {
args.push(`--start=${opts.startPosition}`); args.push(`--start=${opts.startPosition}`);
} }
@@ -376,7 +400,7 @@ export interface DetectedPlayer {
export function detectPlayers(): DetectedPlayer[] { export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = []; const players: DetectedPlayer[] = [];
const mpvPath = which("mpv"); const mpvPath = resolveMpvBinary();
if (mpvPath) { if (mpvPath) {
players.push({ players.push({
name: "mpv", name: "mpv",
@@ -410,13 +434,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend; if (backend) return backend;
} }
return which("mpv") ? new MpvBackend() : new NoopBackend(); return resolveMpvBinary() ? 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 which("mpv") ? new MpvBackend() : null; return resolveMpvBinary() ? new MpvBackend() : null;
case "none": case "none":
return new NoopBackend(); return new NoopBackend();
} }