Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions scripts/before-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
},
];

/**
Expand Down Expand Up @@ -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. */
Expand Down
69 changes: 69 additions & 0 deletions scripts/before-pack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
});
113 changes: 97 additions & 16 deletions scripts/build-macos-compositor-addon.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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/<name>`
* and adding `@loader_path` points it at the very dylibs `vendorFfmpegDylibs` put beside
* it, whose ids already are `@rpath/<name>`.
*/
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
Expand Down Expand Up @@ -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}`);
Expand Down
41 changes: 38 additions & 3 deletions scripts/fetch-ffmpeg-macos.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
Loading