diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd0f477a6..2d098451b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,8 +42,18 @@ concurrency: jobs: build-windows: - name: Windows installer - runs-on: windows-latest + name: Windows ${{ matrix.arch }} installer + # 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: + arch: [x64, arm64] steps: - name: Checkout code uses: actions/checkout@v7 @@ -51,6 +61,40 @@ 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." + } + + # 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: | + 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 + 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 model is fetched here. The binary is built by # build-whisper-stt.yml and staged below, from the run built from this @@ -62,33 +106,60 @@ jobs: shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # win32-x64 for BOTH arches, deliberately: build-whisper-stt.yml produces no + # 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 + # native helper exists; the resolver already prefers it. run: bash scripts/stage-whisper-stt.sh win32-x64 - - 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 + + # 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 - # 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. + name: openscreen-windows-${{ matrix.arch }} + # 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; } @@ -875,11 +946,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: - name: openscreen-windows - 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 @@ -919,6 +999,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/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/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/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", diff --git a/electron/native/README.md b/electron/native/README.md index 5f68fb254..9d19fdb6e 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -45,10 +45,16 @@ 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`). + +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: `. 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; 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 { 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"; diff --git a/package-lock.json b/package-lock.json index 468e5537f..c81cf8a45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,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", @@ -905,9 +905,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": { @@ -1179,9 +1179,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": { @@ -1189,9 +1189,9 @@ } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "dependencies": { @@ -1265,9 +1265,9 @@ } }, "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "optional": true, @@ -2221,6 +2221,8 @@ "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 20.19.0" }, @@ -2402,15 +2404,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.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", "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": { @@ -5007,9 +5012,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": { @@ -5194,9 +5199,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.17.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.17.0.tgz", + "integrity": "sha512-wfL7EhujuODDW3uMSD8RPcr/MLzmT0dmpVfWV0wFUWXI0rIdaukzAJ7EFUSyHXOpxuBZqaIGlQt6VeQhtHhetw==", "dev": true, "license": "MIT", "dependencies": { @@ -5208,13 +5213,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", @@ -5222,7 +5227,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", @@ -5246,8 +5251,8 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.15.3", - "electron-builder-squirrel-windows": "26.15.3" + "dmg-builder": "26.17.0", + "electron-builder-squirrel-windows": "26.17.0" } }, "node_modules/app-builder-lib/node_modules/@electron/get": { @@ -5297,6 +5302,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", @@ -5614,9 +5632,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": { @@ -5690,9 +5708,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": { @@ -6473,9 +6491,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": { @@ -6503,14 +6521,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.17.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.17.0.tgz", + "integrity": "sha512-EAw1f2iGkICmUvAVBOjm7v5mxJdB7/+OZSycHFhKBimiTkCXtvkCC6wBKzHo7ZQYi58C2MmwIleRbgRnROlK2Q==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.17.0", + "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } @@ -6656,18 +6674,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.17.0", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.17.0.tgz", + "integrity": "sha512-iYHBRiagS9sDIbZx1ZD113f5rEGQvtpvTvHf70ovHKJ8mRvxwIkWX7JCwfmVQmVS4eX735p7TZmoxHnBPwF3vA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.17.0", + "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.17.0", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -6682,15 +6700,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.17.0", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.17.0.tgz", + "integrity": "sha512-uyh+D+B0m12qCRp9c/7dwBirHTh7JcvAzj5eTEbb4dXPPGAhYPx1nozzbu1fC6AdULfat6ZNqgWhipTFpb82lQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", + "app-builder-lib": "26.17.0", + "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, @@ -6733,15 +6751,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", @@ -7437,9 +7455,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": { @@ -7750,9 +7768,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": { @@ -9723,13 +9741,16 @@ } }, "node_modules/pkijs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", - "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", "dev": true, "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], "dependencies": { - "@noble/hashes": "1.4.0", + "@noble/hashes": "1.8.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", @@ -9741,13 +9762,13 @@ } }, "node_modules/pkijs/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "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": ">= 16" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -10291,9 +10312,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": { diff --git a/package.json b/package.json index b1560e944..8806fc905 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", @@ -157,7 +159,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", diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index f92d8e7ed..8dbfef40f 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,28 +458,69 @@ 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.", + ); + } + + // 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. + // + // 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 = 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 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` + + `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.", + ); + } +} - 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() { - 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, @@ -474,7 +528,7 @@ function checkWinNativePayload() { 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 +860,7 @@ exports.__testing = { MAC_MIN_OS_FLOOR, MAC_REQUIRED, checkNativePayload, + checkWinShippedRedist, }; /** Every ELF under `dir`, recursively — the helper's ffmpeg sits in a subdirectory. */ @@ -1118,15 +1173,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; } diff --git a/scripts/before-pack.test.mjs b/scripts/before-pack.test.mjs index a0c356894..e39e9e041 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,119 @@ 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(); + }); + }); + + // 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/build-whisper-stt.sh b/scripts/build-whisper-stt.sh index 74f5be046..23b33ce3e 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,66 @@ 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 + # 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=$? + rm -f "${script}" + return ${status} +} + build_variant() { local variant_name="$1" shift @@ -158,14 +237,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" diff --git a/scripts/build-windows-compositor-addon.mjs b/scripts/build-windows-compositor-addon.mjs index cc40675d1..37a3f55ae 100644 --- a/scripts/build-windows-compositor-addon.mjs +++ b/scripts/build-windows-compositor-addon.mjs @@ -19,12 +19,30 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { findVcVarsAll, run as spawnStep } from "./msvcEnv.mjs"; +import { + parseArchFlag, + 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 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); + // 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 +69,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 +136,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); diff --git a/scripts/build-windows-wgc-helper.mjs b/scripts/build-windows-wgc-helper.mjs index ab4b26c00..ac18a807e 100644 --- a/scripts/build-windows-wgc-helper.mjs +++ b/scripts/build-windows-wgc-helper.mjs @@ -4,12 +4,26 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { findVcVarsAll, run as spawnStep } from "./msvcEnv.mjs"; +import { + parseArchFlag, + resolveTargetArch, + resolveVcvarsArch, + winBinDirName, +} from "./windows-helper-arch.mjs"; + +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 +35,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 +58,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,7 +81,21 @@ if (process.platform !== "win32") { process.exit(0); } +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`, 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); + }); }); diff --git a/scripts/stage-vcomp-runtime.mjs b/scripts/stage-vcomp-runtime.mjs index d987946ea..1ebf96893 100644 --- a/scripts/stage-vcomp-runtime.mjs +++ b/scripts/stage-vcomp-runtime.mjs @@ -35,10 +35,22 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { findVcVarsAll } from "./msvcEnv.mjs"; +import { parseArchFlag, 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: parseArchFlag(process.argv.slice(2)), + envArch: process.env.OPENSCREEN_WIN_HELPER_ARCH, + hostArch: process.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. @@ -50,11 +62,38 @@ 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. +// +// 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. * @@ -86,11 +125,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; @@ -104,10 +150,12 @@ 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)) { // `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); } } }; @@ -132,11 +180,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\\\\x64\\ — 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" + @@ -146,11 +194,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)}`); + } } diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index 0ff318bc0..5b23bafef 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -4,12 +4,28 @@ 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 = diff --git a/scripts/windows-helper-arch.mjs b/scripts/windows-helper-arch.mjs new file mode 100644 index 000000000..e0998acdd --- /dev/null +++ b/scripts/windows-helper-arch.mjs @@ -0,0 +1,77 @@ +// 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()); +} + +// `--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], + ["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..983c0d643 --- /dev/null +++ b/scripts/windows-helper-arch.test.mjs @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeArch, + parseArchFlag, + resolveTargetArch, + resolveVcvarsArch, + SUPPORTED_ARCHES, + 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("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"); + }); + + 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"]); +});