From b30384b9f14e10b26fd29e9ed5a09b61bcd7ffbc Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 6 Sep 2026 08:47:56 +0200 Subject: [PATCH 1/3] fix(build): ship the ffmpeg CLI in the macOS .app so transcription works without a system ffmpeg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mac pack vendored the libav dylibs next to compositor_view.node but never staged the ffmpeg binary, so resolveFfmpeg() found no candidate in the installed app and native STT audio extraction threw FfmpegUnavailableError every time — surfaced to the user only as 'Failed to fetch' (#616). - build-macos-compositor-addon.mjs: stage the SDK's ffmpeg beside the vendored dylibs, rewriting its install names to @rpath/@loader_path (verified locally: the staged binary runs and decodes standalone). Extra libs the CLI links but the addon does not (libavdevice, …) are vendored with the same treatment. - before-pack.cjs: require the ffmpeg binary in the mac payload, so a build that would ship without it fails at pack time instead of in the field. - fetch-ffmpeg-macos.mjs: reuse the vendored tree only when it was built for the pinned deployment target; a stale tree is rebuilt instead of surviving until before-pack's floor guard refuses it. --- scripts/before-pack.cjs | 10 +++ scripts/before-pack.test.mjs | 41 +++++++++ scripts/build-macos-compositor-addon.mjs | 110 +++++++++++++++++++---- scripts/fetch-ffmpeg-macos.mjs | 34 ++++++- 4 files changed, 176 insertions(+), 19 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index e978ca4c7..a43455b17 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -124,6 +124,14 @@ const MAC_REQUIRED = [ breaks: "native screen capture is unavailable", fix: "Build it with:\n\n npm run build:native:mac", }, + { + match: (name) => name === "ffmpeg", + what: "the LGPL ffmpeg CLI (spawned for waveform peaks and STT audio extraction)", + breaks: + "transcription falls back to the renderer decode or fails outright on machines with no\n" + + "system ffmpeg, shown to the user only as \"Failed to fetch\" (#616)", + fix: "Build it with:\n\n npm run build:native:compositor:mac\n\nwhich stages the SDK's ffmpeg beside the vendored dylibs.", + }, ]; /** @@ -790,6 +798,8 @@ exports.__testing = { machoMinOs, checkMacOsVersionFloor, MAC_MIN_OS_FLOOR, + MAC_REQUIRED, + checkNativePayload, }; /** Every ELF under `dir`, recursively — the helper's ffmpeg sits in a subdirectory. */ diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index bc204c737..997d45c9e 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -288,3 +288,44 @@ describe("MAC_MIN_OS_FLOOR", () => { expect(norm(MAC_MIN_OS_FLOOR)).toBe(norm(declared)); }); }); + +// #616: a macOS .app that ships the libav dylibs but no ffmpeg CLI transcribes nothing on +// machines without a system ffmpeg — resolveFfmpeg() finds no candidate, native extraction +// throws, and the renderer can only show "Failed to fetch". The payload guard is the only +// thing that can catch that before the DMG exists; this pins it to the requirement. +describe("MAC_REQUIRED", () => { + it("demands the ffmpeg CLI, and names transcription as what breaks without it", () => { + const { MAC_REQUIRED } = testing(); + const entry = MAC_REQUIRED.find((req) => req.match("ffmpeg")); + expect(entry, "MAC_REQUIRED has no entry matching a file named 'ffmpeg'").toBeDefined(); + expect(entry.breaks).toContain("#616"); + }); + + it("refuses a payload that has everything except the ffmpeg binary", () => { + const { MAC_REQUIRED, checkNativePayload } = testing(); + // One satisfying file per requirement, spelled out rather than derived from the + // matchers (which cannot be inverted) — except the ffmpeg CLI, deliberately absent. + const files = Object.fromEntries( + [ + "compositor_view.node", + "whisper-stt-server", + "libggml-base.dylib", + "openscreen-screencapturekit-helper", + ...["avcodec", "avformat", "avutil", "swresample", "swscale", "avfilter"].map( + (lib, i) => `lib${lib}.${62 - i}.dylib`, + ), + ].map((name) => [name, Buffer.from("x")]), + ); + withPayload(files, (dir) => { + expect(() => + checkNativePayload({ + dir, + required: MAC_REQUIRED, + osLabel: "macOS", + bundleNoun: "the .app", + emptyDirFix: "unused", + }), + ).toThrow(/ffmpeg CLI/); + }); + }); +}); diff --git a/scripts/build-macos-compositor-addon.mjs b/scripts/build-macos-compositor-addon.mjs index 200b9cd87..4b1491495 100644 --- a/scripts/build-macos-compositor-addon.mjs +++ b/scripts/build-macos-compositor-addon.mjs @@ -130,6 +130,26 @@ function installAtomically(from, to) { fs.renameSync(tmp, to); } +/** + * Every Mach-O load command that points outside the OS's own prefixes. `otool -D`/`-L` + * both echo the filename on the first line, so both lists skip it. + */ +function absolutePaths(file) { + const id = execFileSync("otool", ["-D", file], { encoding: "utf8" }) + .split("\n") + .slice(1) // first line is the filename echoed back + .map((l) => l.trim()) + .filter(Boolean); + const deps = execFileSync("otool", ["-L", file], { encoding: "utf8" }) + .split("\n") + .slice(1) // ditto + .map((l) => l.trim().split(" ")[0]) + .filter(Boolean); + return [...id, ...deps].filter( + (p) => p.startsWith("/") && !p.startsWith("/usr/lib/") && !p.startsWith("/System/"), + ); +} + /** * Vendors the ffmpeg dylibs next to the addon and rewrites every install name to * `@rpath`, so the packaged app loads its own copies instead of a build-machine path. @@ -204,22 +224,6 @@ function vendorFfmpegDylibs(nodePath, ffmpegDir) { // the id above — nor any future non-ffmpeg dependency that arrives absolute. // Assert the real invariant instead: nothing outside the OS's own prefixes // may be referenced by absolute path. - const absolutePaths = (file) => { - const id = execFileSync("otool", ["-D", file], { encoding: "utf8" }) - .split("\n") - .slice(1) // first line is the filename echoed back - .map((l) => l.trim()) - .filter(Boolean); - const deps = execFileSync("otool", ["-L", file], { encoding: "utf8" }) - .split("\n") - .slice(1) // ditto - .map((l) => l.trim().split(" ")[0]) - .filter(Boolean); - return [...id, ...deps].filter( - (p) => p.startsWith("/") && !p.startsWith("/usr/lib/") && !p.startsWith("/System/"), - ); - }; - for (const file of [nodePath, ...names.map((n) => path.join(outDir, n))]) { const remaining = absolutePaths(file); if (remaining.length > 0) { @@ -233,6 +237,79 @@ function vendorFfmpegDylibs(nodePath, ffmpegDir) { console.log(`No absolute build-machine paths remain in ${path.basename(nodePath)} or its dylibs`); } +/** + * Stages the SDK's `ffmpeg` BINARY next to the addon's vendored dylibs, so the packaged + * app has the CLI that `resolveFfmpeg()` (electron/media/audioPeaks.ts) and native STT + * audio extraction (electron/stt/extractAudio.ts) spawn. + * + * Without it the .app carries libav*.dylib but no executable, and every transcription on + * a machine without a system ffmpeg dies with FfmpegUnavailableError — the failure the + * renderer can only show as "Failed to fetch" (#616). Windows never had this gap: its + * installer ships `ffmpeg-shared.exe` beside the DLLs it links. + * + * The binary leaves `make install` referencing the SDK's lib/ — by absolute path or by + * `@executable_path/../lib/`, depending on the configure — and neither survives the move + * into electron-builder's extraResources. Rewriting every lib reference to `@rpath/` + * and adding `@loader_path` points it at the very dylibs `vendorFfmpegDylibs` put beside + * it, whose ids already are `@rpath/`. + */ +function stageFfmpegBinary(outDir, ffmpegDir) { + const from = path.join(ffmpegDir, "bin", "ffmpeg"); + if (!fs.existsSync(from)) { + throw new Error(`No ffmpeg binary at ${from}; the SDK tree is incomplete.`); + } + const to = path.join(outDir, "ffmpeg"); + installAtomically(from, to); + fs.chmodSync(to, 0o755); + + const deps = execFileSync("otool", ["-L", to], { encoding: "utf8" }) + .split("\n") + .map((line) => line.trim().split(" ")[0]) + .filter((p) => /(^\/|@executable_path).*lib(av|sw)\w+\.\d+\.dylib$/.test(p)); + if (deps.length === 0) { + throw new Error(`${to} links no ffmpeg dylib — nothing to rewrite, which is wrong.`); + } + // The CLI links more of the SDK than the addon does (libavdevice, libpostproc, …). + // `vendorFfmpegDylibs` staged only what the addon needs, so copy anything still + // missing — with the same `@rpath/` id and inter-library rewrites, so the + // chain resolves the same way the addon's set does. + const copied = []; + for (const dep of deps) { + const name = path.basename(dep); + const staged = path.join(outDir, name); + if (fs.existsSync(staged)) continue; + const sdkLib = path.join(ffmpegDir, "lib", name); + if (!fs.existsSync(sdkLib)) { + throw new Error(`Missing ${sdkLib}; the binary links it but the SDK does not ship it.`); + } + fs.copyFileSync(sdkLib, staged); + fs.chmodSync(staged, 0o755); + execFileSync("install_name_tool", ["-id", `@rpath/${name}`, staged]); + copied.push(staged); + } + for (const file of [...copied, to]) { + const fileDeps = execFileSync("otool", ["-L", file], { encoding: "utf8" }) + .split("\n") + .map((line) => line.trim().split(" ")[0]) + .filter((p) => p.startsWith("/") && /lib(av|sw)\w+\.\d+\.dylib$/.test(p)); + for (const dep of fileDeps) { + execFileSync("install_name_tool", ["-change", dep, `@rpath/${path.basename(dep)}`, file]); + } + execFileSync("install_name_tool", ["-add_rpath", "@loader_path", file]); + // install_name_tool invalidates the signature; re-sign ad-hoc. + execFileSync("codesign", ["--force", "--sign", "-", file]); + } + + const remaining = absolutePaths(to); + if (remaining.length > 0) { + throw new Error( + `${path.basename(to)} still references build-machine paths after rewriting: ` + + remaining.join(", "), + ); + } + console.log(`Staged ffmpeg binary at ${to} (rewritten to @rpath beside the vendored dylibs)`); +} + /** * Refuses to package a GPL ffmpeg. `--enable-gpl` pulls x264/x265 in and relicenses this * MIT app; a Homebrew ffmpeg is exactly that and is an easy thing to point MAC_FFMPEG_DIR @@ -270,6 +347,7 @@ installAtomically(builtDylib, archDest); // Only the arch-tagged copy ships (mac `extraResources`, filter `darwin-*/*`), so that // is the one that gets its dylibs and its @rpath. vendorFfmpegDylibs(archDest, macFfmpegDir); +stageFfmpegBinary(archBinDir, macFfmpegDir); console.log(`Built ${builtDylib}`); console.log(`Copied ${dest}`); diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs index 48d92669b..94b8d29b8 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -162,22 +162,50 @@ function isLgpl(dir) { return /Lesser General Public/i.test(banner) && !/GNU General Public License/i.test(banner); } +/** + * Whether the vendored tree was built for the pinned deployment target. Part of the + * reuse decision alongside the licence: a tree that predates the pin (or was built by + * hand without it) is LGPL and would otherwise be reused forever, only for + * before-pack's floor guard to refuse it at packaging time — a five-minute rebuild + * deferred to the worst possible moment. The binary, not the dylibs, because it is one + * vtool call and the whole tree shares a configure. + */ +function isAtDeploymentTarget(dir) { + const bin = path.join(dir, "bin", "ffmpeg"); + if (!fs.existsSync(bin)) return false; + const build = execFileSync("vtool", ["-show-build", bin], { encoding: "utf8" }); + const minos = /minos (\d+(?:\.\d+)+)/.exec(build)?.[1]; + if (minos === undefined) return false; + const toNum = (v) => v.split(".").reduce((acc, part) => acc * 100 + Number(part), 0); + return toNum(minos) <= toNum(MACOS_DEPLOYMENT_TARGET); +} + if (process.platform !== "darwin") { console.log("Skipping macOS ffmpeg vendoring: macOS-only (Windows uses fetch:ffmpeg)."); process.exit(0); } -if (fs.existsSync(path.join(DEST, "include")) && isLgpl(DEST)) { - console.log(`ffmpeg already vendored at ${DEST} and its -L banner says LGPL. Nothing to do.`); +if (fs.existsSync(path.join(DEST, "include")) && isLgpl(DEST) && isAtDeploymentTarget(DEST)) { + console.log( + `ffmpeg already vendored at ${DEST}: LGPL, built for macOS ${MACOS_DEPLOYMENT_TARGET}. Nothing to do.`, + ); process.exit(0); } -if (fs.existsSync(path.join(DEST, "include"))) { +if (fs.existsSync(path.join(DEST, "include")) && !isLgpl(DEST)) { throw new Error( `${DEST} exists but is not an LGPL build (checked with \`ffmpeg -L\`).\n` + "Refusing to reuse it — linking a GPL ffmpeg would relicense OpenScreen.\n" + "Delete the directory and re-run to rebuild it from source.", ); } +if (fs.existsSync(path.join(DEST, "include"))) { + console.warn( + `${DEST} was not built for the ${MACOS_DEPLOYMENT_TARGET} deployment target ` + + "(check: vtool -show-build). Rebuilding — a stale floor here only fails at packaging,\n" + + "in before-pack's macOS version guard.", + ); + fs.rmSync(DEST, { recursive: true, force: true }); +} const work = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-ffmpeg-")); const tarball = path.join(work, `ffmpeg-${VERSION}.tar.xz`); From 57b141a0366cbb450aaa57b2e4a651b52d32b14f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 6 Sep 2026 20:52:07 +0200 Subject: [PATCH 2/3] fix(build): address review comments and format before-pack.cjs --- scripts/before-pack.cjs | 2 +- scripts/build-macos-compositor-addon.mjs | 2 +- scripts/fetch-ffmpeg-macos.mjs | 11 +++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index a43455b17..dd1c78c75 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -129,7 +129,7 @@ const MAC_REQUIRED = [ what: "the LGPL ffmpeg CLI (spawned for waveform peaks and STT audio extraction)", breaks: "transcription falls back to the renderer decode or fails outright on machines with no\n" + - "system ffmpeg, shown to the user only as \"Failed to fetch\" (#616)", + 'system ffmpeg, shown to the user only as "Failed to fetch" (#616)', fix: "Build it with:\n\n npm run build:native:compositor:mac\n\nwhich stages the SDK's ffmpeg beside the vendored dylibs.", }, ]; diff --git a/scripts/build-macos-compositor-addon.mjs b/scripts/build-macos-compositor-addon.mjs index 4b1491495..a9b939fc3 100644 --- a/scripts/build-macos-compositor-addon.mjs +++ b/scripts/build-macos-compositor-addon.mjs @@ -291,7 +291,7 @@ function stageFfmpegBinary(outDir, ffmpegDir) { const fileDeps = execFileSync("otool", ["-L", file], { encoding: "utf8" }) .split("\n") .map((line) => line.trim().split(" ")[0]) - .filter((p) => p.startsWith("/") && /lib(av|sw)\w+\.\d+\.dylib$/.test(p)); + .filter((p) => /(^\/|@executable_path).*lib(av|sw)\w+\.\d+\.dylib$/.test(p)); for (const dep of fileDeps) { execFileSync("install_name_tool", ["-change", dep, `@rpath/${path.basename(dep)}`, file]); } diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs index 94b8d29b8..d165af762 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -176,8 +176,15 @@ function isAtDeploymentTarget(dir) { const build = execFileSync("vtool", ["-show-build", bin], { encoding: "utf8" }); const minos = /minos (\d+(?:\.\d+)+)/.exec(build)?.[1]; if (minos === undefined) return false; - const toNum = (v) => v.split(".").reduce((acc, part) => acc * 100 + Number(part), 0); - return toNum(minos) <= toNum(MACOS_DEPLOYMENT_TARGET); + const compareVersions = (a, b) => { + const left = a.split(".").map(Number); + const right = b.split(".").map(Number); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + if ((left[i] ?? 0) !== (right[i] ?? 0)) return (left[i] ?? 0) - (right[i] ?? 0); + } + return 0; + }; + return compareVersions(minos, MACOS_DEPLOYMENT_TARGET) <= 0; } if (process.platform !== "darwin") { From 6467141be833f11b246aa92a916f3791a7696131 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 6 Sep 2026 23:30:27 +0200 Subject: [PATCH 3/3] fix(build): harden macOS ffmpeg dylib staging --- scripts/before-pack.cjs | 6 +++++ scripts/before-pack.test.mjs | 28 ++++++++++++++++++++ scripts/build-macos-compositor-addon.mjs | 33 +++++++++++++----------- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index dd1c78c75..f92d8e7ed 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -105,6 +105,12 @@ const MAC_REQUIRED = [ breaks: "the compositor addon cannot be loaded at all (dyld error at require())", fix: FIX_MAC, })), + { + match: (name) => /^libavdevice\.\d+\.dylib$/.test(name), + what: "the LGPL libavdevice dylib the ffmpeg CLI links", + breaks: "ffmpeg dies in dyld before main(), so waveform and STT extraction cannot start", + fix: FIX_MAC, + }, { match: (name) => name === "whisper-stt-server", what: "the whisper.cpp STT helper", diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index 997d45c9e..a0c356894 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -311,6 +311,7 @@ describe("MAC_REQUIRED", () => { "whisper-stt-server", "libggml-base.dylib", "openscreen-screencapturekit-helper", + "libavdevice.62.dylib", ...["avcodec", "avformat", "avutil", "swresample", "swscale", "avfilter"].map( (lib, i) => `lib${lib}.${62 - i}.dylib`, ), @@ -328,4 +329,31 @@ describe("MAC_REQUIRED", () => { ).toThrow(/ffmpeg CLI/); }); }); + + it("refuses a payload whose ffmpeg CLI is missing libavdevice", () => { + const { MAC_REQUIRED, checkNativePayload } = testing(); + const files = Object.fromEntries( + [ + "compositor_view.node", + "ffmpeg", + "whisper-stt-server", + "libggml-base.dylib", + "openscreen-screencapturekit-helper", + ...["avcodec", "avformat", "avutil", "swresample", "swscale", "avfilter"].map( + (lib, i) => `lib${lib}.${62 - i}.dylib`, + ), + ].map((name) => [name, Buffer.from("x")]), + ); + withPayload(files, (dir) => { + expect(() => + checkNativePayload({ + dir, + required: MAC_REQUIRED, + osLabel: "macOS", + bundleNoun: "the .app", + emptyDirFix: "unused", + }), + ).toThrow(/libavdevice/); + }); + }); }); diff --git a/scripts/build-macos-compositor-addon.mjs b/scripts/build-macos-compositor-addon.mjs index a9b939fc3..d1f52268c 100644 --- a/scripts/build-macos-compositor-addon.mjs +++ b/scripts/build-macos-compositor-addon.mjs @@ -270,24 +270,25 @@ function stageFfmpegBinary(outDir, ffmpegDir) { throw new Error(`${to} links no ffmpeg dylib — nothing to rewrite, which is wrong.`); } // The CLI links more of the SDK than the addon does (libavdevice, libpostproc, …). - // `vendorFfmpegDylibs` staged only what the addon needs, so copy anything still - // missing — with the same `@rpath/` id and inter-library rewrites, so the - // chain resolves the same way the addon's set does. - const copied = []; - for (const dep of deps) { + // Refresh every direct dependency, including files vendorFfmpegDylibs already staged. + // The output directory persists between local builds, so keeping an existing file could + // mix a new CLI with an old SDK dylib or preserve a half-rewritten file from an interrupted + // run. Replacing the complete set also gives every file the same atomic-install guarantee + // as the addon and CLI. + const stagedLibraries = []; + for (const dep of new Set(deps)) { const name = path.basename(dep); const staged = path.join(outDir, name); - if (fs.existsSync(staged)) continue; const sdkLib = path.join(ffmpegDir, "lib", name); if (!fs.existsSync(sdkLib)) { throw new Error(`Missing ${sdkLib}; the binary links it but the SDK does not ship it.`); } - fs.copyFileSync(sdkLib, staged); + installAtomically(sdkLib, staged); fs.chmodSync(staged, 0o755); execFileSync("install_name_tool", ["-id", `@rpath/${name}`, staged]); - copied.push(staged); + stagedLibraries.push(staged); } - for (const file of [...copied, to]) { + for (const file of [...stagedLibraries, to]) { const fileDeps = execFileSync("otool", ["-L", file], { encoding: "utf8" }) .split("\n") .map((line) => line.trim().split(" ")[0]) @@ -300,12 +301,14 @@ function stageFfmpegBinary(outDir, ffmpegDir) { execFileSync("codesign", ["--force", "--sign", "-", file]); } - const remaining = absolutePaths(to); - if (remaining.length > 0) { - throw new Error( - `${path.basename(to)} still references build-machine paths after rewriting: ` + - remaining.join(", "), - ); + for (const file of [...stagedLibraries, to]) { + const remaining = absolutePaths(file); + if (remaining.length > 0) { + throw new Error( + `${path.basename(file)} still references build-machine paths after rewriting: ` + + remaining.join(", "), + ); + } } console.log(`Staged ffmpeg binary at ${to} (rewritten to @rpath beside the vendored dylibs)`); }