Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
06e9074
feat(build): add pure Windows helper arch-resolution module
christian-wr Jul 2, 2026
7de7e8b
feat(build): make Windows WGC helper build arch-aware (x64/arm64, nat…
christian-wr Jul 2, 2026
d45e3dd
feat(build): add build:win:arm64 and build:native:win:arm64 scripts
christian-wr Jul 2, 2026
3c294ef
build: include arch in Windows installer filename to avoid x64/arm64 …
christian-wr Jul 2, 2026
932e35a
ci: build Windows x64 and arm64 installers via matrix
christian-wr Jul 2, 2026
78dd673
docs: document Windows arm64 native helper build and installer
christian-wr Jul 2, 2026
c87d693
fix(build): wipe WGC build dir on target-arch change to prevent stale…
christian-wr Jul 2, 2026
cc6c50e
ci: fetch win32-arm64 sharp prebuilt for arm64 packaging (non-fatal)
christian-wr Jul 2, 2026
2921476
fix(build): check the Windows native payload for the target arch
christian-wr Sep 18, 2026
c9a5b18
feat(build): build the D3D11 compositor addon for the target arch
christian-wr Sep 18, 2026
13720c3
feat(build): stage the Visual C++ runtime for the target arch
christian-wr Sep 18, 2026
3e13f94
build(deps): electron-builder 26.16.1 — fixes the arm64 NSIS installer
christian-wr Sep 18, 2026
3bea9e5
fix(stt): fall back to the x64 whisper helper on Windows on ARM
christian-wr Sep 18, 2026
0b70ed6
fix(test): run the WGC smoke tests against the host's own helper
christian-wr Sep 18, 2026
24e5607
feat(build): build the whisper helper natively for Windows on ARM
christian-wr Sep 18, 2026
f32397b
fix(build): refuse --arch without a value instead of building for the…
christian-wr Oct 1, 2026
0e489ff
fix(build): keep argument boundaries in the whisper VS environment wr…
christian-wr Oct 1, 2026
5066226
ci: build the Windows arm64 installer natively and publish one update…
christian-wr Oct 1, 2026
22fbb72
build(nix): update npmDepsHash for the electron-builder bump
christian-wr Oct 1, 2026
12a07d9
style: apply Biome formatting to the Windows helper scripts
christian-wr Oct 1, 2026
d012d43
Merge main into feat/windows-on-arm-support
christian-wr Oct 1, 2026
eb1d62e
build(nix): update npmDepsHash for the merged lockfile
christian-wr Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 111 additions & 15 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,59 @@ 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

- 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
Expand All @@ -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; }

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<version>-x64.exe`) and ARM64 (`Openscreen.Setup.<version>-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.
Expand Down
6 changes: 6 additions & 0 deletions crates/.cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
2 changes: 1 addition & 1 deletion electron-builder.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion electron/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<arch>/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: <path>`.

Expand Down
37 changes: 37 additions & 0 deletions electron/stt/gpuDetector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
42 changes: 32 additions & 10 deletions electron/stt/gpuDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ buildNpmPackage {
);
};

npmDepsHash = "sha256-VeehS9Cp7Z/tcKgPAkjTzRLORqMuSp1pRf2AIOgm6q4=";
npmDepsHash = "sha256-OZeVwqaUrC+BryZKqvJMQVtdvLuNC0ZM+KOvwCwGcyg=";

env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1";

Expand Down
Loading
Loading