diff --git a/crates/compositor/src/audio.rs b/crates/compositor/src/audio.rs index 3c37326f..1b51a7a9 100644 --- a/crates/compositor/src/audio.rs +++ b/crates/compositor/src/audio.rs @@ -35,7 +35,10 @@ extern "C" { fn averr(ret: i32, ctx: &str) -> Result<()> { if ret < 0 { - let mut buf = [0i8; 256]; + // Surtout pas `[0i8; 256]` : `c_char` est signé sur x86_64 mais NON SIGNÉ sur + // aarch64, donc un i8 en dur fait du `*mut c_char` d'av_strerror une erreur de + // type sur arm64. + let mut buf = [0 as std::ffi::c_char; 256]; unsafe { av_strerror(ret, buf.as_mut_ptr(), buf.len()) }; let msg = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }.to_string_lossy(); bail!("{ctx}: {ret} ({msg})"); diff --git a/electron/native/pipewire-capture/build.rs b/electron/native/pipewire-capture/build.rs index d9aa3366..0190e818 100644 --- a/electron/native/pipewire-capture/build.rs +++ b/electron/native/pipewire-capture/build.rs @@ -73,11 +73,23 @@ fn freestanding_header_args() -> Vec { return Vec::new(); } } - let gcc_root = Path::new("/usr/lib/gcc/x86_64-linux-gnu"); - let Ok(entries) = std::fs::read_dir(gcc_root) else { + // The vendor triplet is NOT hardcoded. It is `x86_64-linux-gnu` on Debian/Ubuntu + // amd64 but `aarch64-linux-gnu` on arm64 — hardcoding the former is why bindgen + // died with "'limits.h' file not found" there — and neither on Arch + // (`x86_64-pc-linux-gnu`). Matching on the target arch prefix covers all of them, + // and on a box with a cross-gcc installed it still refuses the OTHER + // architecture's headers, whose type widths would be wrong. + let arch = std::env::var("CARGO_CFG_TARGET_ARCH") + .unwrap_or_else(|_| std::env::consts::ARCH.to_string()); + let prefix = format!("{arch}-"); + let Ok(vendors) = std::fs::read_dir("/usr/lib/gcc") else { return Vec::new(); }; - let mut dirs: Vec = entries + let mut dirs: Vec = vendors + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(&prefix)) + .filter_map(|vendor| std::fs::read_dir(vendor.path()).ok()) + .flatten() .filter_map(|entry| entry.ok()) .map(|entry| entry.path().join("include")) .filter(|dir| dir.join("limits.h").is_file() && dir.join("stddef.h").is_file()) diff --git a/electron/native/pipewire-capture/src/ffmpeg.rs b/electron/native/pipewire-capture/src/ffmpeg.rs index 1a710341..ded81cb8 100644 --- a/electron/native/pipewire-capture/src/ffmpeg.rs +++ b/electron/native/pipewire-capture/src/ffmpeg.rs @@ -49,7 +49,9 @@ pub const AVIO_FLAG_WRITE: i32 = 2; /// Formats an ffmpeg return code the way `av_err2str` would, since that too is a /// macro and needs a caller-supplied buffer. pub fn err_to_string(code: i32) -> String { - let mut buffer = [0i8; 256]; + // NOT `[0i8; 256]`: `c_char` is signed on x86_64 but UNSIGNED on aarch64, so a + // hardcoded i8 makes av_strerror's `*mut c_char` a type error on arm64. + let mut buffer = [0 as std::ffi::c_char; 256]; // SAFETY: the buffer is the size we tell av_strerror it is, and ffmpeg // always NUL-terminates within it. let ok = unsafe { av_strerror(code, buffer.as_mut_ptr(), buffer.len()) } == 0; diff --git a/scripts/build-linux-compositor-addon.mjs b/scripts/build-linux-compositor-addon.mjs index aa0f82a5..557ce868 100644 --- a/scripts/build-linux-compositor-addon.mjs +++ b/scripts/build-linux-compositor-addon.mjs @@ -79,6 +79,13 @@ function resolveFfmpegDir() { ); } +/** + * Debian/Ubuntu multiarch triplet for the host. Hardcoding the x86_64 one made + * both libclang and gcc's stddef.h invisible on arm64, where the same libraries + * live under /usr/lib/aarch64-linux-gnu. + */ +const MULTIARCH = process.arch === "arm64" ? "aarch64-linux-gnu" : "x86_64-linux-gnu"; + /** * bindgen loads libclang at runtime. crates/.cargo/config.toml hardcodes a * Windows LLVM path, so on Linux we locate it ourselves rather than making @@ -88,7 +95,7 @@ function resolveLibclangDir() { if (process.env.LIBCLANG_PATH) { return process.env.LIBCLANG_PATH; } - const roots = ["/usr/lib/x86_64-linux-gnu", "/usr/lib64", "/usr/lib"]; + const roots = [`/usr/lib/${MULTIARCH}`, "/usr/lib64", "/usr/lib"]; for (const llvmRoot of ["/usr/lib"]) { if (!fs.existsSync(llvmRoot)) continue; for (const entry of fs.readdirSync(llvmRoot)) { @@ -115,20 +122,33 @@ function resolveLibclangDir() { * On distributions that ship only `libclang.so.1` (no -dev package) clang also * cannot find its own `stddef.h`. Point it at gcc's copy in that case rather * than making the caller discover it. + * + * Kept behaviourally identical to `freestanding_header_args()` in + * electron/native/pipewire-capture/build.rs, which is the authority — that one + * runs for a bare `cargo build` too. Two properties matter and both were once + * wrong here: `limits.h` must be required alongside `stddef.h` (the failure + * being fixed is `'limits.h' file not found` — glibc's copy `#include_next`s + * the compiler's), and the newest gcc must be picked deterministically rather + * than whatever `readdirSync` happened to return first. */ function bindgenClangArgs() { if (process.env.BINDGEN_EXTRA_CLANG_ARGS) { return process.env.BINDGEN_EXTRA_CLANG_ARGS; } - const gccIncludeRoot = "/usr/lib/gcc/x86_64-linux-gnu"; + const gccIncludeRoot = `/usr/lib/gcc/${MULTIARCH}`; if (!fs.existsSync(gccIncludeRoot)) { return ""; } - const withStddef = fs + const usable = fs .readdirSync(gccIncludeRoot) .map((version) => path.join(gccIncludeRoot, version, "include")) - .filter((dir) => fs.existsSync(path.join(dir, "stddef.h"))); - return withStddef.length > 0 ? `-I${withStddef[0]}` : ""; + .filter( + (dir) => + fs.existsSync(path.join(dir, "limits.h")) && fs.existsSync(path.join(dir, "stddef.h")), + ) + .sort() + .reverse(); + return usable.length > 0 ? `-I${usable[0]}` : ""; } /** diff --git a/scripts/build-linux-pipewire-helper.mjs b/scripts/build-linux-pipewire-helper.mjs index d761db6f..ac898c4c 100644 --- a/scripts/build-linux-pipewire-helper.mjs +++ b/scripts/build-linux-pipewire-helper.mjs @@ -63,6 +63,12 @@ if (cargoVersion.status !== 0) { process.exit(1); } +// No BINDGEN_EXTRA_CLANG_ARGS wrangling here on purpose. bindgen needs clang's +// freestanding headers (limits.h, stddef.h) and Ubuntu ships libclang.so.1 with +// no resource dir, but the crate's own build.rs already falls back to gcc's +// copies — see freestanding_header_args() there. Doing it here too would only +// cover the build that goes through this script and leave a bare `cargo build` +// broken, which is exactly the split build.rs exists to avoid. const build = spawnSync("cargo", ["build", "--release", "--manifest-path", manifest], { cwd: crateDir, stdio: "inherit", diff --git a/scripts/fetch-ffmpeg.mjs b/scripts/fetch-ffmpeg.mjs index 331457da..8c2f0ed8 100644 --- a/scripts/fetch-ffmpeg.mjs +++ b/scripts/fetch-ffmpeg.mjs @@ -125,6 +125,10 @@ const SHARED_PINNED = { asset: "ffmpeg-n8.1.2-34-g9b6c8969e0-linux64-lgpl-shared-8.1.tar.xz", sha256: "c882a80f06617149198a98a07a0880a7e881953ae9f9cb931f5be09a4f93caae", }, + "linux-arm64": { + asset: "ffmpeg-n8.1.2-34-g9b6c8969e0-linuxarm64-lgpl-shared-8.1.tar.xz", + sha256: "eec386482ac6799bb547b5f507dedd19ef6354eee0ca4ddb04bdd053d03c3cfb", + }, "win32-x64": { asset: "ffmpeg-n8.1.2-34-g9b6c8969e0-win64-lgpl-shared-8.1.zip", sha256: "c222a490dde4e7059f45495deef6bfb98dbcacc2b43df5b607546252037aa95c", diff --git a/scripts/fetch-ffmpeg.test.mjs b/scripts/fetch-ffmpeg.test.mjs new file mode 100644 index 00000000..9ff869c7 --- /dev/null +++ b/scripts/fetch-ffmpeg.test.mjs @@ -0,0 +1,71 @@ +// The pin table in fetch-ffmpeg.mjs is a supply-chain artifact, and that file says +// so itself: "tag, asset name and digest are one unit. Re-pin all three or none." +// Nothing enforced it. A re-pin that moved some entries and not others merges clean +// — the edits do not even touch the same lines — and then fails only on the arch +// nobody builds in CI, where `npm run build:linux` dies on a 404 from the pinned +// release before it has done anything. +// +// That is not hypothetical: the linux-arm64 shared entry arrived naming +// n8.1.2-32-gcfa62de001 while every sibling had already moved to +// n8.1.2-34-g9b6c8969e0. This test is the guard that was missing. +// +// Read as source text rather than imported: fetch-ffmpeg.mjs calls main() at import +// and would start downloading. The property under test is a property of the literal +// table anyway. + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const source = fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), "fetch-ffmpeg.mjs"), + "utf8", +); + +// Anchored at the property so prose in the file's (extensive) comments cannot match. +const assets = [...source.matchAll(/^\s*asset:\s*"([^"]+)"/gm)].map((m) => m[1]); +const digests = [...source.matchAll(/^\s*sha256:\s*"([^"]+)"/gm)].map((m) => m[1]); + +/** `ffmpeg-n8.1.2-34-g9b6c8969e0-linuxarm64-lgpl-shared-8.1.tar.xz` -> `n8.1.2-34-g9b6c8969e0`. */ +const buildId = (asset) => asset.match(/-(n\d+(?:\.\d+)+-\d+-g[0-9a-f]+)-/)?.[1]; + +describe("fetch-ffmpeg pins", () => { + // Without this, a reformat that breaks the regexes above would leave every other + // assertion iterating an empty array and passing vacuously. + it("still finds the pin table", () => { + expect(assets.length).toBeGreaterThanOrEqual(8); + expect(digests).toHaveLength(assets.length); + }); + + it("names one single ffmpeg build across every pinned asset", () => { + const byBuild = new Map(); + for (const asset of assets) { + const id = buildId(asset); + // Also the "never an `N-…` master snapshot" rule: those carry no n. + expect(id, `${asset} does not name an n--g release build`).toBeDefined(); + byBuild.set(id, [...(byBuild.get(id) ?? []), asset]); + } + expect( + [...byBuild.keys()], + `The pin table straddles ${byBuild.size} different ffmpeg builds:\n${[...byBuild] + .map(([id, list]) => ` ${id}\n${list.map((a) => ` ${a}`).join("\n")}`) + .join("\n")}\nRe-pin every entry together — RELEASE_TAG, asset and digest are one unit.`, + ).toHaveLength(1); + }); + + // One GPL component relicenses the whole binary, which would contaminate this MIT + // app. assertLgpl() checks the artifact after download; this checks the intent + // before anyone runs the script. + it("pins only -lgpl assets", () => { + for (const asset of assets) { + expect(asset, `${asset} is not an -lgpl asset`).toMatch(/-lgpl(-shared)?-/); + } + }); + + it("pins a full sha-256 for every asset", () => { + for (const digest of digests) { + expect(digest).toMatch(/^[0-9a-f]{64}$/); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index d775a6e3..3a6243b3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig({ // docblock the other way round, to escape the global jsdom; that one is now // redundant but harmless.) environment: "node", - include: ["{src,electron,.github}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + include: ["{src,electron,scripts,.github}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], // Vitest's 5s default is too tight here and produces red runs that mean nothing. // Measured: with the machine loaded, 11 tests fail and 9 of them are purely // "Test timed out in 5000ms" — ordinary component tests that pass in 200ms on an