From 4418c884f15eb7c14369736d6ec0eb4f9fac1b2a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 29 Aug 2026 16:39:41 +0200 Subject: [PATCH 1/3] fix(build): pin the macOS deployment target for ffmpeg and whisper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither build set one, so clang and CMake defaulted to the BUILD MACHINE's SDK and the shipped binaries inherited whatever macOS compiled them. Measured on the installed, notarized v1.10.0 arm64 payload: every ffmpeg dylib, every ggml/whisper/parakeet dylib and whisper-stt-server stamped minos 26.0, inside an app whose Info.plist declares LSMinimumSystemVersion 12.0. The minos number is NOT itself the bug. dyld does not refuse a binary — or a dylib — whose minos exceeds the running OS; both were verified to load here (a dylib stamped 27.0 loads fine on 26.5, with only a link-time warning). What the deployment target actually controls is which symbols the toolchain is willing to import from the OS, and that is where the damage is. Measured by rebuilding at 12.0 and diffing imports against the shipped binaries: ffmpeg identical import sets, 0 symbols either way. The claim that the compositor addon cannot load on macOS 12 is NOT supported; these would very likely have loaded. whisper 9 STRONG (non-weak) undefined refs to libc++ symbols that the 12.0 build does not reference at all: __ZTVNSt3__117bad_function_callE and friends __ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE4openEPKcj vtable/VTT for basic_ifstream / basic_ofstream Those are version-gated by libc++ itself. The SDK's availability header declares the bad_function_call key function as `availability(macos, strict, introduced = 15.4)`, and the cutovers reproduce exactly on a three-line test program: the fstream symbols start being imported at a 13.0 target, the bad_function_call ones at 15.4. Below those the toolchain emits local definitions instead — which is precisely what it does now. So the shipped STT helper carries strong references to symbols the toolchain says do not exist before macOS 15.4, well above the Monterey case that prompted this. Not observed on an old macOS — no such machine here — but that annotation is Apple's own statement about where the symbol ships. After the pin, every rebuilt Mach-O reports minos 12.0, whisper-stt-server carries 0 of those 9 refs, and it still loads and runs. This is the macOS twin of the ubuntu-22.04 pin in build-whisper-stt.yml: a shipped binary's floor decided by the runner rather than by the project. Refs #515 --- scripts/build-whisper-stt.sh | 20 ++++++++++++++++++++ scripts/fetch-ffmpeg-macos.mjs | 16 ++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/scripts/build-whisper-stt.sh b/scripts/build-whisper-stt.sh index dc4728141..71cfa6e6f 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 be <= the app's +# LSMinimumSystemVersion. +readonly MACOS_DEPLOYMENT_TARGET="12.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 (Electron's own +# LSMinimumSystemVersion, and what README/installation.md promise). 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 d611aa627..52563ee97 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -29,6 +29,18 @@ 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 (Electron 41's own LSMinimumSystemVersion, and +// what README/installation.md promise). 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 = "12.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 +214,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 }, ); From 50c956d94e0fc9e4bb91ba18c7255a05fb79ffee Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 29 Aug 2026 16:48:17 +0200 Subject: [PATCH 2/3] feat(build): refuse to pack a macOS binary built above the supported floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before-pack.cjs already refuses an incomplete macOS payload. "Complete" is not the same property as "runnable on the macOS we claim", and #515 was the second kind: the payload was whole, and one helper in it was built for macOS 13 while the app advertised 12. Nothing in the pipeline looked. Walks electron/native/bin/darwin-* and fails the pack if any Mach-O declares a minimum macOS above MAC_MIN_OS_FLOOR. Verified both directions against real binaries rather than only fixtures — on this branch, which still carries the original .macOS(.v13): $ node scripts/build-macos-screencapturekit-helper.mjs && node scripts/before-pack.cjs Refusing to package binaries that demand a newer macOS than the 12.0 floor - openscreen-macos-cursor-helper is built for macOS 13.0.0 (floor 12.0) - openscreen-screencapturekit-helper is built for macOS 13.0.0 (floor 12.0) and exit 0 once Package.swift is at .v12. Pointed at the installed, notarized v1.10.0 payload it names all 25 ffmpeg/whisper dylibs at 26.0. Parses LC_BUILD_VERSION (and LC_VERSION_MIN_MACOSX) out of the file rather than shelling out to `vtool`. Same reason neededSymbolVersions() does not use readelf and importedDlls() does not use dumpbin, plus one specific to this hook: it 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. Cross-checked against `vtool -show-build` on all 44 Mach-O files across two real payloads: 0 mismatches. Universal binaries take the highest slice, since an x86_64 half built on a newer machine strands Intel users just as thoroughly. The message names the file, its measured floor, the constant, and #515, and states the mechanism — dyld does NOT gate on the minos number; the deployment target decides which symbols get resolved against the OS, and a too-high floor leaves strong references to symbols the target macOS never had. Without that, the obvious "fix" is to raise the constant until it passes. Carries the same parser-sanity assertion as its Linux sibling: reading no deployment target from any Mach-O means the parser broke, not that the payload is unusually clean. Tests synthesise Mach-O headers instead of invoking clang, so they run on the Linux and Windows CI legs as well, and tie MAC_MIN_OS_FLOOR to README.md. That assertion is one-directional on purpose — building for older than advertised is harmless, building for newer is the bug — so it holds both before and after the README correction in the #515 branch. Refs #515 --- scripts/before-pack.cjs | 180 +++++++++++++++++++++++++++++++++- scripts/before-pack.test.mjs | 184 +++++++++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+), 2 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index 70a2d6ccb..b54060ba9 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,21 @@ 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. + * + * Keep in step with README.md's system requirements and with Electron's own + * LSMinimumSystemVersion, which the .app inherits verbatim (electron-builder writes the + * key only when `mac.minimumSystemVersion` is set, and it is not). + * + * before-pack.test.mjs ties this to the README so the two cannot drift apart quietly. The + * invariant it asserts is one-directional on purpose: this floor must be no HIGHER than + * the oldest macOS the README promises. Building for something older than we advertise is + * harmless; building for something newer is #515. + */ +const MAC_MIN_OS_FLOOR = "12.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 +739,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 +847,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 905283510..3e993a665 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -88,3 +88,187 @@ 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", () => { + it("passes a payload built at the floor", () => { + withPayload({ a: thinMachO("12.0"), b: thinMachO("11.0") }, (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("13.0") }, (dir) => { + let message = ""; + try { + testing().checkMacOsVersionFloor(dir); + } catch (err) { + message = err.message; + } + expect(message).toContain("openscreen-macos-cursor-helper"); + expect(message).toContain("macOS 13.0.0"); + expect(message).toContain("12.0"); + 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("12.0"), bad1: thinMachO("13.0"), bad2: thinMachO("26.0") }, + (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("is no higher than the oldest macOS the README promises", () => { + const readme = readFileSync(path.join(path.dirname(BEFORE_PACK), "..", "README.md"), "utf8"); + const claimed = readme.match(/^-\s+\*\*macOS\*\*:\s*(\d+(?:\.\d+)?)/m); + expect(claimed, "no macOS line found in README.md system requirements").not.toBeNull(); + + const { MAC_MIN_OS_FLOOR } = testing(); + const asNumbers = (v) => v.split(".").map(Number); + const [floorMajor, floorMinor = 0] = asNumbers(MAC_MIN_OS_FLOOR); + const [claimedMajor, claimedMinor = 0] = asNumbers(claimed[1]); + + // One-directional: building for older than advertised is harmless, building for + // newer is the bug. So floor <= claimed, not floor === claimed. + expect( + floorMajor * 1000 + floorMinor, + `before-pack.cjs builds for macOS ${MAC_MIN_OS_FLOOR} but README.md promises ` + + `${claimed[1]} or later. Shipping binaries that cannot run on a version the ` + + "README claims to support is issue #515.", + ).toBeLessThanOrEqual(claimedMajor * 1000 + claimedMinor); + }); +}); From 7908d3169827f51658254eeed93b428e4a6ce70a Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 29 Aug 2026 20:26:18 +0200 Subject: [PATCH 3/3] fix(build): retarget the macOS floor to 13, matching the declared support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the decision in the parent branch to declare macOS 13 rather than accommodate 12. The pins and the pack-time guard move with it: 13.0 in fetch-ffmpeg-macos.mjs, build-whisper-stt.sh and MAC_MIN_OS_FLOOR, all now described as tracking `mac.minimumSystemVersion` in electron-builder.json5, which is the number the .app actually tells LaunchServices. This does NOT weaken the fix — it is the whole point of it. The defect was never Monterey specifically: the shipped v1.10.0 binaries carried 9 strong undefined references to libc++ symbols that the toolchain dates to macOS 15.4 (`availability(macos, strict, introduced = 15.4)` on the bad_function_call key function), so STT was expected to fail to load on Ventura and Sonoma too — the versions this project still supports, one of which the README recommends. Rebuilt at 13.0 and re-measured rather than assumed: whisper-stt-server 15.4-gated (bad_function_call) 9 -> 0 13.0-gated (fstream/filebuf) 7 (correct at this floor) The second row is the point of pinning rather than merely lowering: at a 13.0 target the toolchain still imports the fstream symbols, which exist on 13.0, and stops importing the 15.4 ones. Both halves are the deployment target doing its job. Every shipped Mach-O now reports 13.0 (compositor_view.node stays at rustc's 11.0, below the floor), whisper-stt-server still loads and runs, the compositor addon links the rebuilt ffmpeg, and `node scripts/before-pack.cjs` exits 0 on the complete payload. before-pack.test.mjs now asserts MAC_MIN_OS_FLOOR EQUALS the declared minimumSystemVersion, not merely that it is no higher: a pack-time guard looser than the app's own declaration would wave through exactly the binaries LaunchServices then refuses to run. Its fixtures are derived from the floor instead of hardcoding versions — the previous literals silently turned from offenders into compliant binaries when the floor moved, so the guard's own tests stopped testing it. Refs #515 --- scripts/before-pack.cjs | 15 ++++----- scripts/before-pack.test.mjs | 58 +++++++++++++++++++++------------- scripts/build-whisper-stt.sh | 10 +++--- scripts/fetch-ffmpeg-macos.mjs | 8 +++-- 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index b54060ba9..b06568008 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -514,16 +514,13 @@ 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. * - * Keep in step with README.md's system requirements and with Electron's own - * LSMinimumSystemVersion, which the .app inherits verbatim (electron-builder writes the - * key only when `mac.minimumSystemVersion` is set, and it is not). - * - * before-pack.test.mjs ties this to the README so the two cannot drift apart quietly. The - * invariant it asserts is one-directional on purpose: this floor must be no HIGHER than - * the oldest macOS the README promises. Building for something older than we advertise is - * harmless; building for something newer is #515. + * 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 = "12.0"; +const MAC_MIN_OS_FLOOR = "13.0"; /** * The one supported way past the ceiling, for the one case it does not fit: a developer diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index 3e993a665..5c34f59cd 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -197,8 +197,19 @@ describe("machoMinOs", () => { }); describe("checkMacOsVersionFloor", () => { - it("passes a payload built at the floor", () => { - withPayload({ a: thinMachO("12.0"), b: thinMachO("11.0") }, (dir) => { + /** + * 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(); }); }); @@ -209,7 +220,7 @@ describe("checkMacOsVersionFloor", () => { * 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("13.0") }, (dir) => { + withPayload({ "openscreen-macos-cursor-helper": thinMachO(above()) }, (dir) => { let message = ""; try { testing().checkMacOsVersionFloor(dir); @@ -217,8 +228,8 @@ describe("checkMacOsVersionFloor", () => { message = err.message; } expect(message).toContain("openscreen-macos-cursor-helper"); - expect(message).toContain("macOS 13.0.0"); - expect(message).toContain("12.0"); + 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"); @@ -227,7 +238,11 @@ describe("checkMacOsVersionFloor", () => { it("reports every offender, not just the first", () => { withPayload( - { ok: thinMachO("12.0"), bad1: thinMachO("13.0"), bad2: thinMachO("26.0") }, + { + 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/); }, @@ -252,23 +267,22 @@ describe("checkMacOsVersionFloor", () => { }); describe("MAC_MIN_OS_FLOOR", () => { - it("is no higher than the oldest macOS the README promises", () => { - const readme = readFileSync(path.join(path.dirname(BEFORE_PACK), "..", "README.md"), "utf8"); - const claimed = readme.match(/^-\s+\*\*macOS\*\*:\s*(\d+(?:\.\d+)?)/m); - expect(claimed, "no macOS line found in README.md system requirements").not.toBeNull(); + 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 asNumbers = (v) => v.split(".").map(Number); - const [floorMajor, floorMinor = 0] = asNumbers(MAC_MIN_OS_FLOOR); - const [claimedMajor, claimedMinor = 0] = asNumbers(claimed[1]); - - // One-directional: building for older than advertised is harmless, building for - // newer is the bug. So floor <= claimed, not floor === claimed. - expect( - floorMajor * 1000 + floorMinor, - `before-pack.cjs builds for macOS ${MAC_MIN_OS_FLOOR} but README.md promises ` + - `${claimed[1]} or later. Shipping binaries that cannot run on a version the ` + - "README claims to support is issue #515.", - ).toBeLessThanOrEqual(claimedMajor * 1000 + claimedMinor); + 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 71cfa6e6f..74f5be046 100644 --- a/scripts/build-whisper-stt.sh +++ b/scripts/build-whisper-stt.sh @@ -74,9 +74,9 @@ 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 be <= the app's -# LSMinimumSystemVersion. -readonly MACOS_DEPLOYMENT_TARGET="12.0" +# 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() { @@ -310,8 +310,8 @@ BUILD_FLAGS=() if [[ -n "${DEFAULT_FLAG}" ]]; then BUILD_FLAGS+=("${DEFAULT_FLAG}") fi -# Pin the macOS floor the app actually ships against (Electron's own -# LSMinimumSystemVersion, and what README/installation.md promise). Without it CMake +# 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`, diff --git a/scripts/fetch-ffmpeg-macos.mjs b/scripts/fetch-ffmpeg-macos.mjs index 52563ee97..48d92669b 100644 --- a/scripts/fetch-ffmpeg-macos.mjs +++ b/scripts/fetch-ffmpeg-macos.mjs @@ -29,8 +29,10 @@ 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 (Electron 41's own LSMinimumSystemVersion, and -// what README/installation.md promise). Without it, clang defaults the deployment target +// 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 @@ -39,7 +41,7 @@ const CRATES_DIR = path.join(ROOT, "crates"); // 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 = "12.0"; +const MACOS_DEPLOYMENT_TARGET = "13.0"; const VERSION = "8.1.2"; const TARBALL_SHA256 = "464beb5e7bf0c311e68b45ae2f04e9cc2af88851abb4082231742a74d97b524c";