windows-local-build · git:20261028.7f6e1aa · 2026-10-28 · sha256 7a6893e7b1d65e54
windows-local-build git:20261028.7f6e1aaA
Immutable. This exact content never changes and is served at /api/v1/blob/7a6893e7b1d65e54.
---
name: windows-local-build
description: >-
Build a Tauri app's Windows version on a Mac and test it in a local VM, with no
GitHub Actions and no Windows PC: cross-compile the exe and NSIS installer with
cargo-xwin, then install, run a windowless self-test, screenshot, cut the network
and roll back snapshots in a Parallels VM through prlctl. Use when the user wants
a Windows build without CI, has run out of Actions minutes, asks to cross-compile
for Windows, test in Parallels or compare x64 and arm64, when makensis crashes
with std::bad_alloc, or says "לבנות לווינדוס מהמק", "לבדוק בווינדוס בלי GitHub",
"בנייה מקומית לווינדוס". It does NOT add the updater or write the feed
(windows-direct-distribution), does not Authenticode-sign, and does not measure
speed on real x64 hardware.
---
# Build and test a Windows app locally, from a Mac
Building Windows in GitHub Actions is the easy default and the expensive one: on a
private repo a Windows minute counts twice, and an allowance that runs out stops
every build in every repo until the month turns. A Tauri app does not need it. The
installer **cross-compiles on the Mac**, and a Windows VM on the same Mac **installs
and checks** it — both driven from one script.
Three things carry the weight, and each has its reasons in
[references/local-build.md](references/local-build.md):
1. **makensis needs a UTF-8 locale.** Without one it dies with `std::bad_alloc`,
which reads like a memory problem and is not. The script sets `LC_ALL` itself.
2. **The VM sees only some of the Mac.** Never assume a project folder is shared;
check, and stage files through a folder that is.
3. **The check runs without a window** (`--selftest`), because the VM is often
someone's working Windows, and a test that opens windows over it is a test
nobody runs.
## Prerequisites
- **Tools:** a Tauri 2 app with an NSIS target; on the Mac `brew install nsis llvm`,
`cargo install --locked cargo-xwin`,
`rustup target add x86_64-pc-windows-msvc aarch64-pc-windows-msvc`, Node (`npx`),
`python3`. For testing: Parallels Desktop with a Windows 10/11 VM (`prlctl` ships
with it). Building alone needs no VM.
- **Credentials:** none.
- **Hub (`$APP_HUB`):** not used.
- **Other tracks:** none.
## Workflow
### 1. First: does the repo already have a script?
Look for a `win*.sh` beside the app's other build scripts. If one exists, **run it**:
it already knows the app's names and the shares of the VM it was written on. If not,
use [scripts/win_local.sh](scripts/win_local.sh) — run it from the app directory, or
**offer to copy it into the repo** so a person can build without opening a session.
```bash
bash ${CLAUDE_PLUGIN_ROOT}/skills/windows-local-build/scripts/win_local.sh --help
```
It reads the exe name from `src-tauri/Cargo.toml` and the install folder from
`productName` in `tauri.conf.json`; the VM name, the staging folder and the snapshot
name are variables (`WIN_VM`, `WIN_STAGE`, `WIN_SNAPSHOT` …, all in `--help`).
### 2. One-time setup on the Mac, then build
Install the tools under Prerequisites, then:
```bash
win_local.sh build all # or x64 / arm64
```
This is `npx tauri build --runner cargo-xwin --target <triple> --bundles nsis` per
architecture. The installer lands in
`src-tauri/target/<triple>/release/bundle/nsis/`. The **first** build downloads the
MSVC CRT and Windows SDK headers into cargo-xwin's cache — several hundred MB, once,
and under Microsoft's license terms, which cargo-xwin accepts on your behalf. Say so
before the first run.
If `makensis` crashes with `std::bad_alloc`, the build ran without the script, in a
shell with no UTF-8 locale: `export LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8`.
If **arm64** fails with `clang: error: no such file or directory: '/imsvc'`, the
culprit is `ring`, pulled in by rustls. Switch the HTTP client to `native-tls` — which
a desktop app wants anyway, because filtering networks that re-sign HTTPS with a root
in the Windows store break a bundled root list
([details](references/local-build.md#ring-on-arm64-and-why-a-desktop-app-wants-native-tls-anyway)).
### 3. Find out what the VM sees — before staging anything
```bash
win_local.sh shares # `net use` and `dir \\Mac\Home` inside the VM
```
Parallels often shares only the "Shared Profile" folders (Desktop, Documents,
Downloads…), not the folder the code lives in. The script stages each file in
`~/Downloads/<bin>-vm` by default and the VM copies it from there to its own disk.
If Downloads is not listed, set `WIN_STAGE` to a folder that is; if the VM reaches
the Mac's home by drive letter only, set `WIN_HOME_VM` (for example `Z:`).
### 4. Give the app a windowless self-test
`win_local.sh test` runs `<exe> --selftest <file>` inside the VM and prints the JSON
the app writes. **An app that does not handle the flag simply opens its window** —
add it first; it is a dozen lines in `main.rs`, in
[references/local-build.md](references/local-build.md#the---selftest-pattern). Check
what the app depends on: the build target, its database engine and version, a
writable data folder, whatever else would differ between platforms.
```bash
win_local.sh test all # x64 and arm64 side by side
```
On Apple Silicon the VM is ARM64: the arm64 build runs natively, the x64 build under
Windows' emulation. Identical output from both (and from the Mac build) is the
evidence that the platform layer behaves the same.
### 5. Install, open, look
```bash
win_local.sh install x64 # silent NSIS install (/S)
win_local.sh open # blocks until the window is closed — run it in the background
win_local.sh shot # PNG of the VM's screen
win_local.sh close
```
`prlctl exec` waits for the whole process tree, so `open` returns only when the app
quits. Start it in the background and take `shot`s, or `close` it from another call.
### 6. First install and no network
```bash
win_local.sh snapshot # once, on a clean VM, before the app was ever installed
win_local.sh fresh # back to it: a true first install
win_local.sh offline # / online
```
`offline` disconnects the **whole VM** — anything else running there loses its
network too. Ask before running it on a VM someone is working in, and always run
`online` afterwards. `fresh` discards the VM's current state; ask before it as well.
### 7. Report what the VM could not show
Name these in the report rather than leaving them implied
([details](references/local-build.md#what-the-vm-cannot-show)):
- **Speed on real x64 hardware** — emulation says nothing about it. One run on an
ordinary PC before a first release.
- **WebView2 on an offline Windows 10.** Windows 11 ships it; an older Windows 10 may
not, and Tauri's default installer downloads it. For users without a network, a
second installer with `webviewInstallMode: offlineInstaller` (about 150 MB more).
- **Windows 7 and 8** are not supported at all: WebView2 and Tauri 2 need Windows 10+.
## Without Parallels
Parallels costs money; **UTM** is free and runs Windows 11 ARM well. Building needs
neither — it happens on the Mac. But UTM has no good equivalent of `prlctl exec
--current-user` (its guest-agent `exec` runs as a service, not as the signed-in user),
so `test`, `install`, `open`, `shot` and the network commands do not work with it. What is left
is by hand: copy the installer into the VM through a shared folder, install it, run
`<exe> --selftest out.json` in a Windows terminal, and read the file.
## Scripts
- [scripts/win_local.sh](scripts/win_local.sh) — build, test, install, open, close,
shot, offline/online, snapshot/fresh, shares; `--help` lists every variable.
## References
- [references/local-build.md](references/local-build.md) — the cross-compile chain
and its failures (including `ring` on arm64 and native-tls), Parallels' shares and `prlctl exec`'s quirks, the `--selftest`
pattern in Rust, x64 under emulation, and what a VM cannot show.
## Boundaries
This skill **builds and checks**. It does not add the updater or write the appcast
(`windows-direct-distribution`), does not Authenticode-sign or notarize anything,
does not upload or publish, and does not build the Mac version. It does not set up
GitHub Actions — it is the way around them.