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/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..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; } 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..72c8b2fd9 100644 --- a/scripts/stage-vcomp-runtime.mjs +++ b/scripts/stage-vcomp-runtime.mjs @@ -35,10 +35,23 @@ 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 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 +117,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 +149,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" + 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"]); +});