diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index e978ca4c7..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", @@ -124,6 +130,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 +804,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..a0c356894 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -288,3 +288,72 @@ 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", + "libavdevice.62.dylib", + ...["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/); + }); + }); + + 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 200b9cd87..d1f52268c 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,82 @@ 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, …). + // 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); + 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.`); + } + installAtomically(sdkLib, staged); + fs.chmodSync(staged, 0o755); + execFileSync("install_name_tool", ["-id", `@rpath/${name}`, staged]); + stagedLibraries.push(staged); + } + for (const file of [...stagedLibraries, to]) { + const fileDeps = execFileSync("otool", ["-L", file], { encoding: "utf8" }) + .split("\n") + .map((line) => line.trim().split(" ")[0]) + .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]); + } + execFileSync("install_name_tool", ["-add_rpath", "@loader_path", file]); + // install_name_tool invalidates the signature; re-sign ad-hoc. + execFileSync("codesign", ["--force", "--sign", "-", file]); + } + + 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)`); +} + /** * 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 +350,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..d165af762 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -162,22 +162,57 @@ 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 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") { 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`);