diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index 70a2d6cc..b0656800 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -419,13 +419,19 @@ function checkWinNativePayload() { } function checkMacNativePayload(context) { + const dir = path.join(ROOT, "electron", "native", "bin", `darwin-${archTagFor(context)}`); checkNativePayload({ - dir: path.join(ROOT, "electron", "native", "bin", `darwin-${archTagFor(context)}`), + dir, required: MAC_REQUIRED, osLabel: "macOS", bundleNoun: "the .app", emptyDirFix: `${FIX_MAC}\n\nThe STT helper and the capture helper are separate builds — see\ntechnical-documentation/engineering/build-and-packaging.md.`, }); + + // "Complete" is not the same property as "runnable on the macOS we claim". This file + // exists because a payload can be whole and still broken; a floor above the supported + // one is the second way that happens. See checkMacOsVersionFloor(). + checkMacOsVersionFloor(dir); } function checkLinuxNativePayload(context) { @@ -504,6 +510,18 @@ function checkLinuxNativePayload(context) { */ const MAX_SYMBOL_VERSION = { GLIBC: "2.35", GLIBCXX: "3.4.30", CXXABI: "1.3.13" }; +/** + * The oldest macOS anything in the payload may demand — the macOS twin of + * MAX_SYMBOL_VERSION above, and the same class of bug on a different libc. + * + * Must equal `mac.minimumSystemVersion` in electron-builder.json5, which is what the .app + * tells LaunchServices; before-pack.test.mjs asserts exactly that, so the two cannot drift + * apart quietly. Not read from the config at runtime because this hook must keep working + * if that file is ever restructured — a guard that throws while parsing is a guard that + * gets deleted. + */ +const MAC_MIN_OS_FLOOR = "13.0"; + /** * The one supported way past the ceiling, for the one case it does not fit: a developer * on a distro newer than the floor, building a package for their own machine. @@ -718,7 +736,13 @@ function resolveSymbolCeiling() { // are the only things standing between this escape hatch and a published package that // starts on nobody's machine but the builder's, and they are reachable from a test // without a payload to scan — so they are tested rather than trusted. -exports.__testing = { resolveSymbolCeiling, MAX_SYMBOL_VERSION }; +exports.__testing = { + resolveSymbolCeiling, + MAX_SYMBOL_VERSION, + machoMinOs, + checkMacOsVersionFloor, + MAC_MIN_OS_FLOOR, +}; /** Every ELF under `dir`, recursively — the helper's ffmpeg sits in a subdirectory. */ function elfFilesUnder(dir) { @@ -820,6 +844,155 @@ function checkLinuxSymbolVersionFloor(dir) { ); } +/** + * The macOS minimum-OS a Mach-O declares, as "12.0", or null if it declares none. + * + * Reads LC_BUILD_VERSION (and LC_VERSION_MIN_MACOSX, which is what anything built + * against an older SDK carries) straight out of the file. Parsed here rather than + * shelled out to `vtool -show-build` for the same reason neededSymbolVersions() does not + * use readelf and importedDlls() does not use dumpbin — but with an extra one on top: + * this hook runs for the Windows and Linux packs too, and vtool exists on neither, so a + * subprocess would have to be skipped on exactly the hosts where skipping is silent. + * Parsing makes the guard host-independent instead of conditionally absent. + * + * Universal binaries are walked slice by slice and the HIGHEST floor wins: an x86_64 half + * built on a newer machine strands Intel users just as thoroughly as a thin binary would. + */ +function machoMinOs(file) { + const b = fs.readFileSync(file); + const FAT_MAGIC = 0xcafebabe; + const FAT_MAGIC_64 = 0xcafebabf; + const MH_MAGIC_64 = 0xfeedfacf; + const MH_MAGIC_32 = 0xfeedface; + const LC_VERSION_MIN_MACOSX = 0x24; + const LC_BUILD_VERSION = 0x32; + const PLATFORM_MACOS = 1; + + /** X.Y.Z packed as nibbles: 0x000c0000 is 12.0.0. */ + const decode = (packed) => `${packed >>> 16}.${(packed >> 8) & 0xff}.${packed & 0xff}`; + + const sliceMinOs = (start) => { + const magic = b.readUInt32LE(start); + if (magic !== MH_MAGIC_64 && magic !== MH_MAGIC_32) return null; + const ncmds = b.readUInt32LE(start + 16); + // 32 bytes of mach_header_64 (28 + 4 bytes of `reserved`); 28 for the 32-bit one. + let off = start + (magic === MH_MAGIC_64 ? 32 : 28); + for (let i = 0; i < ncmds; i++) { + if (off + 8 > b.length) return null; + const cmd = b.readUInt32LE(off); + const cmdsize = b.readUInt32LE(off + 4); + if (cmdsize < 8) return null; + if (cmd === LC_BUILD_VERSION && b.readUInt32LE(off + 8) === PLATFORM_MACOS) { + return decode(b.readUInt32LE(off + 12)); + } + if (cmd === LC_VERSION_MIN_MACOSX) { + return decode(b.readUInt32LE(off + 8)); + } + off += cmdsize; + } + return null; + }; + + const fat = b.readUInt32BE(0); + if (fat === FAT_MAGIC || fat === FAT_MAGIC_64) { + const wide = fat === FAT_MAGIC_64; + const nfat = b.readUInt32BE(4); + let best = null; + for (let i = 0; i < nfat; i++) { + const entry = 8 + i * (wide ? 32 : 20); + const offset = wide ? Number(b.readBigUInt64BE(entry + 8)) : b.readUInt32BE(entry + 8); + const found = sliceMinOs(offset); + if (found && (!best || compareVersions(found, best) > 0)) best = found; + } + return best; + } + + return sliceMinOs(0); +} + +/** Every Mach-O under `dir`, recursively. Symlinks are skipped — see elfFilesUnder(). */ +function machoFilesUnder(dir) { + const found = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + found.push(...machoFilesUnder(full)); + continue; + } + if (!entry.isFile()) continue; + // By magic, not by extension: the helpers and whisper-stt-server have none, and + // the ggml/whisper dylibs come as chains of symlinks onto one real file. + const magic = Buffer.alloc(4); + const fd = fs.openSync(full, "r"); + try { + fs.readSync(fd, magic, 0, 4, 0); + } finally { + fs.closeSync(fd); + } + const le = magic.readUInt32LE(0); + const be = magic.readUInt32BE(0); + if (le === 0xfeedfacf || le === 0xfeedface || be === 0xcafebabe || be === 0xcafebabf) { + found.push(full); + } + } + return found; +} + +/** Nothing we ship may demand a newer macOS than MAC_MIN_OS_FLOOR. */ +function checkMacOsVersionFloor(dir) { + const scanned = machoFilesUnder(dir).map((file) => ({ + name: path.relative(dir, file), + minOs: machoMinOs(file), + })); + + // Same assertion the Linux floor makes, for the same reason: a guard that quietly + // stops looking reports "clean" for the rest of the project's life. Every binary we + // ship is built with a deployment target, so reading none from any of them means the + // parser broke rather than that the payload is unusually clean. + if (scanned.length > 0 && !scanned.some((entry) => entry.minOs)) { + throw new Error( + `Refusing to package: read no macOS deployment target from any of the ${scanned.length} ` + + `Mach-O files in ${path.relative(ROOT, dir)}.\n\n` + + "Every one of them carries LC_BUILD_VERSION, so this is a bug in machoMinOs()\n" + + "(scripts/before-pack.cjs), not an unusually clean payload. Fix the parser — leaving\n" + + "it is how a build that cannot start on the supported macOS gets shipped again.", + ); + } + + const offenders = scanned.filter( + (entry) => entry.minOs && compareVersions(entry.minOs, MAC_MIN_OS_FLOOR) > 0, + ); + if (offenders.length === 0) { + return; + } + + throw new Error( + `Refusing to package binaries that demand a newer macOS than the ${MAC_MIN_OS_FLOOR} floor\n` + + "the app claims to support.\n\n" + + ` looked in: ${path.relative(ROOT, dir)}\n\n` + + `${offenders.map((o) => ` - ${o.name} is built for macOS ${o.minOs} (floor ${MAC_MIN_OS_FLOOR})`).join("\n")}\n\n` + + "Almost certainly nothing asked for this: clang and CMake default the deployment\n" + + "target to the BUILD MACHINE's SDK, so this usually means a build script forgot to\n" + + "pin one and the floor followed whatever image compiled it. CI's macos-latest moves\n" + + "on its own, so the same source can ship a different floor month to month.\n\n" + + "The number itself is not what breaks: dyld does NOT refuse a binary whose minos\n" + + "exceeds the running OS. The damage is done at link time — the deployment target\n" + + "decides which symbols the linker resolves against the OS instead of emitting\n" + + "locally, so a too-high floor leaves strong references to symbols the target macOS\n" + + "has never had, and the binary dies in dyld with 'Symbol not found'. That is issue\n" + + "#515: a helper built for 13 stranded every macOS 12 user, and the app reported it\n" + + "as a denied Accessibility permission.\n\n" + + "Pin the deployment target in whichever script built the file:\n\n" + + " Swift platforms: [.macOS(.v12)] electron/native/screencapturekit/Package.swift\n" + + " CMake -DCMAKE_OSX_DEPLOYMENT_TARGET scripts/build-whisper-stt.sh\n" + + " clang -mmacosx-version-min scripts/fetch-ffmpeg-macos.mjs\n" + + " rustc MACOSX_DEPLOYMENT_TARGET scripts/build-macos-compositor-addon.mjs\n\n" + + "To see it yourself:\n\n" + + " vtool -show-build \n\n" + + `Raising MAC_MIN_OS_FLOOR drops a macOS version the README claims to support.`, + ); +} + /** Newest mtime under `target` (file or directory), or 0 if it does not exist. */ function newestMtimeMs(target) { let stat; diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index 90528351..5c34f59c 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -88,3 +88,201 @@ describe("symbol-version ceiling", () => { }); }); }); + +// --------------------------------------------------------------------------- +// macOS deployment floor (issue #515) +// --------------------------------------------------------------------------- +// +// Mach-O headers are synthesised here rather than compiled with clang, so this runs on +// the Linux and Windows CI legs too. That is the same reason the guard parses the file +// itself instead of shelling out to `vtool`: the check has to be present everywhere the +// hook is, not conditionally absent on the hosts where nobody would notice. +// +// The parser is separately cross-checked against the real thing — on a machine with a +// staged macOS payload, every Mach-O in it agreed with `vtool -show-build` (44/44). + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +/** Packs X.Y.Z the way LC_BUILD_VERSION does: one byte per component, X in the top half. */ +function packVersion(text) { + const [x = 0, y = 0, z = 0] = text.split(".").map(Number); + return ((x & 0xffff) << 16) | ((y & 0xff) << 8) | (z & 0xff); +} + +/** A 64-bit Mach-O carrying exactly one load command: LC_BUILD_VERSION for macOS. */ +function thinMachO(minOs) { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); // MH_MAGIC_64 + header.writeUInt32LE(1, 16); // ncmds + const lc = Buffer.alloc(24); + lc.writeUInt32LE(0x32, 0); // LC_BUILD_VERSION + lc.writeUInt32LE(24, 4); // cmdsize + lc.writeUInt32LE(1, 8); // PLATFORM_MACOS + lc.writeUInt32LE(packVersion(minOs), 12); + return Buffer.concat([header, lc]); +} + +/** The older spelling, which anything built against an older SDK carries instead. */ +function thinMachOVersionMin(minOs) { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); + header.writeUInt32LE(1, 16); + const lc = Buffer.alloc(16); + lc.writeUInt32LE(0x24, 0); // LC_VERSION_MIN_MACOSX + lc.writeUInt32LE(16, 4); + lc.writeUInt32LE(packVersion(minOs), 8); + return Buffer.concat([header, lc]); +} + +/** A universal binary whose slices disagree — the highest floor is the one that counts. */ +function fatMachO(minOsPerSlice) { + const headerSize = 8 + minOsPerSlice.length * 20; + const head = Buffer.alloc(headerSize); + head.writeUInt32BE(0xcafebabe, 0); + head.writeUInt32BE(minOsPerSlice.length, 4); + const slices = minOsPerSlice.map(thinMachO); + let offset = headerSize; + slices.forEach((slice, i) => { + const entry = 8 + i * 20; + head.writeUInt32BE(offset, entry + 8); // offset + head.writeUInt32BE(slice.length, entry + 12); // size + offset += slice.length; + }); + return Buffer.concat([head, ...slices]); +} + +function withPayload(files, body) { + const dir = mkdtempSync(path.join(tmpdir(), "openscreen-minos-")); + try { + for (const [name, bytes] of Object.entries(files)) { + writeFileSync(path.join(dir, name), bytes); + } + return body(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const testing = () => require(BEFORE_PACK).__testing; + +describe("machoMinOs", () => { + it("reads LC_BUILD_VERSION", () => { + withPayload({ helper: thinMachO("12.0") }, (dir) => { + expect(testing().machoMinOs(path.join(dir, "helper"))).toBe("12.0.0"); + }); + }); + + it("reads the older LC_VERSION_MIN_MACOSX spelling", () => { + withPayload({ helper: thinMachOVersionMin("11.3") }, (dir) => { + expect(testing().machoMinOs(path.join(dir, "helper"))).toBe("11.3.0"); + }); + }); + + it("takes the HIGHEST floor across a universal binary's slices", () => { + // An arm64 half built correctly does not rescue an x86_64 half that was not: + // Intel users are stranded just as thoroughly. + withPayload({ fat: fatMachO(["12.0", "26.0"]) }, (dir) => { + expect(testing().machoMinOs(path.join(dir, "fat"))).toBe("26.0.0"); + }); + }); + + it("returns null for a Mach-O that declares no deployment target", () => { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); + withPayload({ bare: header }, (dir) => { + expect(testing().machoMinOs(path.join(dir, "bare"))).toBeNull(); + }); + }); +}); + +describe("checkMacOsVersionFloor", () => { + /** + * Fixtures are derived from the floor rather than written as literals. An earlier + * revision hardcoded the then-current floor as "the offending version", and raising + * the floor silently turned the offender into a compliant binary — the guard's own + * tests stopped testing it. The exact versions were never the point; being on the + * wrong side of the floor is. + */ + const floorMajor = Number(testing().MAC_MIN_OS_FLOOR.split(".")[0]); + const above = (bump = 1) => `${floorMajor + bump}.0`; + const below = () => `${floorMajor - 1}.0`; + + it("passes a payload built at or below the floor", () => { + withPayload({ a: thinMachO(testing().MAC_MIN_OS_FLOOR), b: thinMachO(below()) }, (dir) => { + expect(() => testing().checkMacOsVersionFloor(dir)).not.toThrow(); + }); + }); + + /** + * The regression test for #515: the helper that stranded Monterey was built for 13, + * and nothing in the pipeline looked. The message has to carry enough for whoever + * hits it to understand the consequence rather than just raise the constant. + */ + it("refuses a binary built above the floor, and says which and why", () => { + withPayload({ "openscreen-macos-cursor-helper": thinMachO(above()) }, (dir) => { + let message = ""; + try { + testing().checkMacOsVersionFloor(dir); + } catch (err) { + message = err.message; + } + expect(message).toContain("openscreen-macos-cursor-helper"); + expect(message).toContain(`macOS ${above()}.0`); + expect(message).toContain(testing().MAC_MIN_OS_FLOOR); + expect(message).toContain("#515"); + // The mechanism, so nobody "fixes" it by assuming dyld gates on the number. + expect(message).toContain("Symbol not found"); + }); + }); + + it("reports every offender, not just the first", () => { + withPayload( + { + ok: thinMachO(testing().MAC_MIN_OS_FLOOR), + bad1: thinMachO(above(1)), + bad2: thinMachO(above(2)), + }, + (dir) => { + expect(() => testing().checkMacOsVersionFloor(dir)).toThrow(/bad1[\s\S]*bad2/); + }, + ); + }); + + it("shouts if it parsed nothing, rather than reporting a clean payload", () => { + const header = Buffer.alloc(32); + header.writeUInt32LE(0xfeedfacf, 0); + withPayload({ bare: header }, (dir) => { + expect(() => testing().checkMacOsVersionFloor(dir)).toThrow(/bug in machoMinOs/); + }); + }); + + it("says nothing about a directory with no Mach-O in it", () => { + // Non-macOS packs reach this only if the tree exists; an empty one is not an error + // here — checkNativePayload already owns "the payload is incomplete". + withPayload({ "notes.txt": Buffer.from("hello") }, (dir) => { + expect(() => testing().checkMacOsVersionFloor(dir)).not.toThrow(); + }); + }); +}); + +describe("MAC_MIN_OS_FLOOR", () => { + it("matches the floor the .app declares to LaunchServices", () => { + const config = readFileSync( + path.join(path.dirname(BEFORE_PACK), "..", "electron-builder.json5"), + "utf8", + ); + const declared = config.match(/"minimumSystemVersion"\s*:\s*"([\d.]+)"/); + expect( + declared, + 'no "minimumSystemVersion" in electron-builder.json5 — without it the .app ' + + "inherits Electron's own floor, which is what let #515 ship", + ).not.toBeNull(); + + const { MAC_MIN_OS_FLOOR } = testing(); + const norm = (v) => v.split(".").concat(["0", "0"]).slice(0, 2).join("."); + // Equal, not merely <=: a pack-time guard looser than the app's own declaration + // would wave through exactly the binaries LaunchServices then refuses to run. + expect(norm(MAC_MIN_OS_FLOOR)).toBe(norm(declared[1])); + }); +}); diff --git a/scripts/build-whisper-stt.sh b/scripts/build-whisper-stt.sh index dc472814..74f5be04 100644 --- a/scripts/build-whisper-stt.sh +++ b/scripts/build-whisper-stt.sh @@ -73,6 +73,11 @@ os_arch_tag() { readonly OS_ARCH="$(os_arch_tag)" readonly OUT_DIR="${OUT_ROOT}/${OS_ARCH}" +# Kept beside the other build constants so it is greppable next to the ffmpeg one in +# scripts/fetch-ffmpeg-macos.mjs; the two must agree, and both must match +# `mac.minimumSystemVersion` in electron-builder.json5. +readonly MACOS_DEPLOYMENT_TARGET="13.0" + # Determine the default backend flag for this host. backend_flag_for_host() { case "${OS_ARCH}" in @@ -305,6 +310,21 @@ BUILD_FLAGS=() if [[ -n "${DEFAULT_FLAG}" ]]; then BUILD_FLAGS+=("${DEFAULT_FLAG}") fi +# Pin the macOS floor the app actually ships against (`mac.minimumSystemVersion` in +# electron-builder.json5). Without it CMake +# defaults the deployment target to the BUILD MACHINE's SDK, so whisper-stt-server and +# the libwhisper/libggml*/libparakeet dylibs inherit whatever macOS built them — +# measured 26.0 on the shipped v1.10.0 payload, and ~15.x from CI's `macos-latest`, +# a floor that moves on its own whenever GitHub rolls that image. +# +# This is the macOS twin of the ubuntu-22.04 pin in build-whisper-stt.yml: same defect +# (a shipped binary's floor decided by the runner rather than by the project), different +# libc. Note it is NOT a loader version gate — dyld does not refuse a binary whose minos +# exceeds the running OS. Setting it is what makes the linker enforce macOS 12 symbol +# availability, which is what actually fails at load time. See issue #515. +if [[ "${OS_ARCH}" == darwin-* ]]; then + BUILD_FLAGS+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=${MACOS_DEPLOYMENT_TARGET}") +fi # See the comment in build_variant() re: bash 3.2 + `set -u` + empty arrays # (macOS x64/CPU has no DEFAULT_FLAG, so BUILD_FLAGS is genuinely empty here). build_variant "default" ${BUILD_FLAGS[@]+"${BUILD_FLAGS[@]}"} diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs index d611aa62..48d92669 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -29,6 +29,20 @@ const ROOT = path.join(__dirname, ".."); const CRATES_DIR = path.join(ROOT, "crates"); /** Pinned release. The directory name is what build.rs looks for. */ +// The macOS floor the app ships against — keep in step with `mac.minimumSystemVersion` +// in electron-builder.json5, which is what the .app tells LaunchServices. +// +// Without it, clang defaults the deployment target +// to the BUILD MACHINE's SDK, so the vendored dylibs inherit whatever macOS built them — +// measured 26.0 on a local build and ~15.x from CI's `macos-latest`, a floor that moves on +// its own every time GitHub rolls that image. That is the same class of leak the configure +// comment below guards against for Homebrew packages, and it is the one it missed. +// +// Note this is NOT a loader version gate: dyld does not refuse a dylib whose minos exceeds +// the running OS (verified). Setting it is what makes the LINKER enforce macOS 12 symbol +// availability, which is the thing that actually breaks at load time. See issue #515. +const MACOS_DEPLOYMENT_TARGET = "13.0"; + const VERSION = "8.1.2"; const TARBALL_SHA256 = "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c"; const DEST = path.join(CRATES_DIR, "thirdparty", `ffmpeg-n${VERSION}-macos64-lgpl-shared`); @@ -202,6 +216,10 @@ run( "--disable-x86asm", `--arch=${process.arch === "arm64" ? "arm64" : "x86_64"}`, "--cc=clang", + // Both, not just cflags: the deployment target has to reach the link step too, or + // the dylibs are stamped with the build machine's floor however they were compiled. + `--extra-cflags=-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}`, + `--extra-ldflags=-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}`, ], { cwd: src }, );