From 06e9074f75884b82e61423d7f214f0cf93863f40 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:48:23 +0200 Subject: [PATCH 01/24] feat(build): add pure Windows helper arch-resolution module --- scripts/windows-helper-arch.mjs | 62 ++++++++++++++++++++++++ scripts/windows-helper-arch.test.mjs | 71 ++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 scripts/windows-helper-arch.mjs create mode 100644 scripts/windows-helper-arch.test.mjs diff --git a/scripts/windows-helper-arch.mjs b/scripts/windows-helper-arch.mjs new file mode 100644 index 000000000..e9f0503a6 --- /dev/null +++ b/scripts/windows-helper-arch.mjs @@ -0,0 +1,62 @@ +// Pure helpers that map a target CPU architecture to the parameters the Windows +// native-helper build needs. Kept side-effect free so it is unit-testable and +// importable from both the build script and Vitest. + +export const SUPPORTED_ARCHES = ["x64", "arm64"]; + +const ALIASES = new Map([ + ["x64", "x64"], + ["amd64", "x64"], + ["x86_64", "x64"], + ["arm64", "arm64"], + ["aarch64", "arm64"], +]); + +export function normalizeArch(value) { + if (value === undefined || value === null || value === "") { + return undefined; + } + return ALIASES.get(String(value).toLowerCase()); +} + +export function resolveTargetArch({ cliArch, envArch, hostArch } = {}) { + for (const [label, raw] of [ + ["--arch", cliArch], + ["OPENSCREEN_WIN_HELPER_ARCH", envArch], + ]) { + if (raw !== undefined && raw !== null && raw !== "") { + const normalized = normalizeArch(raw); + if (!normalized) { + throw new Error( + `Invalid ${label} value "${raw}". Expected one of: ${SUPPORTED_ARCHES.join(", ")}.`, + ); + } + return normalized; + } + } + + const host = normalizeArch(hostArch); + if (!host) { + throw new Error( + `Unsupported host architecture "${hostArch}". Pass --arch with one of: ${SUPPORTED_ARCHES.join(", ")}.`, + ); + } + return host; +} + +export function resolveVcvarsArch(hostArch, targetArch) { + const host = normalizeArch(hostArch); + const target = normalizeArch(targetArch); + if (!host || !target) { + throw new Error(`Unsupported architecture pair host="${hostArch}" target="${targetArch}".`); + } + return host === target ? target : `${host}_${target}`; +} + +export function winBinDirName(targetArch) { + const target = normalizeArch(targetArch); + if (!target) { + throw new Error(`Unsupported target architecture "${targetArch}".`); + } + return `win32-${target}`; +} diff --git a/scripts/windows-helper-arch.test.mjs b/scripts/windows-helper-arch.test.mjs new file mode 100644 index 000000000..078620347 --- /dev/null +++ b/scripts/windows-helper-arch.test.mjs @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + SUPPORTED_ARCHES, + normalizeArch, + resolveTargetArch, + resolveVcvarsArch, + winBinDirName, +} from "./windows-helper-arch.mjs"; + +describe("normalizeArch", () => { + it("maps known aliases to canonical arches", () => { + expect(normalizeArch("x64")).toBe("x64"); + expect(normalizeArch("amd64")).toBe("x64"); + expect(normalizeArch("x86_64")).toBe("x64"); + expect(normalizeArch("arm64")).toBe("arm64"); + expect(normalizeArch("aarch64")).toBe("arm64"); + expect(normalizeArch("ARM64")).toBe("arm64"); + }); + + it("returns undefined for empty or unknown values", () => { + expect(normalizeArch(undefined)).toBeUndefined(); + expect(normalizeArch("")).toBeUndefined(); + expect(normalizeArch("mips")).toBeUndefined(); + }); +}); + +describe("resolveTargetArch", () => { + it("prefers the CLI arch over env and host", () => { + expect(resolveTargetArch({ cliArch: "arm64", envArch: "x64", hostArch: "x64" })).toBe("arm64"); + }); + + it("falls back to env when no CLI arch", () => { + expect(resolveTargetArch({ envArch: "arm64", hostArch: "x64" })).toBe("arm64"); + }); + + it("defaults to the host arch when nothing explicit is given", () => { + expect(resolveTargetArch({ hostArch: "arm64" })).toBe("arm64"); + expect(resolveTargetArch({ hostArch: "x64" })).toBe("x64"); + }); + + it("throws on an invalid explicit arch", () => { + expect(() => resolveTargetArch({ cliArch: "mips", hostArch: "x64" })).toThrow(/Invalid/); + }); + + it("throws on an unsupported host with no explicit arch", () => { + expect(() => resolveTargetArch({ hostArch: "ppc64" })).toThrow(/host/i); + }); +}); + +describe("resolveVcvarsArch", () => { + it("returns native tokens when host equals target", () => { + expect(resolveVcvarsArch("x64", "x64")).toBe("x64"); + expect(resolveVcvarsArch("arm64", "arm64")).toBe("arm64"); + }); + + it("returns cross tokens as _", () => { + expect(resolveVcvarsArch("x64", "arm64")).toBe("x64_arm64"); + expect(resolveVcvarsArch("arm64", "x64")).toBe("arm64_x64"); + }); +}); + +describe("winBinDirName", () => { + it("builds the packaged bin folder name", () => { + expect(winBinDirName("x64")).toBe("win32-x64"); + expect(winBinDirName("arm64")).toBe("win32-arm64"); + }); +}); + +it("exposes the supported arch list", () => { + expect(SUPPORTED_ARCHES).toEqual(["x64", "arm64"]); +}); From 7de7e8b271bf58fbac09baff48a6ed2190828ce7 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:53:17 +0200 Subject: [PATCH 02/24] feat(build): make Windows WGC helper build arch-aware (x64/arm64, native+cross) --- scripts/build-windows-wgc-helper.mjs | 35 ++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 8d4cfc018..22e8da6bd 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -4,12 +4,37 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { findVcVarsAll, run as spawnStep } from "./msvcEnv.mjs"; +import { + resolveTargetArch, + resolveVcvarsArch, + winBinDirName, +} from "./windows-helper-arch.mjs"; + +function parseArchFlag(argv) { + const eq = argv.find((a) => a.startsWith("--arch=")); + if (eq) { + return eq.slice("--arch=".length); + } + const idx = argv.indexOf("--arch"); + if (idx !== -1) { + return argv[idx + 1]; + } + return undefined; +} + +const TARGET_ARCH = resolveTargetArch({ + cliArch: parseArchFlag(process.argv.slice(2)), + envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, + hostArch: process.arch, +}); +const VCVARS_ARCH = resolveVcvarsArch(process.arch, TARGET_ARCH); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); const SOURCE_DIR = path.join(ROOT, "electron", "native", "wgc-capture"); const BUILD_DIR = path.join(SOURCE_DIR, "build"); const COMPAT_LIB_DIR = path.join(BUILD_DIR, "compat-libs"); -const BIN_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64"); +const BIN_DIR = path.join(ROOT, "electron", "native", "bin", winBinDirName(TARGET_ARCH)); const CMAKE = process.env.CMAKE_EXE ?? "cmake"; function findWindowsSdkUmLibDir() { @@ -21,7 +46,7 @@ function findWindowsSdkUmLibDir() { return fs .readdirSync(sdkLibRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(sdkLibRoot, entry.name, "um", "x64")) + .map((entry) => path.join(sdkLibRoot, entry.name, "um", TARGET_ARCH)) .filter((candidate) => fs.existsSync(path.join(candidate, "kernel32.lib"))) .sort() .at(-1); @@ -44,10 +69,10 @@ async function runInVsEnv(command) { cmdPath, [ "@echo off", - `call "${vcvarsAll}" x64`, + `call "${vcvarsAll}" ${VCVARS_ARCH}`, "if errorlevel 1 exit /b %errorlevel%", `if not exist "${COMPAT_LIB_DIR}" mkdir "${COMPAT_LIB_DIR}"`, - `for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\x64\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, + `for %%L in (gdi32.lib gdiplus.lib winspool.lib shell32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib) do if not exist "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\${TARGET_ARCH}\\%%L" copy /Y "%WindowsSdkDir%Lib\\%WindowsSDKLibVersion%um\\${TARGET_ARCH}\\kernel32.Lib" "${COMPAT_LIB_DIR}\\%%L" >nul`, "if errorlevel 1 exit /b %errorlevel%", `set "LIB=${sdkUmLibDir ? `${sdkUmLibDir};` : ""}%LIB%;${COMPAT_LIB_DIR}"`, command, @@ -67,6 +92,8 @@ if (process.platform !== "win32") { process.exit(0); } +console.log(`Building Windows WGC helper for target arch: ${TARGET_ARCH} (vcvars: ${VCVARS_ARCH})`); + fs.mkdirSync(BUILD_DIR, { recursive: true }); await runInVsEnv( From d45e3dd8f0d5aad4ec292a30bfb8064622784aee Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:57:02 +0200 Subject: [PATCH 03/24] feat(build): add build:win:arm64 and build:native:win:arm64 scripts --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index e80168645..a1f5a240d 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,8 @@ "build:linux": "npm run fetch:ffmpeg:sdk && npm run build:native:linux && npm run fetch:onnxruntime && npm run build:native:compositor:linux && tsc && vite build && electron-builder --linux AppImage deb pacman rpm --config.npmRebuild=false", "build:whisper-binaries": "bash scripts/build-whisper-stt.sh", "test:whisper-stt": "node scripts/test-whisper-stt.mjs", + "build:native:win:arm64": "node scripts/build-windows-wgc-helper.mjs --arch arm64", + "build:win:arm64": "npm run build:native:win:arm64 && npm run fetch:ffmpeg && npm run fetch:onnxruntime && npm run stage:vcomp && npm run build:native:compositor && tsc && vite build && electron-builder --win --arm64 --config.npmRebuild=false", "test": "vitest --run", "test:changed": "vitest --run --changed", "wb": "vitest --run --config vitest.workbench.config.ts", From 3c294ef94d867eb6361e4144a04e0359236dc747 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:58:51 +0200 Subject: [PATCH 04/24] build: include arch in Windows installer filename to avoid x64/arm64 collision --- electron-builder.json5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron-builder.json5 b/electron-builder.json5 index 8da5be310..aca0cf493 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -395,7 +395,7 @@ "nsis" ], "icon": "icons/icons/win/icon.ico", - "artifactName": "${productName}.Setup.${version}.${ext}", + "artifactName": "${productName}.Setup.${version}-${arch}.${ext}", "extraResources": [ { "from": "electron/native/bin", From 932e35ad8c8f2acc5542676a57a610788b40d051 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:01:01 +0200 Subject: [PATCH 05/24] ci: build Windows x64 and arm64 installers via matrix --- .github/workflows/build.yml | 39 ++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 387bed2b9..c1df5d14e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,8 +42,12 @@ concurrency: jobs: build-windows: - name: Windows installer + name: Windows ${{ matrix.arch }} installer runs-on: windows-latest + strategy: + fail-fast: false + matrix: + arch: [x64, arm64] steps: - name: Checkout code uses: actions/checkout@v7 @@ -51,6 +55,25 @@ jobs: - name: Setup Node.js uses: ./.github/actions/setup + - name: Ensure MSVC ARM64 build tools + if: matrix.arch == 'arm64' + shell: pwsh + run: | + $vswhere = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" + $installPath = & $vswhere -latest -products * -property installationPath + $hasArm64 = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.ARM64 ` + -property installationPath + if (-not $hasArm64) { + Write-Host "Installing MSVC ARM64 build tools component..." + $installer = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe" + & $installer modify --installPath "$installPath" ` + --add Microsoft.VisualStudio.Component.VC.Tools.ARM64 ` + --quiet --norestart --wait + } else { + Write-Host "MSVC ARM64 build tools already present." + } + # STT is the bundled whisper-stt-server (whisper.cpp with native DTW token # timestamps); no VAD model is fetched here. The binary is built by # build-whisper-stt.yml and staged below — without that step the installer @@ -60,15 +83,20 @@ jobs: shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash scripts/stage-whisper-stt.sh win32-x64 + run: bash scripts/stage-whisper-stt.sh win32-${{ matrix.arch }} - - name: Build Windows app + - name: Build Windows app (x64) + if: matrix.arch == 'x64' run: npm run build:win -- --publish never + - name: Build Windows app (arm64) + if: matrix.arch == 'arm64' + run: npm run build:win:arm64 -- --publish never + - name: Upload Windows installer uses: actions/upload-artifact@v7 with: - name: openscreen-windows + name: openscreen-windows-${{ matrix.arch }} # latest.yml is the update feed electron-updater reads; the .blockmap is what lets it # download a delta instead of the full ~243 MB installer. Both were already produced # by every build and thrown away here, because this glob only matched the .exe. @@ -876,7 +904,8 @@ jobs: - name: Download Windows installer uses: actions/download-artifact@v8 with: - name: openscreen-windows + pattern: openscreen-windows-* + merge-multiple: true path: artifacts/windows - name: Download macOS arm64 DMG From 78dd673169e29fc0e0a2a703db9631dd9dc7cc89 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:05:05 +0200 Subject: [PATCH 06/24] docs: document Windows arm64 native helper build and installer --- README.md | 2 +- electron/native/README.md | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 872f99144..2cf2237a2 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Microsoft signs the Store package during certification, so it installs with no s **Alternative — standalone installer** -Download the `.exe` from the [Releases page](https://github.com/getopenscreen/openscreen/releases). Use this if you can't reach the Store — Windows LTSC, a locked-down work machine, an offline install, or if you want a specific older version. +Download the `.exe` from the [Releases page](https://github.com/getopenscreen/openscreen/releases). Installers are provided for both x64 (`Openscreen.Setup.-x64.exe`) and ARM64 (`Openscreen.Setup.-arm64.exe`) — on an ARM64 device the x64 build runs under emulation and records poorly, so take the ARM64 one. Use this if you can't reach the Store — Windows LTSC, a locked-down work machine, an offline install, or if you want a specific older version. > [!NOTE] > The `.exe` is not code-signed, so Windows SmartScreen shows **"Windows protected your PC"** and reports an unknown publisher. Choose **More info** → **Run anyway** to continue. diff --git a/electron/native/README.md b/electron/native/README.md index 16013c4bd..0d5ea7dbe 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -45,10 +45,14 @@ Windows native recording is resolved from one of these locations: Build the Windows helper with: ```powershell +# Builds for the host architecture by default (x64 on x64 hosts, arm64 on arm64 hosts). npm run build:native:win + +# Explicit target architecture (native on a matching host, or cross-compiled otherwise): +node scripts/build-windows-wgc-helper.mjs --arch arm64 ``` -The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-x64/wgc-capture.exe`. +The target architecture is resolved from `--arch` (or the `OPENSCREEN_WIN_HELPER_ARCH` env var), falling back to the host arch. Cross-compiling requires the matching MSVC component — "VS C++ ARM64/ARM64EC build tools" for `arm64`. The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-/wgc-capture.exe` (e.g. `win32-arm64`). The helper contract is process-based: the app starts the process with one JSON argument and sends commands on stdin. `stop\n` finalizes the recording. During migration the helper prints both newline-delimited JSON events and the legacy text messages `Recording started` / `Recording stopped. Output path: `. From c87d693423e9f96058b81842e0381a4552d041dd Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:14:57 +0200 Subject: [PATCH 07/24] fix(build): wipe WGC build dir on target-arch change to prevent stale-compiler wrong-arch binaries --- scripts/build-windows-wgc-helper.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 22e8da6bd..4aae7a99f 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -94,7 +94,21 @@ if (process.platform !== "win32") { console.log(`Building Windows WGC helper for target arch: ${TARGET_ARCH} (vcvars: ${VCVARS_ARCH})`); +// CMake caches the detected compiler in the build directory. Reusing a build +// dir that was configured for a different target arch silently produces +// wrong-arch binaries (the cached compiler wins over the new vcvars env). Wipe +// the build dir when the target arch changes; same-arch reruns stay incremental. +const ARCH_STAMP = path.join(BUILD_DIR, ".target-arch"); +if (fs.existsSync(BUILD_DIR)) { + const previousArch = fs.existsSync(ARCH_STAMP) + ? fs.readFileSync(ARCH_STAMP, "utf8").trim() + : ""; + if (previousArch !== TARGET_ARCH) { + fs.rmSync(BUILD_DIR, { recursive: true, force: true }); + } +} fs.mkdirSync(BUILD_DIR, { recursive: true }); +fs.writeFileSync(ARCH_STAMP, TARGET_ARCH); await runInVsEnv( `"${CMAKE}" -S "${SOURCE_DIR}" -B "${BUILD_DIR}" -G Ninja -DCMAKE_BUILD_TYPE=Release`, From cc6c50e2ee6b3bdf8fe7eafeff91bbd54ff43c6a Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:35:30 +0200 Subject: [PATCH 08/24] ci: fetch win32-arm64 sharp prebuilt for arm64 packaging (non-fatal) --- .github/workflows/build.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1df5d14e..b81d9d62c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,6 +74,23 @@ jobs: Write-Host "MSVC ARM64 build tools already present." } + - name: Ensure win32-arm64 sharp binary + # sharp (transitive via @xenova/transformers) only fetches a host-arch + # prebuilt at install time, so on the x64 runner the packaged arm64 app + # would otherwise ship an x64 sharp.node. Best-effort fetch of the + # win32-arm64 prebuilt; non-fatal so it never blocks the build. Verify + # the first arm64 release loads sharp if auto-captions use it. + if: matrix.arch == 'arm64' + continue-on-error: true + shell: bash + run: npm_config_platform=win32 npm_config_arch=arm64 npm rebuild sharp + + - name: Cache caption assets + uses: actions/cache@v4 + with: + path: caption-assets + key: caption-assets-${{ runner.os }}-${{ hashFiles('scripts/fetch-caption-model.mjs') }} + # STT is the bundled whisper-stt-server (whisper.cpp with native DTW token # timestamps); no VAD model is fetched here. The binary is built by # build-whisper-stt.yml and staged below — without that step the installer From 292147625b91e9ba1a4e0852ecf68936a7bbed22 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:04:06 +0200 Subject: [PATCH 09/24] fix(build): check the Windows native payload for the target arch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkWinNativePayload` looked in `electron/native/bin/win32-x64` unconditionally, so an arm64 package was validated against the x64 directory. A complete arm64 payload failed the guard while an arm64 build carrying only x64 binaries would have passed it — the check was inverted in effect for that arch. The macOS branch of the same hook already derives its tag from `archTagFor(context)`; this brings Windows in line. electron-builder passes the target arch in the beforePack context, so nothing new has to be threaded through. --- scripts/before-pack.cjs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index f92d8e7ed..44e0ed02f 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -465,8 +465,8 @@ function checkWinNoRedistDependency(dir) { ); } -function checkWinNativePayload() { - const dir = path.join(ROOT, "electron", "native", "bin", "win32-x64"); +function checkWinNativePayload(context) { + const dir = path.join(ROOT, "electron", "native", "bin", `win32-${archTagFor(context)}`); checkNativePayload({ dir, required: WIN_REQUIRED, @@ -1118,15 +1118,9 @@ exports.default = async function beforePack(context) { // The copy that ships is the arch-tagged one under electron/native/bin/ // (win.extraResources), beside its ffmpeg DLLs — not the dev copy this hook // used to be the sole guardian of. Same reasoning as the darwin branch below. - const shipped = path.join( - ROOT, - "electron", - "native", - "bin", - "win32-x64", - "compositor_view.node", - ); - checkWinNativePayload(); + const tag = `win32-${archTagFor(context)}`; + const shipped = path.join(ROOT, "electron", "native", "bin", tag, "compositor_view.node"); + checkWinNativePayload(context); checkCompositorAddonFreshness(shipped, FIX, "D3D11"); return; } From c9a5b188adf33c96fbd2ee430f4865c206e3c078 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:04:27 +0200 Subject: [PATCH 10/24] feat(build): build the D3D11 compositor addon for the target arch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The addon build hard-coded `vcvarsall x64` and vendored its output into `electron/native/bin/win32-x64`, so an arm64 package either shipped no `compositor_view.node` at all or one the ARM64 runtime cannot load. The loader has always resolved `win32-arm64` correctly (`compositorViewService.ts`); only the producing side was fixed to x64. Resolution now goes through `windows-helper-arch.mjs`, the same `--arch` / `OPENSCREEN_WIN_HELPER_ARCH` order the WGC helper already uses, so one flag drives every Windows native artefact. `crates/.cargo/config.toml` gains the aarch64 counterpart of the existing x86_64 `crt-static` block. Without it the addon imports VCRUNTIME140.dll and `scripts/before-pack.cjs` rejects the package — correctly, since that DLL is not part of Windows. --- crates/.cargo/config.toml | 6 ++++++ scripts/build-windows-compositor-addon.mjs | 23 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml index cb1fd42fb..642cf6842 100644 --- a/crates/.cargo/config.toml +++ b/crates/.cargo/config.toml @@ -34,6 +34,12 @@ LIBCLANG_PATH = "C:\\Program Files\\LLVM\\bin" [target.x86_64-pc-windows-msvc] rustflags = ["-C", "target-feature=+crt-static"] +# Windows on ARM: meme raison que x86_64 ci-dessus. Le paquet arm64 vise des +# machines Snapdragon ou le Redistribuable VC++ n'est pas plus garanti qu'ailleurs, +# et scripts/before-pack.cjs applique la meme regle aux deux arches. +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + # macOS : BtbN ne publie pas de build macOS (cf. scripts/fetch-ffmpeg.mjs), donc on # laisse `crates/compositor/build.rs` chercher MAC_FFMPEG_DIR (env var explicite posée # par la CI macOS ou par le dev local) ou un répertoire attendu sous `thirdparty/`. diff --git a/scripts/build-windows-compositor-addon.mjs b/scripts/build-windows-compositor-addon.mjs index cc40675d1..5a6da5df4 100644 --- a/scripts/build-windows-compositor-addon.mjs +++ b/scripts/build-windows-compositor-addon.mjs @@ -19,12 +19,31 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { findVcVarsAll, run as spawnStep } from "./msvcEnv.mjs"; +import { resolveTargetArch, resolveVcvarsArch, winBinDirName } from "./windows-helper-arch.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); const CRATES_DIR = path.join(ROOT, "crates"); const BUILD_OUT_DIR = path.join(ROOT, "electron", "native", "compositor-view", "build"); +// Same resolution order as build-windows-wgc-helper.mjs so a single --arch (or +// OPENSCREEN_WIN_HELPER_ARCH) drives every Windows native artefact the packaged +// app ships. Without this the addon was always built x64 and copied into +// win32-x64/, so an arm64 package either missed it or shipped one the ARM64 +// runtime cannot load. +const cliArch = (() => { + const eq = process.argv.find((a) => a.startsWith("--arch=")); + if (eq) return eq.slice("--arch=".length); + const idx = process.argv.indexOf("--arch"); + return idx !== -1 ? process.argv[idx + 1] : undefined; +})(); +const TARGET_ARCH = resolveTargetArch({ + cliArch, + envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, + hostArch: process.arch, +}); +const VCVARS_ARCH = resolveVcvarsArch(process.arch, TARGET_ARCH); + // cwd defaults to crates/, not ROOT: cargo reads FFMPEG_DIR and LIBCLANG_PATH // from crates/.cargo/config.toml, which only applies when it runs from there. const run = (command, args, options = {}) => @@ -51,7 +70,7 @@ async function runInVsEnv(command) { cmdPath, [ "@echo off", - `call "${vcvarsAll}" x64`, + `call "${vcvarsAll}" ${VCVARS_ARCH}`, "if errorlevel 1 exit /b %errorlevel%", command, "exit /b %errorlevel%", @@ -118,7 +137,7 @@ fs.copyFileSync(builtDll, dest); // directory is searched for its dependencies. Colocating removes the PATH mechanism // rather than repairing it. `buildCandidatePaths` already probes this location // first, so no loader change is needed. -const archBinDir = path.join(ROOT, "electron", "native", "bin", "win32-x64"); +const archBinDir = path.join(ROOT, "electron", "native", "bin", winBinDirName(TARGET_ARCH)); fs.mkdirSync(archBinDir, { recursive: true }); const archDest = path.join(archBinDir, "compositor_view.node"); fs.copyFileSync(builtDll, archDest); From 13720c3b982258a88cd7a4ef2c5b2fd48b30a3c2 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:04:30 +0200 Subject: [PATCH 11/24] feat(build): stage the Visual C++ runtime for the target arch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destination and Redist lookup were both pinned to x64, so an arm64 package got no CRT staged and `checkWinNoRedistDependency` refused to pack: the vendored `onnxruntime.dll` imports MSVCP140/MSVCP140_1/VCRUNTIME140 and, being an upstream release binary, cannot be rebuilt against the static CRT the way our own Rust addon can. The Redist tree carries an arm64 directory beside x64 with an identical layout, so only the architecture segment changes. Staging an x64 CRT next to an ARM64 onnxruntime.dll would have satisfied the name check and still failed in the loader on the target machine, which is why the search filter moves with the target too. Note that Microsoft ships an x64 `vcruntime140_1.dll` inside its own arm64 Redist directory — that file is the x64-only exception-handling helper and nothing on ARM64 imports it. We stage what the Redist provides rather than second-guessing it. --- scripts/stage-vcomp-runtime.mjs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/stage-vcomp-runtime.mjs b/scripts/stage-vcomp-runtime.mjs index d987946ea..274d17024 100644 --- a/scripts/stage-vcomp-runtime.mjs +++ b/scripts/stage-vcomp-runtime.mjs @@ -35,10 +35,28 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { findVcVarsAll } from "./msvcEnv.mjs"; +import { resolveTargetArch, winBinDirName } from "./windows-helper-arch.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); -const DEST_DIR = path.join(ROOT, "electron", "native", "bin", "win32-x64"); +// Same --arch / OPENSCREEN_WIN_HELPER_ARCH resolution as the two native build +// scripts, so one flag drives the whole Windows payload. The Redist tree carries a +// sibling arm64 directory beside x64 with an identical layout, so only the +// architecture segment changes. Staging an x64 CRT beside an ARM64 onnxruntime.dll +// would satisfy before-pack's name check and still fail in the loader on the +// target machine. +const TARGET_ARCH = resolveTargetArch({ + cliArch: (() => { + const eq = process.argv.find((a) => a.startsWith("--arch=")); + if (eq) return eq.slice("--arch=".length); + const idx = process.argv.indexOf("--arch"); + return idx !== -1 ? process.argv[idx + 1] : undefined; + })(), + envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, + hostArch: process.arch, +}); +const ARCH_SEGMENT = TARGET_ARCH === "arm64" ? /\\arm64\\/i : /\\x64\\/i; +const DEST_DIR = path.join(ROOT, "electron", "native", "bin", winBinDirName(TARGET_ARCH)); // Lower-case, because that is how they are compared against `readdirSync` names. // vcomp140 lives in Microsoft.VC.OpenMP, the other four in Microsoft.VC.CRT — // sibling directories under the same Redist tree, so one walk finds them all. @@ -104,7 +122,7 @@ function findRedistCopies() { const lower = entry.name.toLowerCase(); if (entry.isDirectory()) { walk(full, depth + 1); - } else if (wanted.has(lower) && /\\Redist\\/i.test(full) && /\\x64\\/i.test(full)) { + } else if (wanted.has(lower) && /\\Redist\\/i.test(full) && ARCH_SEGMENT.test(full)) { // `onecore\x64` is a trimmed variant for Windows Core headless SKUs; the // desktop app wants the ordinary one. if (!/\\onecore\\/i.test(full)) found.get(lower).push(full); @@ -136,7 +154,7 @@ const missing = DLLS.filter((name) => copies.get(name).length === 0); if (missing.length > 0) { throw new Error( `Could not find a redistributable ${missing.join(", ")} under any Visual Studio installation.\n\n` + - "They live in VC\\Redist\\MSVC\\\\x64\\ — vcomp140.dll under\n" + + `They live in VC\\Redist\\MSVC\\\\${TARGET_ARCH}\\ — vcomp140.dll under\n` + "Microsoft.VC.OpenMP, the rest under Microsoft.VC.CRT.\n" + "Install the Visual Studio C++ workload, which is required to build the native\n" + "helpers anyway. Without these files the shipped whisper/ggml libraries and the\n" + From 3e13f942853f42fe1b5576d11fef2c8c0b313e2b Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:04:52 +0200 Subject: [PATCH 12/24] =?UTF-8?q?build(deps):=20electron-builder=2026.16.1?= =?UTF-8?q?=20=E2=80=94=20fixes=20the=20arm64=20NSIS=20installer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 26.15.3 produced an arm64 installer that exits 0, writes the uninstall entry and the Start menu shortcut, and never extracts the main executable or any DLL. Measured on a Snapdragon X Elite: 24 of 187 files missing, every one of them an ARM64 PE. `compositor_view.node` survived because 7-Zip does not treat `.node` as an executable extension, and the x64 helpers survived because their filter is one the extractor knows — which is what pinned the cause down. Without an explicit `-mf=`, 7-Zip picks the branch filter matching the file's CPU; since 23.00 that is the ARM64 filter for ARM64 PEs. The Nsis7z decoder bundled in the installer predates it, skips those entries silently, and reports success. The payload itself was always intact — a current 7-Zip extracts all 24 files from the same installer. Upstream pinned `-mf=BCJ` for NSIS payloads in app-builder-lib 26.15.6. Verified here: the archive method changes from `ARM64 LZMA2:20 LZMA:20 BCJ2` to `LZMA2:20 BCJ`, and the installer now lands all 187 files. The lockfile also regains @electron/windows-sign and its six transitive deps. They were dropped by an upstream lockfile regenerated on a non-Windows host, which made `npm ci` fail on windows-latest with EUSAGE — that would have broken both Windows CI jobs, not just arm64. --- package-lock.json | 202 ++++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 105 insertions(+), 99 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6bd96f2e..7ae5b1b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "@vitejs/plugin-react": "^5.2.0", "autoprefixer": "^10.5.0", "electron": "41.2.1", - "electron-builder": "^26.15.3", + "electron-builder": "^26.16.1", "esbuild": "^0.28.1", "fast-check": "^4.7.0", "husky": "^9.1.7", @@ -296,6 +296,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -504,6 +505,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -826,6 +828,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -874,6 +877,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -904,9 +908,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1178,9 +1182,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -1248,7 +1252,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1270,7 +1273,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1287,7 +1289,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1302,7 +1303,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1800,6 +1800,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -1915,6 +1916,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.1.tgz", "integrity": "sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", @@ -2153,19 +2155,6 @@ "zod-to-json-schema": "^3.25.0" } }, - "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2217,6 +2206,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -2340,15 +2330,18 @@ } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", "dev": true, "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/json-schema": { @@ -3957,6 +3950,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.3.tgz", "integrity": "sha512-TJj5929M96C1KlH796wS8MywfHDh49RhmakOyzyMMc9pFmRj9UXi1gj0TCXgsZtjEOG7B+m/DRvNOvnuvR9kmg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4179,6 +4173,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.3.tgz", "integrity": "sha512-52rcaSzYjtVGUrNkdB8k1Bw/rId2lBirrWMvmnBLsieoeFd8zo8ow5zkwV057BYSgWXfnMAV7GOpDyq2IoSD9A==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4297,6 +4292,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.3.tgz", "integrity": "sha512-IA2QKUVJgzUPEhhkUfr6P5u5GFVfhiApiEaaHXBz07saL2c4HNoVs2RC18QlZDCtLNKrPR1mUScr72u7uYs+YQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4311,6 +4307,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.3.tgz", "integrity": "sha512-ppiG57RxM3HSHHgcHT0hP6Ib4P56Sd5itxdV4w0hIGHKPGyLLKiAkYp+htIHa9T7IvjMdaqxIlsfyV3ZKsS1sw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.4.1", "prosemirror-commands": "^1.7.1", @@ -4399,8 +4396,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4571,6 +4567,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4581,6 +4578,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4945,9 +4943,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, "license": "MIT", "engines": { @@ -5078,9 +5076,9 @@ } }, "node_modules/app-builder-lib": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", - "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.16.1.tgz", + "integrity": "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg==", "dev": true, "license": "MIT", "dependencies": { @@ -5092,13 +5090,13 @@ "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", - "@noble/hashes": "^2.2.0", + "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", @@ -5106,7 +5104,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.15.3", + "electron-publish": "26.16.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -5130,8 +5128,8 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.15.3", - "electron-builder-squirrel-windows": "26.15.3" + "dmg-builder": "26.16.1", + "electron-builder-squirrel-windows": "26.16.1" } }, "node_modules/app-builder-lib/node_modules/@electron/get": { @@ -5181,6 +5179,19 @@ "semver": "bin/semver.js" } }, + "node_modules/app-builder-lib/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/app-builder-lib/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -5474,9 +5485,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5518,6 +5529,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5550,9 +5562,9 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", - "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.16.0.tgz", + "integrity": "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==", "dev": true, "license": "MIT", "dependencies": { @@ -6002,8 +6014,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -6256,9 +6267,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -6286,14 +6297,14 @@ "license": "MIT" }, "node_modules/dmg-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", - "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.16.1.tgz", + "integrity": "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } @@ -6341,8 +6352,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dotenv": { "version": "16.6.1", @@ -6434,18 +6444,18 @@ } }, "node_modules/electron-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", - "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.16.1.tgz", + "integrity": "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.15.3", + "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -6460,15 +6470,15 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", - "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.16.1.tgz", + "integrity": "sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, @@ -6511,15 +6521,15 @@ } }, "node_modules/electron-publish": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", - "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.16.0.tgz", + "integrity": "sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", @@ -6643,7 +6653,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6664,7 +6673,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -7008,9 +7016,9 @@ "license": "Unlicense" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -7078,9 +7086,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -7354,9 +7362,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -8374,7 +8382,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -8595,7 +8602,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -9253,6 +9259,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -9397,7 +9404,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -9415,7 +9421,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -9426,7 +9431,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9442,7 +9446,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9708,9 +9711,9 @@ } }, "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", "dev": true, "license": "MIT", "engines": { @@ -9765,6 +9768,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9777,6 +9781,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9813,8 +9818,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -10145,7 +10149,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -10655,6 +10658,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -10738,7 +10742,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -11244,6 +11247,7 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -11333,7 +11337,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", @@ -11752,6 +11757,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index a1f5a240d..f01df7395 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,7 @@ "@vitejs/plugin-react": "^5.2.0", "autoprefixer": "^10.5.0", "electron": "41.2.1", - "electron-builder": "^26.15.3", + "electron-builder": "^26.16.1", "esbuild": "^0.28.1", "fast-check": "^4.7.0", "husky": "^9.1.7", From 3bea9e5ab1258802263bebb0b300400414f916aa Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:43:15 +0200 Subject: [PATCH 13/24] fix(stt): fall back to the x64 whisper helper on Windows on ARM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An arm64 package had no speech-to-text at all: `candidateBinaryPaths` resolves `win32-${process.arch}`, and nothing ever puts a helper in `win32-arm64`. `.github/workflows/build-whisper-stt.yml` builds `win32-x64` only, and `scripts/build-whisper-stt.sh` has no acceleration case for `win32-arm64` — it would fall through even if asked. Every transcription failed with "binary not found". The x64 helper works there. Windows emulates it per-process and it is spawned as its own process rather than loaded into ours, so emulation stays contained. Verified on a Snapdragon X Elite: it boots, loads the model, and reports `backend=whispercpp-vulkan` — ggml finds the Adreno X1-85 through Vulkan and runs inference on the GPU, emulated process notwithstanding. The fallback is the whole `win32-x64` DIRECTORY, not just the .exe. The helper links whisper/ggml and the VC runtime out of its own directory and `win32-arm64/` holds the ARM64 builds of those, so an emulated x64 process sent there dies in the loader. `win32-arm64` stays first in the list: a native helper wins the moment one exists. The CI staging tag goes back to a literal `win32-x64` for the same reason. The arch-parameterised version added alongside the arm64 matrix would have failed that job outright, since the artifact it names is never produced. This is a stopgap with an expiry date, not a substitute for a native build. Transcription runs emulated and slower than it needs to. --- .github/workflows/build.yml | 9 ++++++- electron/stt/gpuDetector.test.ts | 37 ++++++++++++++++++++++++++++ electron/stt/gpuDetector.ts | 42 ++++++++++++++++++++++++-------- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b81d9d62c..b668ebd56 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,7 +100,14 @@ jobs: shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: bash scripts/stage-whisper-stt.sh win32-${{ matrix.arch }} + # win32-x64 for BOTH arches, deliberately: build-whisper-stt.yml produces no + # arm64 helper (scripts/build-whisper-stt.sh has no win32-arm64 acceleration + # case at all), so an arch-parameterised tag would simply fail the arm64 job. + # `candidateBinaryPaths` falls back to the win32-x64 directory on Windows on + # ARM, where the x64 helper runs under emulation — verified on a Snapdragon X + # Elite, Vulkan on the Adreno included. Point this at win32-arm64 the moment a + # native helper exists; the resolver already prefers it. + run: bash scripts/stage-whisper-stt.sh win32-x64 - name: Build Windows app (x64) if: matrix.arch == 'x64' diff --git a/electron/stt/gpuDetector.test.ts b/electron/stt/gpuDetector.test.ts index 5790032d4..e8a2cd4d0 100644 --- a/electron/stt/gpuDetector.test.ts +++ b/electron/stt/gpuDetector.test.ts @@ -51,6 +51,43 @@ describe("gpuDetector", () => { } }); + /** + * Windows on ARM ships no whisper build: `build-whisper-stt.yml` produces + * `win32-x64` only, and `scripts/build-whisper-stt.sh` has no acceleration case + * for `win32-arm64` at all. An arm64 package therefore resolves a tag that will + * never contain the helper, and speech-to-text fails with "binary not found" — + * observed on a Snapdragon X Elite. + * + * The x64 helper runs fine there: Windows emulates it per-process, and it is + * spawned as its own process rather than loaded into ours. Verified by running + * the extracted x64 `whisper-stt-server.exe` on that machine — it starts, loads + * its DLLs and parses its arguments. Falling back to it beats shipping an arm64 + * build with no transcription until a native helper exists. + * + * The fallback tag matters for more than the .exe: the x64 helper needs the x64 + * CRT and ggml DLLs beside it, and `win32-arm64/` holds the ARM64 ones. Pointing + * at `win32-x64/` keeps the helper next to the libraries it actually links. + */ + it("candidateBinaryPaths falls back to the x64 helper on Windows on ARM", () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + Object.defineProperty(process, "arch", { value: "arm64", configurable: true }); + try { + const here = "C:/fake/repo"; + const resolved = candidateBinaryPaths(here).map((p) => p.replace(/\\/g, "/")); + const arm = `${here}/electron/native/bin/win32-arm64/whisper-stt-server.exe`; + const x64 = `${here}/electron/native/bin/win32-x64/whisper-stt-server.exe`; + expect(resolved).toContain(arm); + expect(resolved).toContain(x64); + // Native first: the moment an arm64 helper exists it must win. + expect(resolved.indexOf(arm)).toBeLessThan(resolved.indexOf(x64)); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); + } + }); + it("candidateBinaryPaths prepends env override when set", () => { process.env.OPENSCREEN_WHISPER_SERVER_EXE = "/custom/path/whisper-stt-server"; const here = "/fake/repo"; diff --git a/electron/stt/gpuDetector.ts b/electron/stt/gpuDetector.ts index 2ef415f58..f7aba344f 100644 --- a/electron/stt/gpuDetector.ts +++ b/electron/stt/gpuDetector.ts @@ -77,27 +77,49 @@ export function binaryNameForBackend(_backend: SttBackend): string { * checkout that pre-dates the suffix fix still resolves to a valid file. */ export function candidateBinaryPaths(here: string = process.cwd()): string[] { - const tag = `${process.platform}-${process.arch}`; const name = binaryNameForBackend("whispercpp-cpu"); const envPath = process.env.OPENSCREEN_WHISPER_SERVER_EXE?.trim(); const appPath = readAppPath(); const resourcePath = readResourcesPath(); const names = name.endsWith(".exe") ? [name, name.replace(/\.exe$/, "")] : [name]; - const appPathSegments = appPath - ? names.map((n) => path.join(appPath, "electron", "native", "bin", tag, n)) - : []; - const resourceSegments = resourcePath - ? names.map((n) => path.join(resourcePath, "electron", "native", "bin", tag, n)) - : []; + const tags = binaryTags(); + const under = (base: string) => + tags.flatMap((tag) => names.map((n) => path.join(base, "electron", "native", "bin", tag, n))); return [ ...(envPath ? [envPath] : []), - ...appPathSegments, - ...resourceSegments, - ...names.map((n) => path.join(here, "electron", "native", "bin", tag, n)), + ...(appPath ? under(appPath) : []), + ...(resourcePath ? under(resourcePath) : []), + ...under(here), ...names.map((n) => path.join(here, "electron", "native", "bin", n)), ].filter((p): p is string => Boolean(p)); } +/** + * Arch-tagged directories to search, native first. + * + * Windows on ARM gets a second tag. There is no arm64 whisper build to find: + * `.github/workflows/build-whisper-stt.yml` produces `win32-x64` only, and + * `scripts/build-whisper-stt.sh` has no acceleration case for `win32-arm64` — so an + * arm64 package resolves a directory that will never hold the helper, and every + * transcription fails with "binary not found". + * + * The x64 helper works there. Windows emulates x64 per-process and this helper is + * spawned as its own process rather than loaded into ours, so emulation is contained + * to it. Verified on a Snapdragon X Elite: the x64 `whisper-stt-server.exe` starts, + * resolves its DLLs and parses its arguments. + * + * Falling back to the whole `win32-x64` DIRECTORY rather than just the .exe is the + * point: the helper links whisper/ggml and the VC runtime from its own directory, and + * `win32-arm64/` holds the ARM64 builds of those. Sending an emulated x64 process + * there would fail in the loader. + * + * `win32-arm64` stays first so a native helper wins the moment one exists. + */ +function binaryTags(): string[] { + const tag = `${process.platform}-${process.arch}`; + return process.platform === "win32" && process.arch === "arm64" ? [tag, "win32-x64"] : [tag]; +} + /** Resolve `app.getAppPath()` lazily so this module stays importable from * contexts where Electron's `app` is not yet ready (e.g. unit tests). */ function readAppPath(): string | null { From 0b70ed66c470a4ca8ae4cd80e93062dcf7871a79 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:43:27 +0200 Subject: [PATCH 14/24] fix(test): run the WGC smoke tests against the host's own helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper path was pinned to `win32-x64`, so on an ARM64 host every smoke test launched the x64 binary under emulation — a binary that machine never ships. Its failures read as arm64 capture bugs: `test:wgc-mic:win` and the plain capture test both died on "Helper never acknowledged the stop command", which is exactly what a real arm64 capture regression would look like. With the arch resolved the same way the build scripts resolve it, all four pass against the arm64 helper: microphone (AAC written, device selected), webcam, system audio and the two mixed. The two system-audio tests need sound actually playing on the machine — with a silent host the loopback capture has nothing to record and the output collapses to ~1s, which is a property of the test, not of the helper. --- scripts/test-windows-wgc-helper.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index 785e48c1e..8b56acef6 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -4,12 +4,23 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveTargetArch, winBinDirName } from "./windows-helper-arch.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); +// The arch-tagged directory, not a fixed `win32-x64`. On an ARM64 host the hard-coded +// path silently ran the x64 helper under emulation, so every smoke test reported on a +// binary nobody ships to that machine — and its failures looked like arm64 capture bugs. const HELPER_PATH = process.env.OPENSCREEN_WGC_CAPTURE_EXE ?? - path.join(ROOT, "electron", "native", "bin", "win32-x64", "wgc-capture.exe"); + path.join( + ROOT, + "electron", + "native", + "bin", + winBinDirName(resolveTargetArch({ envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, hostArch: process.arch })), + "wgc-capture.exe", + ); const DURATION_MS = Number(process.env.OPENSCREEN_WGC_TEST_DURATION_MS ?? 5000); const WITH_SYSTEM_AUDIO = From 24e560749dafb7f1a8c16a76576e71e0ba08f807 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:10:56 +0200 Subject: [PATCH 15/24] feat(build): build the whisper helper natively for Windows on ARM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18x faster than the emulated x64 helper, and the only one that produces a usable transcript at all. Same 60 s of German speech, same ggml-small-q8_0 model, both binaries driven directly over /inference: native arm64 (ggml threads) load 0.9s inference 10.1s 9059 chars, language "de" emulated x64 (Vulkan) load 1.3s inference 183.3s failed, 0 chars The emulated run is not merely slow. It returns nothing after three minutes, and in-app it reported "auto-detected language: th" on German speech — the same shape of failure as the D3D11 preview drawing black on this adapter. Adreno under emulation produces results that look like application bugs. Five things had to change, each a separate x64 assumption: `os_arch_tag` derived the architecture from `uname -m`. Git Bash is an x86_64 build, so on Windows on ARM it reports x86_64 while running emulated, and the script would have built and staged an x64 helper on an ARM64 host without a word. Node is native and knows better. `backend_flag_for_host` had no win32-arm64 case and exited with "Unknown os-arch". ggml refuses MSVC for ARM outright ("MSVC is not supported for ARM, use clang") — its NEON intrinsics are absent from MSVC's ARM64 code generator. The build now uses clang-cl through Ninja rather than the `-T ClangCL` VS toolset, so it reuses the standalone LLVM the compositor addon already requires for bindgen instead of pulling a second ~1 GB copy into Visual Studio. Ninja gets no MSVC environment of its own, so configure and build run inside vcvarsall via a throwaway .cmd — nested quoting through bash, MSYS and cmd does not survive, and the same file needs Windows-native paths because Git Bash stops rewriting them once cmd is the caller. `-DGGML_OPENMP=OFF` is a licence constraint, not a tuning choice. clang-cl links ggml against libomp140.aarch64.dll, and the only copy on a Visual Studio install sits under `VC/Redist/.../debug_nonredist/` — a directory whose name is the licence position. ggml's own thread pool needs no runtime we cannot ship, and at 6x realtime it is plainly fast enough. NOTE for CI: this makes LLVM a build prerequisite for the arm64 branch. build-whisper-stt.yml still has no arm64 matrix entry, so packages keep falling back to the staged x64 helper until it gains one. --- scripts/build-whisper-stt.sh | 108 ++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 3 deletions(-) diff --git a/scripts/build-whisper-stt.sh b/scripts/build-whisper-stt.sh index 74f5be046..a9d17576f 100644 --- a/scripts/build-whisper-stt.sh +++ b/scripts/build-whisper-stt.sh @@ -61,8 +61,14 @@ os_arch_tag() { Linux:x86_64) os_arch="linux-x64" ;; Linux:aarch64) os_arch="linux-arm64" ;; MINGW*|CYGWIN*|MSYS*) + # `uname -m` cannot be trusted here. Git Bash is an x86_64 build, so on Windows + # on ARM it runs emulated and reports x86_64 while the machine is arm64 — the + # script would then build and stage an x64 helper on an ARM64 host without ever + # saying so. Node is a native binary and reports the real architecture, and this + # is a Node project, so it is always at hand. `uname -m` stays as the fallback. local arch - arch="$(uname -m)" + arch="$(node -p 'process.arch' 2>/dev/null || true)" + [[ -n "${arch}" ]] || arch="$(uname -m)" os_arch="win32-${arch/x86_64/x64}" ;; *) echo "Unsupported host: $(uname -s):$(uname -m)" >&2; exit 1 ;; @@ -84,6 +90,19 @@ backend_flag_for_host() { darwin-arm64) echo "-DOSC_ENABLE_METAL=ON" ;; darwin-x64) echo "" ;; win32-x64|linux-x64|linux-arm64) echo "-DOSC_ENABLE_VULKAN=ON" ;; + # Windows on ARM: CPU (ARM NEON), not Vulkan. ggml's Vulkan backend needs glslc + # from the Vulkan SDK to compile its shaders, and requiring a ~1 GB SDK install + # would make the arm64 helper unbuildable on a stock machine. Adreno itself is a + # capable Vulkan target — the emulated x64 helper already runs inference on it — + # so switching this to Vulkan is a worthwhile follow-up once the SDK is a declared + # build prerequisite. Measure before assuming it wins: native NEON on 12 Oryon + # cores against emulated code driving the GPU is not an obvious comparison. + # `-DGGML_OPENMP=OFF` is not optional here. clang-cl links ggml against + # `libomp140.aarch64.dll`, and the only copy of that file on a Visual Studio + # install lives under `VC/Redist/.../debug_nonredist/` — a directory whose name + # states the licence position: it may not be redistributed. Without OpenMP, + # ggml uses its own thread pool and the helper needs no runtime we cannot ship. + win32-arm64) echo "-DGGML_OPENMP=OFF" ;; *) echo "Unknown os-arch: ${OS_ARCH}" >&2; exit 1 ;; esac } @@ -141,6 +160,62 @@ relocate_macos_rpaths() { echo "[whisper-stt] rewrote macOS rpaths to @loader_path in ${out_dir}" } +# Runs a command inside the MSVC environment for the host/target pair. +# +# Only Windows on ARM needs this. Everywhere else the Visual Studio generator sets the +# environment up itself, and Unix hosts have no such notion — so this is a no-op unless +# a vcvarsall is both needed and found, and a plain `"$@"` otherwise. +run_in_vs_env() { + if [[ "${OS_ARCH}" != "win32-arm64" ]]; then + "$@" + return + fi + local vcvars + vcvars="$(ls "/c/Program Files (x86)/Microsoft Visual Studio/2022"/*/VC/Auxiliary/Build/vcvarsall.bat 2>/dev/null | head -1)" + if [[ -z "${vcvars}" ]]; then + echo "[whisper-stt] WARN: vcvarsall.bat not found; building without an MSVC environment" >&2 + "$@" + return + fi + # Host arch decides the vcvars argument: `arm64` when building natively on ARM64, + # `amd64_arm64` when cross-compiling from an x64 host such as a CI runner. + local host vcarg + host="$(node -p 'process.arch' 2>/dev/null || echo x64)" + if [[ "${host}" == "arm64" ]]; then vcarg="arm64"; else vcarg="amd64_arm64"; fi + # A throwaway .cmd rather than nested quoting. `cmd //c "call \"...\" && ..."` + # has to survive bash, MSYS path mangling and cmd in turn, and it did not: the + # escaped quotes reached cmd verbatim and it reported the batch file "not found". + # scripts/build-windows-compositor-addon.mjs solves it the same way. + # Where the standalone LLVM lives. `command -v` first so a PATH install wins; + # the default installer location is the fallback. + local llvm_bin="" + if command -v clang-cl >/dev/null 2>&1; then + llvm_bin="$(dirname "$(command -v clang-cl)")" + elif [[ -x "/c/Program Files/LLVM/bin/clang-cl.exe" ]]; then + llvm_bin="/c/Program Files/LLVM/bin" + fi + local script + script="$(mktemp -t whisper-vsenv-XXXXXX.cmd)" + { + echo "@echo off" + printf 'call "%s" %s || exit /b 1 +' "$(cygpath -w "${vcvars}")" "${vcarg}" + # vcvarsall does not add a standalone LLVM to PATH, and CMake then fails to find + # clang-cl at all. Prepend it so the compiler ggml demands for ARM is visible + # without turning its absolute path (which contains spaces) into another quoting + # problem inside the batch file. + if [[ -n "${llvm_bin}" ]]; then + printf 'set "PATH=%s;%%PATH%%" +' "$(cygpath -w "${llvm_bin}")" + fi + echo "$*" + } > "${script}" + local status=0 + cmd //c "$(cygpath -w "${script}")" || status=$? + rm -f "${script}" + return ${status} +} + build_variant() { local variant_name="$1" shift @@ -158,14 +233,41 @@ build_variant() { # variable") even though bash 4+ treats it as zero words. The # `${arr[@]+"${arr[@]}"}` idiom expands to nothing when the array is empty # and to the normal quoted expansion otherwise, on both bash versions. - cmake -S "${SRC_DIR}" -B "${build_dir}" \ + # Windows on ARM must be built with clang, not MSVC: ggmls CPU backend refuses + # outright ("MSVC is not supported for ARM, use clang" in + # ggml/src/ggml-cpu/CMakeLists.txt), because the NEON intrinsics it relies on are + # absent from MSVCs ARM64 code generator. The Visual Studio generator can host the + # LLVM toolset, so the target platform stays ARM64 and only the compiler changes. + local toolchain_flags=() + if [[ "${OS_ARCH}" == "win32-arm64" ]]; then + # clang-cl from a standalone LLVM, driven by Ninja rather than the Visual Studio + # generator. `-T ClangCL` would need the "C++ Clang tools for Windows" VS + # component, which is a second ~1 GB LLVM next to the one the compositor addon + # already requires for bindgen; Ninja lets both builds share one toolchain. + # Ninja gets no MSVC environment of its own, so configure and build run inside + # vcvarsall (see run_in_vs_env below) for the headers, libs and the linker. + toolchain_flags+=(-G Ninja -DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl) + fi + + # Paths must be Windows-native for the arm64 path: cmake runs from inside a .cmd, + # where Git Bash no longer rewrites `/c/...` for the native program it invokes, and + # CMake reports the source directory as missing. Everywhere else the MSYS form is + # what the surrounding tooling expects, so only this branch converts. + local src_arg="${SRC_DIR}" build_arg="${build_dir}" + if [[ "${OS_ARCH}" == "win32-arm64" ]]; then + src_arg="$(cygpath -w "${SRC_DIR}")" + build_arg="$(cygpath -w "${build_dir}")" + fi + + run_in_vs_env cmake -S "${src_arg}" -B "${build_arg}" \ -DCMAKE_BUILD_TYPE=Release \ + ${toolchain_flags[@]+"${toolchain_flags[@]}"} \ ${extra_cmake_flags[@]+"${extra_cmake_flags[@]}"} echo "[whisper-stt] building ${variant_name}" # ponytail: macOS has no `nproc` (it is GNU coreutils), so this silently built # with -j4 on an 8-core Mac. `sysctl -n hw.ncpu` is the BSD equivalent. - cmake --build "${build_dir}" --config Release \ + run_in_vs_env cmake --build "${build_arg}" --config Release \ -j "$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" local bin_name="whisper-stt-server" From f32397b9bd4663aede4c699acb8090a759c6fc89 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:29:03 +0200 Subject: [PATCH 16/24] fix(build): refuse --arch without a value instead of building for the host --- scripts/build-windows-compositor-addon.mjs | 15 +++++++-------- scripts/build-windows-wgc-helper.mjs | 13 +------------ scripts/stage-vcomp-runtime.mjs | 9 ++------- scripts/windows-helper-arch.mjs | 15 +++++++++++++++ scripts/windows-helper-arch.test.mjs | 19 +++++++++++++++++++ 5 files changed, 44 insertions(+), 27 deletions(-) diff --git a/scripts/build-windows-compositor-addon.mjs b/scripts/build-windows-compositor-addon.mjs index 5a6da5df4..37a3f55ae 100644 --- a/scripts/build-windows-compositor-addon.mjs +++ b/scripts/build-windows-compositor-addon.mjs @@ -19,7 +19,12 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { findVcVarsAll, run as spawnStep } from "./msvcEnv.mjs"; -import { resolveTargetArch, resolveVcvarsArch, winBinDirName } from "./windows-helper-arch.mjs"; +import { + parseArchFlag, + resolveTargetArch, + resolveVcvarsArch, + winBinDirName, +} from "./windows-helper-arch.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); @@ -31,14 +36,8 @@ const BUILD_OUT_DIR = path.join(ROOT, "electron", "native", "compositor-view", " // app ships. Without this the addon was always built x64 and copied into // win32-x64/, so an arm64 package either missed it or shipped one the ARM64 // runtime cannot load. -const cliArch = (() => { - const eq = process.argv.find((a) => a.startsWith("--arch=")); - if (eq) return eq.slice("--arch=".length); - const idx = process.argv.indexOf("--arch"); - return idx !== -1 ? process.argv[idx + 1] : undefined; -})(); const TARGET_ARCH = resolveTargetArch({ - cliArch, + cliArch: parseArchFlag(process.argv.slice(2)), envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, hostArch: process.arch, }); diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 4aae7a99f..6abf10cf4 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -5,23 +5,12 @@ import { fileURLToPath } from "node:url"; import { findVcVarsAll, run as spawnStep } from "./msvcEnv.mjs"; import { + parseArchFlag, resolveTargetArch, resolveVcvarsArch, winBinDirName, } from "./windows-helper-arch.mjs"; -function parseArchFlag(argv) { - const eq = argv.find((a) => a.startsWith("--arch=")); - if (eq) { - return eq.slice("--arch=".length); - } - const idx = argv.indexOf("--arch"); - if (idx !== -1) { - return argv[idx + 1]; - } - return undefined; -} - const TARGET_ARCH = resolveTargetArch({ cliArch: parseArchFlag(process.argv.slice(2)), envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, diff --git a/scripts/stage-vcomp-runtime.mjs b/scripts/stage-vcomp-runtime.mjs index 274d17024..72c8b2fd9 100644 --- a/scripts/stage-vcomp-runtime.mjs +++ b/scripts/stage-vcomp-runtime.mjs @@ -35,7 +35,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { findVcVarsAll } from "./msvcEnv.mjs"; -import { resolveTargetArch, winBinDirName } from "./windows-helper-arch.mjs"; +import { parseArchFlag, resolveTargetArch, winBinDirName } from "./windows-helper-arch.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, ".."); @@ -46,12 +46,7 @@ const ROOT = path.join(__dirname, ".."); // would satisfy before-pack's name check and still fail in the loader on the // target machine. const TARGET_ARCH = resolveTargetArch({ - cliArch: (() => { - const eq = process.argv.find((a) => a.startsWith("--arch=")); - if (eq) return eq.slice("--arch=".length); - const idx = process.argv.indexOf("--arch"); - return idx !== -1 ? process.argv[idx + 1] : undefined; - })(), + cliArch: parseArchFlag(process.argv.slice(2)), envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, hostArch: process.arch, }); diff --git a/scripts/windows-helper-arch.mjs b/scripts/windows-helper-arch.mjs index e9f0503a6..e0998acdd 100644 --- a/scripts/windows-helper-arch.mjs +++ b/scripts/windows-helper-arch.mjs @@ -19,6 +19,21 @@ export function normalizeArch(value) { return ALIASES.get(String(value).toLowerCase()); } +// `--arch` with nothing after it must not read as "flag absent": that would fall +// through to the host and quietly produce a binary for the wrong architecture. +export function parseArchFlag(argv) { + const eq = argv.find((a) => a.startsWith("--arch=")); + const idx = argv.indexOf("--arch"); + if (eq === undefined && idx === -1) { + return undefined; + } + const value = eq !== undefined ? eq.slice("--arch=".length) : argv[idx + 1]; + if (!value || value.startsWith("-")) { + throw new Error(`--arch requires a value (${SUPPORTED_ARCHES.join(" or ")}).`); + } + return value; +} + export function resolveTargetArch({ cliArch, envArch, hostArch } = {}) { for (const [label, raw] of [ ["--arch", cliArch], diff --git a/scripts/windows-helper-arch.test.mjs b/scripts/windows-helper-arch.test.mjs index 078620347..510cfcb20 100644 --- a/scripts/windows-helper-arch.test.mjs +++ b/scripts/windows-helper-arch.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { SUPPORTED_ARCHES, normalizeArch, + parseArchFlag, resolveTargetArch, resolveVcvarsArch, winBinDirName, @@ -24,6 +25,24 @@ describe("normalizeArch", () => { }); }); +describe("parseArchFlag", () => { + it("reads both spellings", () => { + expect(parseArchFlag(["--arch", "arm64"])).toBe("arm64"); + expect(parseArchFlag(["--arch=arm64"])).toBe("arm64"); + }); + + it("returns undefined when the flag is absent, so the env and host still apply", () => { + expect(parseArchFlag([])).toBeUndefined(); + expect(parseArchFlag(["--clean"])).toBeUndefined(); + }); + + it("refuses a flag without a value instead of silently building for the host", () => { + expect(() => parseArchFlag(["--arch"])).toThrow(/requires a value/); + expect(() => parseArchFlag(["--arch", "--clean"])).toThrow(/requires a value/); + expect(() => parseArchFlag(["--arch="])).toThrow(/requires a value/); + }); +}); + describe("resolveTargetArch", () => { it("prefers the CLI arch over env and host", () => { expect(resolveTargetArch({ cliArch: "arm64", envArch: "x64", hostArch: "x64" })).toBe("arm64"); From 0e489ff86bea0afb0cbf23aa70c7d862345a408d Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:37:58 +0200 Subject: [PATCH 17/24] fix(build): keep argument boundaries in the whisper VS environment wrapper --- scripts/build-whisper-stt.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/build-whisper-stt.sh b/scripts/build-whisper-stt.sh index a9d17576f..23b33ce3e 100644 --- a/scripts/build-whisper-stt.sh +++ b/scripts/build-whisper-stt.sh @@ -208,7 +208,11 @@ run_in_vs_env() { printf 'set "PATH=%s;%%PATH%%" ' "$(cygpath -w "${llvm_bin}")" fi - echo "$*" + # One quoted word per argument: "$*" rejoins them with bare spaces, and a + # checkout under a path with a space in it reaches CMake split in two. + local arg line="" + for arg in "$@"; do line+="\"${arg}\" "; done + echo "${line}" } > "${script}" local status=0 cmd //c "$(cygpath -w "${script}")" || status=$? From 5066226621b9fbb94b7d8d0f4211ac9dedcc12d8 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:41:19 +0200 Subject: [PATCH 18/24] ci: build the Windows arm64 installer natively and publish one update feed for both arches The arm64 job ran on an x64 runner, where ffmpeg, onnxruntime and the compositor all provisioned for the host; it now runs on windows-11-arm. Each job describes its installer in a sidecar, and publish-release folds both into one latest.yml instead of letting merge-multiple keep whichever arrived last. The Windows downloads go by name, so the Store .appx no longer reaches the GitHub release through the openscreen-windows-* glob. --- .github/workflows/build.yml | 91 +++++++++++++++++++++++--------- electron/native/README.md | 2 + scripts/mac-update-feed.mjs | 5 ++ scripts/mac-update-feed.test.mjs | 14 +++++ 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b668ebd56..34e40ced9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,7 +43,13 @@ concurrency: jobs: build-windows: name: Windows ${{ matrix.arch }} installer - runs-on: windows-latest + # Each arch builds NATIVELY, like the macOS job below. On an x64 runner every step + # that keys off the host — fetch-ffmpeg (which also runs the binary it downloads to + # verify the licence), fetch-onnxruntime, the compositor's cargo build — produced x64 + # output for the arm64 package, and before-pack then refused it. On windows-11-arm + # `process.arch` is arm64 and the same steps that build the arm64 installer on a + # Snapdragon desktop run unchanged. + runs-on: ${{ matrix.arch == 'arm64' && 'windows-11-arm' || 'windows-latest' }} strategy: fail-fast: false matrix: @@ -74,16 +80,14 @@ jobs: Write-Host "MSVC ARM64 build tools already present." } - - name: Ensure win32-arm64 sharp binary - # sharp (transitive via @xenova/transformers) only fetches a host-arch - # prebuilt at install time, so on the x64 runner the packaged arm64 app - # would otherwise ship an x64 sharp.node. Best-effort fetch of the - # win32-arm64 prebuilt; non-fatal so it never blocks the build. Verify - # the first arm64 release loads sharp if auto-captions use it. - if: matrix.arch == 'arm64' - continue-on-error: true + # Everything below resolves its target from the host. Should this job ever land on an + # x64 runner again, fail here rather than after a full build that before-pack rejects. + - name: Check the runner architecture shell: bash - run: npm_config_platform=win32 npm_config_arch=arm64 npm rebuild sharp + run: | + HOST="$(node -p process.arch)" + [ "$HOST" = "${{ matrix.arch }}" ] \ + || { echo "::error::the ${{ matrix.arch }} installer must build on a ${{ matrix.arch }} runner, got $HOST"; exit 1; } - name: Cache caption assets uses: actions/cache@v4 @@ -101,8 +105,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # win32-x64 for BOTH arches, deliberately: build-whisper-stt.yml produces no - # arm64 helper (scripts/build-whisper-stt.sh has no win32-arm64 acceleration - # case at all), so an arch-parameterised tag would simply fail the arm64 job. + # arm64 helper yet (scripts/build-whisper-stt.sh can build one natively on ARM, + # but no workflow runs it), so an arch-parameterised tag would fail the arm64 job. # `candidateBinaryPaths` falls back to the win32-x64 directory on Windows on # ARM, where the x64 helper runs under emulation — verified on a Snapdragon X # Elite, Vulkan on the Adreno included. Point this at win32-arm64 the moment a @@ -117,28 +121,43 @@ jobs: if: matrix.arch == 'arm64' run: npm run build:win:arm64 -- --publish never + # electron-builder writes a latest.yml per job that lists only that job's installer, and + # both land on the same path in the release — the second would overwrite the first and + # serve one arch the other's build. Same problem, same fix as macOS: each job describes + # its installer in a JSON sidecar and publish-release folds both into ONE latest.yml. + # latest.yml is still required here: it proves electron-builder's publish config + # produced a feed at all. + - name: Describe the installer for the update feed + shell: bash + run: | + test -f "$(find release -name latest.yml | head -1)" \ + || { echo "::error::latest.yml missing — electron-updater has no feed to read"; exit 1; } + EXE="$(find release -name 'Openscreen.Setup.*-${{ matrix.arch }}.exe' | head -1)" + test -f "$EXE" \ + || { echo "::error::no ${{ matrix.arch }} installer under release/"; exit 1; } + VERSION="$(node -p "require('./package.json').version")" + node scripts/mac-update-feed.mjs describe "$EXE" "$VERSION" \ + "$(dirname "$EXE")/update-info-win-${{ matrix.arch }}.json" + - name: Upload Windows installer uses: actions/upload-artifact@v7 with: name: openscreen-windows-${{ matrix.arch }} - # latest.yml is the update feed electron-updater reads; the .blockmap is what lets it - # download a delta instead of the full ~243 MB installer. Both were already produced - # by every build and thrown away here, because this glob only matched the .exe. + # The sidecar becomes latest.yml in publish-release; the .blockmap is what lets + # electron-updater download a delta instead of the full ~243 MB installer. path: | release/**/Openscreen.Setup.*.exe release/**/Openscreen.Setup.*.exe.blockmap - release/**/latest.yml + release/**/update-info-win-*.json if-no-files-found: error retention-days: 30 # `if-no-files-found: error` evaluates the UNION of the globs above, so a dead pattern # among live ones never fails — that is exactly how the *.zsync glob rotted unnoticed on - # the Linux job. Assert the update feed specifically. - - name: Verify the update feed was produced + # the Linux job. Assert the blockmap specifically. + - name: Verify the blockmap was produced shell: bash run: | - test -f "$(find release -name latest.yml | head -1)" \ - || { echo "::error::latest.yml missing — electron-updater has no feed to read"; exit 1; } test -f "$(find release -name 'Openscreen.Setup.*.exe.blockmap' | head -1)" \ || { echo "::error::blockmap missing — differential updates would silently degrade"; exit 1; } @@ -925,12 +944,20 @@ jobs: echo "prerelease_flag=$PRERELEASE_FLAG" >> "$GITHUB_OUTPUT" echo "notes_start_tag=$NOTES_START_TAG" >> "$GITHUB_OUTPUT" - - name: Download Windows installer + # By name, not `pattern: openscreen-windows-*`: that pattern also matches + # openscreen-windows-store, and publish-release does not wait for the Store job, so + # whether its .appx reached the GitHub release depended on which job finished first. + - name: Download Windows x64 installer uses: actions/download-artifact@v8 with: - pattern: openscreen-windows-* - merge-multiple: true - path: artifacts/windows + name: openscreen-windows-x64 + path: artifacts/windows-x64 + + - name: Download Windows arm64 installer + uses: actions/download-artifact@v8 + with: + name: openscreen-windows-arm64 + path: artifacts/windows-arm64 - name: Download macOS arm64 DMG uses: actions/download-artifact@v8 @@ -970,6 +997,22 @@ jobs: grep -q 'arm64' artifacts/latest-mac.yml \ || { echo "::error::latest-mac.yml has no arm64 entry — Apple Silicon would update onto the Intel build"; exit 1; } + # Same fold for Windows (see "Describe the installer for the update feed"). The feed's + # arch-blind `path:` points at x64, which Windows on ARM still runs under emulation. + - name: Build the Windows update feed + run: | + X64_INFO="$(find artifacts/windows-x64 -name update-info-win-x64.json)" + ARM64_INFO="$(find artifacts/windows-arm64 -name update-info-win-arm64.json)" + node scripts/mac-update-feed.mjs merge "$X64_INFO" "$ARM64_INFO" artifacts/latest.yml + rm -f "$X64_INFO" "$ARM64_INFO" + ENTRIES="$(grep -c '^ - url:' artifacts/latest.yml)" + [ "$ENTRIES" -eq 2 ] \ + || { echo "::error::latest.yml lists ${ENTRIES} installers, expected 2 (one per arch)"; exit 1; } + grep -q '^ - url: .*-x64\.exe$' artifacts/latest.yml \ + || { echo "::error::latest.yml has no x64 installer"; exit 1; } + grep -q '^ - url: .*-arm64\.exe$' artifacts/latest.yml \ + || { echo "::error::latest.yml has no arm64 installer"; exit 1; } + - name: Publish release assets env: GH_TOKEN: ${{ secrets.OPENSCREEN_RELEASE_TOKEN }} diff --git a/electron/native/README.md b/electron/native/README.md index 0d5ea7dbe..c1fb18358 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -54,6 +54,8 @@ node scripts/build-windows-wgc-helper.mjs --arch arm64 The target architecture is resolved from `--arch` (or the `OPENSCREEN_WIN_HELPER_ARCH` env var), falling back to the host arch. Cross-compiling requires the matching MSVC component — "VS C++ ARM64/ARM64EC build tools" for `arm64`. The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-/wgc-capture.exe` (e.g. `win32-arm64`). +Cross-compiling covers this helper, not the installer. `npm run build:win:arm64` has to run on an ARM64 host: `fetch-ffmpeg.mjs` and `fetch-onnxruntime.mjs` provision for the host, and the ffmpeg licence check runs the downloaded binary. CI therefore builds the arm64 installer on a `windows-11-arm` runner. + The helper contract is process-based: the app starts the process with one JSON argument and sends commands on stdin. `stop\n` finalizes the recording. During migration the helper prints both newline-delimited JSON events and the legacy text messages `Recording started` / `Recording stopped. Output path: `. Current V2 JSON shape: diff --git a/scripts/mac-update-feed.mjs b/scripts/mac-update-feed.mjs index 042160b14..40de3477c 100644 --- a/scripts/mac-update-feed.mjs +++ b/scripts/mac-update-feed.mjs @@ -8,6 +8,11 @@ // architecture served the wrong build — electron-builder#5592, closed as not-planned. So each // job emits a JSON sidecar and `merge` folds both into ONE feed with two `files:` entries. // +// Windows has the same shape — x64 on windows-latest, arm64 on windows-11-arm — and reuses +// both subcommands for its NSIS `latest.yml`. Nothing below is macOS-specific: NsisUpdater's +// `findFile` also picks the entry whose name contains `process.arch`, and the x64 fallback +// is as right for Windows on ARM, which emulates x64, as it is for Rosetta. +// // describe — per-arch, on the macOS runner // merge — once, on the publish runner // diff --git a/scripts/mac-update-feed.test.mjs b/scripts/mac-update-feed.test.mjs index 36c761586..89fc887b1 100644 --- a/scripts/mac-update-feed.test.mjs +++ b/scripts/mac-update-feed.test.mjs @@ -60,4 +60,18 @@ describe("buildFeedYml", () => { it("refuses to mix versions, which is what a stale artifact looks like", () => { expect(() => buildFeedYml([ARM, { ...X64, version: "1.9.2" }], NOW)).toThrow(/disagree/); }); + + // build.yml folds the two Windows NSIS installers through the same merge. + it("builds the Windows feed from the arch-tagged installer names", () => { + const yml = buildFeedYml( + [ + { ...ARM, url: "Openscreen.Setup.1.9.3-arm64.exe" }, + { ...X64, url: "Openscreen.Setup.1.9.3-x64.exe" }, + ], + NOW, + ); + expect(yml).toContain(" - url: Openscreen.Setup.1.9.3-arm64.exe"); + expect(yml).toContain(" - url: Openscreen.Setup.1.9.3-x64.exe"); + expect(yml).toMatch(/^path: Openscreen\.Setup\.1\.9\.3-x64\.exe$/m); + }); }); From 22fbb72829c37fb63f83d5925fb001ae09d2cee7 Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:42:06 +0200 Subject: [PATCH 19/24] build(nix): update npmDepsHash for the electron-builder bump --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index a8f4290e5..e902450c4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -51,7 +51,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-Rso0fFqexyhMTe3V/gtLWJzRQI8LrUWEPwEdRb2TYTY="; + npmDepsHash = "sha256-UzpZ/ENcr2YoYGXjlboWwzxz7iIYP1c7y2RWYVxyG3o="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; From 12a07d90a7e5f8fab9706e31143e2b34e90c08ad Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:42:06 +0200 Subject: [PATCH 20/24] style: apply Biome formatting to the Windows helper scripts --- scripts/build-windows-wgc-helper.mjs | 4 +--- scripts/test-windows-wgc-helper.mjs | 7 ++++++- scripts/windows-helper-arch.test.mjs | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index 6abf10cf4..b0afaf528 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -89,9 +89,7 @@ console.log(`Building Windows WGC helper for target arch: ${TARGET_ARCH} (vcvars // the build dir when the target arch changes; same-arch reruns stay incremental. const ARCH_STAMP = path.join(BUILD_DIR, ".target-arch"); if (fs.existsSync(BUILD_DIR)) { - const previousArch = fs.existsSync(ARCH_STAMP) - ? fs.readFileSync(ARCH_STAMP, "utf8").trim() - : ""; + const previousArch = fs.existsSync(ARCH_STAMP) ? fs.readFileSync(ARCH_STAMP, "utf8").trim() : ""; if (previousArch !== TARGET_ARCH) { fs.rmSync(BUILD_DIR, { recursive: true, force: true }); } diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index 8b56acef6..9d0463995 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -18,7 +18,12 @@ const HELPER_PATH = "electron", "native", "bin", - winBinDirName(resolveTargetArch({ envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, hostArch: process.arch })), + winBinDirName( + resolveTargetArch({ + envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, + hostArch: process.arch, + }), + ), "wgc-capture.exe", ); diff --git a/scripts/windows-helper-arch.test.mjs b/scripts/windows-helper-arch.test.mjs index 510cfcb20..983c0d643 100644 --- a/scripts/windows-helper-arch.test.mjs +++ b/scripts/windows-helper-arch.test.mjs @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { - SUPPORTED_ARCHES, normalizeArch, parseArchFlag, resolveTargetArch, resolveVcvarsArch, + SUPPORTED_ARCHES, winBinDirName, } from "./windows-helper-arch.mjs"; From eb1d62e4d09f6acf736ef637d826b155fe45670e Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:30:43 +0200 Subject: [PATCH 21/24] build(nix): update npmDepsHash for the merged lockfile --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 50038033c..a9ad62dd1 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -51,7 +51,7 @@ buildNpmPackage { ); }; - npmDepsHash = "sha256-VeehS9Cp7Z/tcKgPAkjTzRLORqMuSp1pRf2AIOgm6q4="; + npmDepsHash = "sha256-OZeVwqaUrC+BryZKqvJMQVtdvLuNC0ZM+KOvwCwGcyg="; env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; From 683901f3e479eb7e2e2b1e7120045b3fdca506ea Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:54:38 +0200 Subject: [PATCH 22/24] fix(build): ship the x64 OpenMP runtime with the arm64 installer's whisper fallback The arm64 installer carries win32-x64 for the whisper helper that runs under emulation, and its ggml libraries import vcomp140.dll. Only the arm64 copy was staged, so on a machine without the x64 Redistributable transcription failed in the loader. stage-vcomp-runtime now stages the x64 vcomp140.dll there too, and before-pack checks every win32-* folder that ships and refuses a runtime DLL built for the wrong architecture. --- scripts/before-pack.cjs | 84 +++++++++++++++++++++----- scripts/before-pack.test.mjs | 104 +++++++++++++++++++++++++++++++- scripts/stage-vcomp-runtime.mjs | 53 +++++++++++----- 3 files changed, 208 insertions(+), 33 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index 44e0ed02f..7124a67f4 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -381,6 +381,19 @@ function importedDlls(file) { return names; } +/** IMAGE_FILE_HEADER.Machine, as the arch tag of the `win32-` directories. */ +const PE_MACHINE = { 0x8664: "x64", 0xaa64: "arm64" }; + +/** The architecture a PE binary is built for, or undefined for a machine we do not ship. */ +function peArch(file) { + const b = fs.readFileSync(file); + const notPe = () => new Error(`${file} is not a PE binary, or is truncated`); + if (b.length < 0x40 || b.readUInt16LE(0) !== 0x5a4d) throw notPe(); + const pe = b.readUInt32LE(0x3c); + if (pe + 6 > b.length || b.readUInt32LE(pe) !== 0x00004550) throw notPe(); + return PE_MACHINE[b.readUInt16LE(pe + 4)]; +} + /** * Nothing we ship may depend on the Visual C++ Redistributable. * @@ -445,24 +458,62 @@ function checkWinNoRedistDependency(dir) { bad: entry.imports.filter((d) => VC_REDIST_DLL.test(d) && !shipped.has(d.toLowerCase())), })) .filter((entry) => entry.bad.length > 0); - if (offenders.length === 0) { + if (offenders.length > 0) { + throw new Error( + "Refusing to package binaries that need the Visual C++ Redistributable.\n\n" + + ` looked in: ${path.relative(ROOT, dir)}\n\n` + + `${offenders.map((o) => ` - ${o.name} imports ${o.bad.join(", ")}`).join("\n")}\n\n` + + "Those DLLs are not part of Windows. On a clean image the loader kills the process\n" + + "before main() (0xC0000135) or fails require(), and the app can only report an exit\n" + + "code. It works on every developer machine, which is why this is checked here.\n\n" + + "Build against the static CRT instead:\n" + + " - CMake helpers: CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded (electron/native/wgc-capture)\n" + + " - Rust addon: -C target-feature=+crt-static (crates/.cargo/config.toml)\n\n" + + "For a prebuilt binary that is not ours to recompile, ship the DLL it needs into this\n" + + "same directory and the check passes — that is what scripts/stage-vcomp-runtime.mjs\n" + + "does for the OpenMP runtime the whisper/ggml libraries import.", + ); + } + + // A colocated copy only counts when the loader can use it, and the name check above + // cannot tell: the Redist tree holds an x64 and an arm64 vcomp140.dll under the same + // name, and either one satisfies `shipped`. The x64 loader refuses the arm64 copy + // with the same 0xC0000135 as a missing file. + // + // Only the copies something here imports. The arm64 Redist folder carries a + // vcruntime140_1.dll whose header says x64 — it is the ARM64EC build Microsoft ships + // for emulated x64 code — and no ARM64 binary loads it, so its machine is irrelevant. + const dirArch = path.basename(dir).match(/^win32-(.+)$/)?.[1]; + if (!dirArch) { return; } + const imported = new Set(scanned.flatMap((entry) => entry.imports.map((d) => d.toLowerCase()))); + const wrongArch = files + .filter((name) => VC_REDIST_DLL.test(name) && imported.has(name.toLowerCase())) + .map((name) => ({ name, arch: peArch(path.join(dir, name)) })) + .filter((entry) => entry.arch !== dirArch); + if (wrongArch.length > 0) { + throw new Error( + "Refusing to package Visual C++ runtime DLLs built for the wrong architecture.\n\n" + + `${wrongArch.map((e) => ` - ${e.name} in ${path.basename(dir)} is built for ${e.arch ?? "an unknown machine"}`).join("\n")}\n\n` + + "The loader skips a DLL for another architecture as if it were absent. Stage the\n" + + `${dirArch} copy from VC\\Redist\\MSVC\\\\${dirArch}\\ — scripts/stage-vcomp-runtime.mjs does.`, + ); + } +} - throw new Error( - "Refusing to package binaries that need the Visual C++ Redistributable.\n\n" + - ` looked in: ${path.relative(ROOT, dir)}\n\n` + - `${offenders.map((o) => ` - ${o.name} imports ${o.bad.join(", ")}`).join("\n")}\n\n` + - "Those DLLs are not part of Windows. On a clean image the loader kills the process\n" + - "before main() (0xC0000135) or fails require(), and the app can only report an exit\n" + - "code. It works on every developer machine, which is why this is checked here.\n\n" + - "Build against the static CRT instead:\n" + - " - CMake helpers: CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded (electron/native/wgc-capture)\n" + - " - Rust addon: -C target-feature=+crt-static (crates/.cargo/config.toml)\n\n" + - "For a prebuilt binary that is not ours to recompile, ship the DLL it needs into this\n" + - "same directory and the check passes — that is what scripts/stage-vcomp-runtime.mjs\n" + - "does for the OpenMP runtime the whisper/ggml libraries import.", - ); +/** + * Every `win32-*` directory, because that is what ships: the extraResources filter is + * `win32-*` / `*`, not the target's directory alone. The arm64 installer carries + * win32-x64 for the whisper helper that runs under emulation, and checking only + * win32-arm64 let that helper ship without the OpenMP runtime its ggml libraries import. + */ +function checkWinShippedRedist(binDir) { + for (const entry of fs.readdirSync(binDir, { withFileTypes: true })) { + if (entry.isDirectory() && /^win32-/.test(entry.name)) { + checkWinNoRedistDependency(path.join(binDir, entry.name)); + } + } } function checkWinNativePayload(context) { @@ -474,7 +525,7 @@ function checkWinNativePayload(context) { bundleNoun: "the installer", emptyDirFix: `${FIX}\n\nThe STT helper and the capture helper are separate builds — see\ntechnical-documentation/engineering/build-and-packaging.md.`, }); - checkWinNoRedistDependency(dir); + checkWinShippedRedist(path.dirname(dir)); } function checkMacNativePayload(context) { @@ -806,6 +857,7 @@ exports.__testing = { MAC_MIN_OS_FLOOR, MAC_REQUIRED, checkNativePayload, + checkWinShippedRedist, }; /** 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 a0c356894..6abd46a8c 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -101,7 +101,7 @@ describe("symbol-version ceiling", () => { // 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 { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { declaredAppVersionFrom } from "./macos-floor.mjs"; @@ -357,3 +357,105 @@ describe("MAC_REQUIRED", () => { }); }); }); + +// The Windows counterpart. A synthetic PE32+ carrying only what importedDlls() reads: the +// COFF machine, one section, and an import directory naming `imports`. The tests run on +// the Linux CI, where no real Windows binary exists to point the guard at. +const MACHINE = { x64: 0x8664, arm64: 0xaa64 }; + +function pe(machine, imports) { + const pe = 0x40; + const opt = pe + 24; + const dirs = opt + 112; + const sectionTable = dirs + 16 * 8; + const raw = 0x200; + const va = 0x1000; + const descriptors = (imports.length + 1) * 20; + const names = imports.map((name) => Buffer.from(`${name}\0`, "latin1")); + const b = Buffer.alloc(raw + descriptors + names.reduce((n, name) => n + name.length, 0)); + b.writeUInt16LE(0x5a4d, 0); // "MZ" + b.writeUInt32LE(pe, 0x3c); + b.writeUInt32LE(0x00004550, pe); // "PE\0\0" + b.writeUInt16LE(machine, pe + 4); + b.writeUInt16LE(1, pe + 6); // NumberOfSections + b.writeUInt16LE(sectionTable - opt, pe + 20); // SizeOfOptionalHeader + b.writeUInt16LE(0x20b, opt); // PE32+ + b.writeUInt32LE(16, dirs - 4); // NumberOfRvaAndSizes + b.writeUInt32LE(va, dirs + 8); // import directory RVA + b.writeUInt32LE(va, sectionTable + 12); // VirtualAddress + b.writeUInt32LE(b.length - raw, sectionTable + 16); // VirtualSize + b.writeUInt32LE(raw, sectionTable + 20); // PointerToRawData + let at = raw + descriptors; + names.forEach((name, i) => { + b.writeUInt32LE(va + (at - raw), raw + i * 20 + 12); // IMAGE_IMPORT_DESCRIPTOR.Name + name.copy(b, at); + at += name.length; + }); + return b; +} + +/** A bin/ directory holding one `win32-` folder per key, the way electron-builder sees it. */ +function withWinBin(dirs, body) { + const bin = mkdtempSync(path.join(tmpdir(), "openscreen-winbin-")); + try { + for (const [tag, files] of Object.entries(dirs)) { + mkdirSync(path.join(bin, tag)); + for (const [name, bytes] of Object.entries(files)) { + writeFileSync(path.join(bin, tag, name), bytes); + } + } + return body(bin); + } finally { + rmSync(bin, { recursive: true, force: true }); + } +} + +describe("checkWinShippedRedist", () => { + // The arm64 installer as CI builds it: the native payload, plus the x64 whisper + // helper that runs under emulation until a native one is staged. `win32-*/*` ships + // both directories. + const arm64Native = { + "onnxruntime.dll": pe(MACHINE.arm64, ["MSVCP140.dll", "KERNEL32.dll"]), + "msvcp140.dll": pe(MACHINE.arm64, ["KERNEL32.dll"]), + }; + const x64Fallback = { + "whisper-stt-server.exe": pe(MACHINE.x64, ["ggml-base.dll", "KERNEL32.dll"]), + "ggml-base.dll": pe(MACHINE.x64, ["VCOMP140.DLL", "KERNEL32.dll"]), + }; + + it("refuses the arm64 package when its x64 fallback has no x64 OpenMP runtime", () => { + withWinBin({ "win32-arm64": arm64Native, "win32-x64": x64Fallback }, (bin) => { + expect(() => testing().checkWinShippedRedist(bin)).toThrow( + /win32-x64[\s\S]*ggml-base\.dll imports VCOMP140\.DLL/, + ); + }); + }); + + it("passes once the x64 runtime sits beside the x64 libraries", () => { + const x64 = { ...x64Fallback, "vcomp140.dll": pe(MACHINE.x64, ["KERNEL32.dll"]) }; + withWinBin({ "win32-arm64": arm64Native, "win32-x64": x64 }, (bin) => { + expect(() => testing().checkWinShippedRedist(bin)).not.toThrow(); + }); + }); + + // The name check alone is satisfied by the wrong file: an ARM64 vcomp140.dll in + // win32-x64 is exactly the copy the x64 loader cannot use. + it("refuses a runtime DLL built for the other architecture", () => { + const x64 = { ...x64Fallback, "vcomp140.dll": pe(MACHINE.arm64, ["KERNEL32.dll"]) }; + withWinBin({ "win32-arm64": arm64Native, "win32-x64": x64 }, (bin) => { + expect(() => testing().checkWinShippedRedist(bin)).toThrow( + /vcomp140\.dll in win32-x64 is built for arm64/, + ); + }); + }); + + // The arm64 Redist folder's vcruntime140_1.dll really does carry an x64 header (it is + // the ARM64EC build for emulated code). Nothing ARM64 imports it, so it must not fail + // the package — measured on the 1.13.0 arm64 installer, where it sits unused. + it("ignores a runtime DLL of another machine that nothing in the directory imports", () => { + const arm64 = { ...arm64Native, "vcruntime140_1.dll": pe(MACHINE.x64, ["KERNEL32.dll"]) }; + withWinBin({ "win32-arm64": arm64 }, (bin) => { + expect(() => testing().checkWinShippedRedist(bin)).not.toThrow(); + }); + }); +}); diff --git a/scripts/stage-vcomp-runtime.mjs b/scripts/stage-vcomp-runtime.mjs index 72c8b2fd9..4218fc2f9 100644 --- a/scripts/stage-vcomp-runtime.mjs +++ b/scripts/stage-vcomp-runtime.mjs @@ -50,8 +50,7 @@ const TARGET_ARCH = resolveTargetArch({ envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, hostArch: process.arch, }); -const ARCH_SEGMENT = TARGET_ARCH === "arm64" ? /\\arm64\\/i : /\\x64\\/i; -const DEST_DIR = path.join(ROOT, "electron", "native", "bin", winBinDirName(TARGET_ARCH)); +const binDir = (arch) => path.join(ROOT, "electron", "native", "bin", winBinDirName(arch)); // Lower-case, because that is how they are compared against `readdirSync` names. // vcomp140 lives in Microsoft.VC.OpenMP, the other four in Microsoft.VC.CRT — // sibling directories under the same Redist tree, so one walk finds them all. @@ -63,6 +62,17 @@ const DLLS = [ "vcruntime140_1.dll", ]; +// What goes where. The arm64 installer also ships win32-x64: until a native whisper +// helper is staged, the x64 one runs there under emulation, and its ggml-base.dll and +// ggml-cpu.dll import the x64 vcomp140.dll. The arm64 copy beside the native payload +// cannot stand in for it — the x64 loader skips a DLL of another architecture as if it +// were absent — so on a machine without the x64 Redistributable transcription died in +// the loader. That is the only x64 import there; whisper-stt-server.exe needs no CRT. +const STAGINGS = [{ arch: TARGET_ARCH, names: DLLS }]; +if (TARGET_ARCH === "arm64" && fs.existsSync(path.join(binDir("x64"), "ggml-base.dll"))) { + STAGINGS.push({ arch: "x64", names: ["vcomp140.dll"] }); +} + if (process.platform !== "win32") { console.log("Skipping Visual C++ runtime staging: Windows-only."); process.exit(0); @@ -99,11 +109,18 @@ function searchRoots() { ]; } -/** Candidate paths per DLL name, from ONE walk — the trees are large enough that - * walking them once per name would be the slowest part of the build. */ +// The architecture is the directory right under the toolset version: +// VC\Redist\MSVC\\\Microsoft.VC.{CRT,OpenMP}\. +const REDIST_ARCH = /\\MSVC\\[\d.]+\\(x64|arm64)\\/i; +const key = (arch, name) => `${arch}/${name}`; + +/** Candidate paths per arch and DLL name, from ONE walk — the trees are large enough + * that walking them once per name would be the slowest part of the build. */ function findRedistCopies() { const wanted = new Set(DLLS); - const found = new Map(DLLS.map((name) => [name, []])); + const found = new Map( + STAGINGS.flatMap(({ arch, names }) => names.map((name) => [key(arch, name), []])), + ); const walk = (dir, depth) => { if (depth > 8) return; let entries; @@ -117,10 +134,12 @@ function findRedistCopies() { const lower = entry.name.toLowerCase(); if (entry.isDirectory()) { walk(full, depth + 1); - } else if (wanted.has(lower) && /\\Redist\\/i.test(full) && ARCH_SEGMENT.test(full)) { + } else if (wanted.has(lower) && /\\Redist\\/i.test(full)) { // `onecore\x64` is a trimmed variant for Windows Core headless SKUs; the // desktop app wants the ordinary one. - if (!/\\onecore\\/i.test(full)) found.get(lower).push(full); + const arch = full.match(REDIST_ARCH)?.[1].toLowerCase(); + const bucket = arch && found.get(key(arch, lower)); + if (bucket && !/\\onecore\\/i.test(full)) bucket.push(full); } } }; @@ -145,11 +164,11 @@ const copies = findRedistCopies(); // Report every missing name at once. Staging four of five and failing on the fifth // would send someone back through the same install-and-retry loop per DLL. -const missing = DLLS.filter((name) => copies.get(name).length === 0); +const missing = [...copies].filter(([, paths]) => paths.length === 0).map(([k]) => k); if (missing.length > 0) { throw new Error( `Could not find a redistributable ${missing.join(", ")} under any Visual Studio installation.\n\n` + - `They live in VC\\Redist\\MSVC\\\\${TARGET_ARCH}\\ — vcomp140.dll under\n` + + "They live in VC\\Redist\\MSVC\\\\\\ — vcomp140.dll under\n" + "Microsoft.VC.OpenMP, the rest under Microsoft.VC.CRT.\n" + "Install the Visual Studio C++ workload, which is required to build the native\n" + "helpers anyway. Without these files the shipped whisper/ggml libraries and the\n" + @@ -159,11 +178,13 @@ if (missing.length > 0) { ); } -fs.mkdirSync(DEST_DIR, { recursive: true }); -for (const name of DLLS) { - const source = copies.get(name).sort(newestFirst)[0]; - const dest = path.join(DEST_DIR, name); - fs.copyFileSync(source, dest); - console.log(`Staged ${name} from ${source}`); - console.log(` -> ${path.relative(ROOT, dest)}`); +for (const { arch, names } of STAGINGS) { + fs.mkdirSync(binDir(arch), { recursive: true }); + for (const name of names) { + const source = copies.get(key(arch, name)).sort(newestFirst)[0]; + const dest = path.join(binDir(arch), name); + fs.copyFileSync(source, dest); + console.log(`Staged ${name} from ${source}`); + console.log(` -> ${path.relative(ROOT, dest)}`); + } } From eca72dcd33aa25635ee72fd1cb0a07ec6b7df83f Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:08:46 +0200 Subject: [PATCH 23/24] fix(wgc): build the BGRA to NV12 converter without SSE2 on ARM64 c7298340 vectorised the converter with SSE2, and MSVC's emmintrin.h is an #error on ARM64, so the helper no longer built for Windows on ARM. The SSE2 loops now compile on x86/x64 only; ARM64 runs the scalar loops that already handled the row tails. mf_encoder_color_test passes natively on ARM64 (18/18, colours exact to the code value) at 3.5 ms per 1080p frame. --- electron/native/wgc-capture/src/mf_encoder.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 9eac8c4b4..500ff5008 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -10,7 +10,13 @@ #include #include +// SSE2 only where it exists. On ARM64 MSVC's emmintrin.h is an #error unless the +// soft-intrinsics emulation is linked in, so the converter below falls back to its +// scalar loops there, which the colour test checks against BT.709 the same way. +#if defined(_M_X64) || defined(_M_IX86) +#define OPENSCREEN_NV12_SSE2 1 #include +#endif #include #include @@ -608,6 +614,7 @@ void convertBgraToNv12Bt709(const BYTE* bgra, int stride, int width, int height, // Luma four pixels at a time in SSE2, which every x64 CPU has: this runs on // the video writer's thread for every frame, and the scalar loop alone cost // 2.5 ms a 1080p frame. 15-bit coefficients so they fit madd's int16 lanes. +#ifdef OPENSCREEN_NV12_SSE2 const __m128i zero = _mm_setzero_si128(); const __m128i lumaCoefficients = _mm_setr_epi16(2032, 20127, 5983, 0, 2032, 20127, 5983, 0); const __m128i lumaRounding = _mm_set1_epi32(16384); @@ -617,10 +624,12 @@ void convertBgraToNv12Bt709(const BYTE* bgra, int stride, int width, int height, const __m128i products = _mm_madd_epi16(pixels, lumaCoefficients); return _mm_add_epi32(products, _mm_shuffle_epi32(products, _MM_SHUFFLE(2, 3, 0, 1))); }; +#endif for (int y = 0; y < height; y += 1) { const BYTE* row = bgra + static_cast(y) * stride; BYTE* out = luma + static_cast(y) * width; int x = 0; +#ifdef OPENSCREEN_NV12_SSE2 for (; x + 4 <= width; x += 4) { const __m128i pixels = _mm_loadu_si128(reinterpret_cast(row + x * 4)); const __m128i first = lumaOfTwo(_mm_unpacklo_epi8(pixels, zero)); @@ -632,6 +641,7 @@ void convertBgraToNv12Bt709(const BYTE* bgra, int stride, int width, int height, const int packed = _mm_cvtsi128_si32(bytes); std::memcpy(out + x, &packed, 4); } +#endif for (; x < width; x += 1) { const int b = row[x * 4]; const int g = row[x * 4 + 1]; @@ -641,6 +651,7 @@ void convertBgraToNv12Bt709(const BYTE* bgra, int stride, int width, int height, } // Chroma the same way, two 2x2 blocks at a time: the four pixels of each // block summed in int16 lanes (at most 1020), then one madd per channel. +#ifdef OPENSCREEN_NV12_SSE2 const __m128i cbCoefficients = _mm_setr_epi16(28784, -22189, -6596, 0, 28784, -22189, -6596, 0); const __m128i crCoefficients = _mm_setr_epi16(-2642, -26142, 28784, 0, -2642, -26142, 28784, 0); const __m128i chromaRounding = _mm_set1_epi32(131072); @@ -650,11 +661,13 @@ void convertBgraToNv12Bt709(const BYTE* bgra, int stride, int width, int height, const __m128i sums = _mm_add_epi32(products, _mm_shuffle_epi32(products, _MM_SHUFFLE(2, 3, 0, 1))); return _mm_shuffle_epi32(sums, _MM_SHUFFLE(2, 0, 2, 0)); }; +#endif for (int y = 0; y < height; y += 2) { const BYTE* top = bgra + static_cast(y) * stride; const BYTE* bottom = y + 1 < height ? top + stride : top; BYTE* out = chroma + static_cast(y / 2) * width; int x = 0; +#ifdef OPENSCREEN_NV12_SSE2 for (; x + 4 <= width; x += 4) { const __m128i upper = _mm_loadu_si128(reinterpret_cast(top + x * 4)); const __m128i lower = _mm_loadu_si128(reinterpret_cast(bottom + x * 4)); @@ -669,6 +682,7 @@ void convertBgraToNv12Bt709(const BYTE* bgra, int stride, int width, int height, const int packed = _mm_cvtsi128_si32(_mm_packus_epi16(_mm_packs_epi32(values, zero), zero)); std::memcpy(out + x, &packed, 4); } +#endif for (; x < width; x += 2) { const int right = (x + 1 < width ? x + 1 : x) * 4; const int left = x * 4; From 6fa461f80ba77e6a1750d19659c3f71350a2752a Mon Sep 17 00:00:00 2001 From: Christian Schmittel <90287914+christian-wr@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:29:54 +0200 Subject: [PATCH 24/24] fix(build): check every shipped Windows binary's architecture, and refuse a partial x64 whisper fallback before-pack now checks the PE machine of every binary in a win32-* folder, not only the runtime DLLs something imports, so an x64 compositor addon in win32-arm64 is refused. stage-vcomp-runtime keys the x64 fallback off the server and refuses a server staged without the libraries that import OpenMP, instead of skipping the runtime for it. --- scripts/before-pack.cjs | 29 ++++++++++++++++------------- scripts/before-pack.test.mjs | 14 ++++++++++++++ scripts/stage-vcomp-runtime.mjs | 24 ++++++++++++++++++++---- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index 7124a67f4..8dbfef40f 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -475,29 +475,32 @@ function checkWinNoRedistDependency(dir) { ); } - // A colocated copy only counts when the loader can use it, and the name check above - // cannot tell: the Redist tree holds an x64 and an arm64 vcomp140.dll under the same - // name, and either one satisfies `shipped`. The x64 loader refuses the arm64 copy - // with the same 0xC0000135 as a missing file. + // Every binary here must be built for this directory's architecture, and none of the + // checks above can tell: the right name and clean imports say nothing about the + // machine. An x64 compositor addon in win32-arm64 fails require() on every ARM64 + // device, and the Redist tree holds an x64 and an arm64 vcomp140.dll under the same + // name, so either satisfies `shipped` while the loader refuses the wrong one with + // the same 0xC0000135 as a missing file. // - // Only the copies something here imports. The arm64 Redist folder carries a - // vcruntime140_1.dll whose header says x64 — it is the ARM64EC build Microsoft ships - // for emulated x64 code — and no ARM64 binary loads it, so its machine is irrelevant. + // The one exception is a runtime DLL that nothing here imports. The arm64 Redist + // folder carries a vcruntime140_1.dll whose header says x64 — it is the ARM64EC build + // Microsoft ships for emulated x64 code — and no ARM64 binary loads it. const dirArch = path.basename(dir).match(/^win32-(.+)$/)?.[1]; if (!dirArch) { return; } const imported = new Set(scanned.flatMap((entry) => entry.imports.map((d) => d.toLowerCase()))); - const wrongArch = files - .filter((name) => VC_REDIST_DLL.test(name) && imported.has(name.toLowerCase())) - .map((name) => ({ name, arch: peArch(path.join(dir, name)) })) + const wrongArch = scanned + .filter(({ name }) => !VC_REDIST_DLL.test(name) || imported.has(name.toLowerCase())) + .map(({ name }) => ({ name, arch: peArch(path.join(dir, name)) })) .filter((entry) => entry.arch !== dirArch); if (wrongArch.length > 0) { throw new Error( - "Refusing to package Visual C++ runtime DLLs built for the wrong architecture.\n\n" + + "Refusing to package native binaries built for the wrong architecture.\n\n" + `${wrongArch.map((e) => ` - ${e.name} in ${path.basename(dir)} is built for ${e.arch ?? "an unknown machine"}`).join("\n")}\n\n` + - "The loader skips a DLL for another architecture as if it were absent. Stage the\n" + - `${dirArch} copy from VC\\Redist\\MSVC\\\\${dirArch}\\ — scripts/stage-vcomp-runtime.mjs does.`, + `A ${dirArch} process cannot load them. Rebuild or re-fetch them for ${dirArch}; for the\n` + + `Visual C++ runtime, stage the copy from VC\\Redist\\MSVC\\\\${dirArch}\\ —\n` + + "scripts/stage-vcomp-runtime.mjs does.", ); } } diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index 6abd46a8c..e39e9e041 100644 --- a/scripts/before-pack.test.mjs +++ b/scripts/before-pack.test.mjs @@ -458,4 +458,18 @@ describe("checkWinShippedRedist", () => { expect(() => testing().checkWinShippedRedist(bin)).not.toThrow(); }); }); + + // Not only the runtime: an x64 compositor addon in win32-arm64 has the right name and + // clean imports, and an ARM64 process still cannot load it. + it("refuses any native binary built for the other architecture", () => { + const arm64 = { + ...arm64Native, + "compositor_view.node": pe(MACHINE.x64, ["KERNEL32.dll"]), + }; + withWinBin({ "win32-arm64": arm64 }, (bin) => { + expect(() => testing().checkWinShippedRedist(bin)).toThrow( + /compositor_view\.node in win32-arm64 is built for x64/, + ); + }); + }); }); diff --git a/scripts/stage-vcomp-runtime.mjs b/scripts/stage-vcomp-runtime.mjs index 4218fc2f9..1ebf96893 100644 --- a/scripts/stage-vcomp-runtime.mjs +++ b/scripts/stage-vcomp-runtime.mjs @@ -68,16 +68,32 @@ const DLLS = [ // cannot stand in for it — the x64 loader skips a DLL of another architecture as if it // were absent — so on a machine without the x64 Redistributable transcription died in // the loader. That is the only x64 import there; whisper-stt-server.exe needs no CRT. -const STAGINGS = [{ arch: TARGET_ARCH, names: DLLS }]; -if (TARGET_ARCH === "arm64" && fs.existsSync(path.join(binDir("x64"), "ggml-base.dll"))) { - STAGINGS.push({ arch: "x64", names: ["vcomp140.dll"] }); -} +// +// Keyed off the server, not off a library: a server staged without the two libraries +// that import OpenMP is a fallback that cannot start, and skipping the runtime for it +// would let that package through. Refuse it here instead. +const X64_WHISPER_OPENMP_LIBS = ["ggml-base.dll", "ggml-cpu.dll"]; if (process.platform !== "win32") { console.log("Skipping Visual C++ runtime staging: Windows-only."); process.exit(0); } +const STAGINGS = [{ arch: TARGET_ARCH, names: DLLS }]; +if (TARGET_ARCH === "arm64" && fs.existsSync(path.join(binDir("x64"), "whisper-stt-server.exe"))) { + const absent = X64_WHISPER_OPENMP_LIBS.filter( + (name) => !fs.existsSync(path.join(binDir("x64"), name)), + ); + if (absent.length > 0) { + throw new Error( + `win32-x64 holds whisper-stt-server.exe but not ${absent.join(", ")}.\n\n` + + "The arm64 installer would ship an x64 transcription fallback that cannot start.\n" + + "Stage the helper as a whole: bash scripts/stage-whisper-stt.sh win32-x64", + ); + } + STAGINGS.push({ arch: "x64", names: ["vcomp140.dll"] }); +} + /** * Every vcomp140.dll under a Visual Studio redistributable directory. *