Windows Local Build
ASecurityBuild 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::ba...
- 5 stars
- 0 votes
- 0 copies
- 0 views
- Added November 11, 2026
Works with
Security analysis
100/100Pro scans all 3 files and shows the line behind each finding
npx -y skills add yybd/app-factory --skill windows-local-build --agent claude-codeAre you the author of Windows Local Build?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/yybd-windows-local-build)---
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.
Four 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.
4. **Xcode 27's linker corrupts release links** when `MACOSX_DEPLOYMENT_TARGET` is 12
or higher. The cross build fails on it, and a **Mac** release build is damaged by it
silently. The script links host code through a wrapper that avoids it.
## 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 the build stops with **`E0463 can't find crate for <x>_macros`** (or `_derive`),
naming a different crate each run, the host linker is the Xcode 27 one with its
LINKEDIT bug: the proc-macro dylibs it wrote do not load. The script avoids it by
pointing `CARGO_TARGET_<host>_APPLE_DARWIN_LINKER` at a wrapper that runs
`cc -Wl,-no_fixup_chains`; outside the script, set that yourself
([details](references/local-build.md#xcode-27s-linker-and-e0463)).
> **The same bug ships broken Mac apps.** A Mac release build of the app linked by
> that linker still launches on the build machine, so nothing looks wrong. Check every
> Mac release binary with `dyld_info -platform <binary>` — "mis-aligned LINKEDIT string
> pool" means it is damaged — and build Mac releases with the same wrapper.
A `#[cfg(target_os = "macos")]` in a **`build.rs`** describes the machine running the
build script, not the target, so it is true in a cross build from a Mac and breaks it.
Read `std::env::var("CARGO_CFG_TARGET_OS")` there instead.
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)).
#### llama.cpp with the Vulkan backend
`llama-cpp-sys-2` with its `vulkan` feature needs a Vulkan SDK for Windows, which
LunarG ships only as a Windows installer. One built from Homebrew works:
```bash
brew install vulkan-headers shaderc spirv-headers ninja
win_local.sh vulkan-sdk # ~/Library/Caches/vulkan-sdk-windows, once
win_local.sh build x64 # exports VULKAN_SDK and CMAKE_PREFIX_PATH by itself
```
`build` sets the variables when `src-tauri/Cargo.toml` mentions vulkan (or
`WIN_VULKAN=1`). Never put `vulkan-headers` on `CMAKE_PREFIX_PATH` — `spirv.hpp` is
then not found. Tested for x64 only
([details](references/local-build.md#llamacpps-vulkan-backend-cross-built-for-windows)).
### 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, vulkan-sdk; `--help` lists every
variable.
## References
- [references/local-build.md](references/local-build.md) — the cross-compile chain
and its failures (including Xcode 27's linker, `build.rs` cfgs, the Vulkan SDK, and
`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.
Files in this skill
- SKILL.md
- references/local-build.md
- scripts/win_local.sh
Attribution
Related skills
View all in development →Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.