diff --git a/CLAUDE.md b/CLAUDE.md index 6304af68..8766b520 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,8 @@ Maple is a **Tauri-based AI chat application** that runs on desktop (macOS, Linu ## Environment The user typically runs `nix develop` (using `flake.nix`) before starting Claude. This means you're usually in a Nix shell with all required tools (bun, cargo, rustc, etc.) already available. +`frontend/.env.local` must exist for any local build (mac/linux/windows). CI sets these values via GitHub env, but local devs need the file or Vite bakes empty strings into the bundle and the app launches to a silent white-screen. Copy `frontend/.env.local.template` to `frontend/.env.local` if it's missing. On Windows, `scripts/setup-windows.ps1` creates it automatically. See `docs/windows-build.md` for the Windows dev workflow. + ## Build & Development Commands Use justfile for all commands (`just --list` to see all): diff --git a/README.md b/README.md index cd6952d8..0baa2904 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ sudo apt install libwebkit2gtk-4.1-dev \ librsvg2-dev ``` +### Windows +A bootstrap script handles VS Build Tools, Rust, Node, and `.env.local`. +Run from an elevated PowerShell in the repo root: +```powershell +powershell -ExecutionPolicy Bypass -File scripts/setup-windows.ps1 +``` +See [docs/windows-build.md](docs/windows-build.md) for end-to-end VM +setup, the `just windows-build` / `just windows-dev` workflow, and the +ARM64/x64 cross-build matrix. + 4. Add required Rust targets for universal macOS builds: ```bash rustup target add aarch64-apple-darwin x86_64-apple-darwin @@ -244,6 +254,28 @@ The GitHub Actions workflow will automatically: If there's a new version of the enclave pushed to staging or prod, append the new PCR0 value to the `pcr0Values` or `pcr0DevValues` arrays in `frontend/src/app.tsx`. +## Windows Development + +After running `scripts/setup-windows.ps1` once, both build and dev are +single-command `just` recipes: + +```powershell +# Native ARM64 build (default): +just windows-build + +# Tauri dev server (Vite hot-reload): +just windows-dev + +# Cross-build to x64 from an ARM64 host: +just windows-build arm64_amd64 +``` + +The recipes wrap `vcvarsall.bat` so you don't need a Developer PowerShell +open, point `CARGO_TARGET_DIR` off the source tree (Parallels-safe), and +apply the `tauri.windows.conf.json` overlay (swaps `bun` for `npm` since +bun has no Windows ARM64 binary). Full guide: +[docs/windows-build.md](docs/windows-build.md). + ## iOS Development Run in emulator: diff --git a/docs/windows-build.md b/docs/windows-build.md new file mode 100644 index 00000000..b873f8d1 --- /dev/null +++ b/docs/windows-build.md @@ -0,0 +1,162 @@ +# Windows development + +End-to-end guide for building and running Maple on Windows 10/11 (x64 or +ARM64). Goal: clone → setup → first build in ~30 minutes on a clean VM. + +> Most ARM64 dev was done on Apple Silicon Parallels VMs (Win11 ARM64). +> Snapdragon X laptops use the same toolchain. Native x64 is also +> supported. + +## Prerequisites + +A fresh Win10/11 install needs three things: the right Visual Studio Build +Tools components, a Rust toolchain pinned to the same version CI uses, and +a `frontend/.env.local` with the backend URL. The bootstrap script handles +all of it. + +```powershell +# In repo root, from an elevated PowerShell (Administrator): +powershell -ExecutionPolicy Bypass -File scripts/setup-windows.ps1 +``` + +The script is idempotent — safe to re-run if something fails mid-way or you +want to verify state. + +What it installs: + +- **VS Build Tools 2022** with the exact components the Rust/Tauri chain + needs: `VC.Tools.x86.x64`, `VC.Tools.ARM64`, `VC.Llvm.Clang`, + `Windows11SDK.22621`. The Clang component is the one most likely to be + forgotten; `ring` 0.17 on `aarch64-pc-windows-msvc` needs clang for + ARM64 assembly and `cl.exe` alone can't compile it. +- **Node.js LTS** via winget (`bun` has no Windows ARM64 binary; the + `tauri.windows.conf.json` overlay swaps `bun` for `npm` automatically). +- **rustup** + Rust toolchain pinned to **1.95.0** (matches the CI pin in + `.github/workflows/desktop-build.yml`). Override with + `-RustToolchain 1.96.0` if you need a different version. +- **VC++ 2015+ Redistributable** for both x64 and ARM64 (rollup's native + module links against the ARM64 redist on ARM hosts). +- **LLVM/Clang** standalone as a backstop for the VS Clang component. +- **Git for Windows** — `git.exe` for the clone, plus Git Bash + (`bash.exe` + the bundled `curl` / `sha256sum` / `unzip` / `cygpath` + unix tools that `scripts/tauri-windows.ps1` and the ONNX Runtime helper + both rely on). +- **just** — the recipe runner for `just windows-build` / `just windows-dev`. +- A `frontend/.env.local` template pointing at the production enclave + (see [.env.local handling](#envlocal-handling) below). + +After setup, **open a new PowerShell** so PATH picks up the new tools. +Sanity check: + +```powershell +rustc --version # rustc 1.95.0 +node --version # v22.x.x or later (LTS) +cargo --version # cargo 1.95.0 +``` + +## First build + +The build dance (vcvarsall + cargo target dir + ORT setup) is wrapped in +two `just` recipes that hide all of it: + +```powershell +# Native ARM64 build (default; produces an ARM64 NSIS installer): +just windows-build + +# Cross-build to x64 from an ARM64 host: +just windows-build arm64_amd64 + +# Native x64 build: +just windows-build x64 +``` + +The recipe resolves to `scripts/tauri-windows.ps1`, which: + +1. Locates `vcvarsall.bat` via `vswhere`. +2. Sets `CARGO_TARGET_DIR` to `%USERPROFILE%\maple-cargo-target` — off the + source tree so Parallels shared-folder writes don't corrupt cargo + metadata. Override with `-CargoTargetDir`. +3. Runs `frontend/src-tauri/scripts/provide-windows-onnxruntime.sh` + through Git Bash to fetch + SHA-verify ONNX Runtime, then imports the + `ORT_LIB_LOCATION` / `ORT_SKIP_DOWNLOAD` / `ORT_DYLIB_PATH` env vars. + Mirrors how `desktop-build.yml` feeds those into `$GITHUB_ENV`. Skip + with `-SkipOrt` to fall back to the `ort` crate's auto-download. +4. Chains `vcvarsall.bat ` and the `tauri build` invocation in a + single `cmd /c` so the MSVC env survives into the build. +5. Applies the `tauri.windows.conf.json` overlay so `npm` replaces `bun` + in `beforeBuildCommand` / `beforeDevCommand`. + +The built `.exe` lands under `%USERPROFILE%\maple-cargo-target\release\bundle\nsis\`. + +## Iteration loop + +Use `just windows-dev` for the hot-reload loop — same arch handling, runs +`tauri dev` instead of `tauri build`. Vite reloads frontend changes +instantly; Rust changes trigger an incremental cargo rebuild. + +```powershell +just windows-dev +``` + +Major iteration unlock vs. rebuilding the full installer every change. + +## `.env.local` handling + +Vite reads `frontend/.env.local` at **build time** and bakes the values +into the bundle. A missing file produces a silent white-screen on launch +(captured for ~30 min of devtools debugging during PR 1 manual smoke). + +The bootstrap script creates `frontend/.env.local` automatically if absent. +For non-Windows hosts (or if you want a non-prod backend), copy the +template: + +```bash +cp frontend/.env.local.template frontend/.env.local +# edit if pointing at a non-prod backend +``` + +> Reminder: Vite bakes env vars at build time. **Restart the dev server +> after editing `.env.local`** or values won't propagate. + +## Gotchas + +| Gotcha | Why it bites | Fix | +|---|---|---| +| `bun` doesn't ship a Windows ARM64 binary | `beforeBuildCommand: "bun run build"` fails on ARM hosts | `tauri.windows.conf.json` overlay swaps in `npm` (auto-applied by `just windows-*`) | +| `ring` 0.17 build fails on `aarch64-pc-windows-msvc` with `cl.exe` errors | needs clang for ARM64 asm; `cl.exe` can't compile it | Install `VC.Llvm.Clang` component (setup script does it) | +| `aws-lc-sys` build fails | same root cause | same fix | +| Cargo target dir corruption on Parallels VM | shared-folder writes don't play with cargo metadata | `tauri-windows.ps1` points `CARGO_TARGET_DIR` at `%USERPROFILE%\maple-cargo-target` | +| Silent white-screen on launch | empty / missing `frontend/.env.local` | Copy `frontend/.env.local.template` | +| `just` errors with `Error parsing line: 'VITE_...'` at line index 0 | UTF-8 BOM at start of `frontend/.env.local` (PS 5.1's `Out-File` / `Set-Content` / `>` redirect and old Notepad all add one by default) | Re-run `scripts/setup-windows.ps1` — it strips the BOM in place; or save the file in VS Code (no BOM by default), or in PS 7+ use `Set-Content -Encoding utf8NoBOM` | +| `vcvarsall.bat arm64` vs `arm64_amd64` | wrong arch produces wrong-bitness binary that fails to load | Use `just windows-build arm64` for native, `arm64_amd64` to cross to x64 | +| `winget install --quiet --override` swallows exit codes for VS BuildTools modify | bootstrapper reports success even when components didn't land | Setup script uses `--passive` and verifies on disk via `vswhere` after install | + +## CI vs local parity + +Local `just windows-build` and the CI `build-windows` job +(`.github/workflows/desktop-build.yml` for master push, +`desktop-pr-build.yml` for PRs) share these pins: + +- Rust **1.95.0** (`-RustToolchain` param in setup script; + `dtolnay/rust-toolchain` action with `toolchain: 1.95.0` in CI) +- ONNX Runtime **1.22.0** with SHA-verified archive + DLL fetch via + `frontend/src-tauri/scripts/provide-windows-onnxruntime.sh` (same + helper for both) +- Tauri config: identical `tauri.conf.json` + Windows overlay applied via + `--config` + +CI builds an unsigned NSIS x64 installer. Local builds default to ARM64 +native; pass `x64` for x64-native parity with CI artifacts. + +## Code signing + +Out of scope for now. Tracked under PR 7 (MPLR-goufxxvn) — Authenticode +signing requires certificate provisioning and is gated on a code-signing +cert being purchased. + +## Native ARM64 shipping decision + +CI currently produces x64 artifacts only. ARM64 hosts run those under +Microsoft's x64 emulation (works, slower than native). Whether to ship a +native ARM64 build alongside x64 is tracked as a spike under +MPLR-xhfehqft. diff --git a/frontend/.env.local.template b/frontend/.env.local.template new file mode 100644 index 00000000..ace66c86 --- /dev/null +++ b/frontend/.env.local.template @@ -0,0 +1,18 @@ +# Copy this file to frontend/.env.local before running `just dev`, `just +# desktop-build`, `just windows-dev`, etc. The values below point at the +# production enclave -- override the URLs for a non-prod backend. +# +# Vite bakes these values into the bundle at BUILD time, not runtime -- +# rerun the dev server or rebuild after editing. A missing or empty +# VITE_OPEN_SECRET_API_URL produces a silent white-screen on launch. + +VITE_OPEN_SECRET_API_URL=https://enclave.trymaple.ai + +# Public OpenSecret project id. Optional; the app uses this value by default. +VITE_CLIENT_ID=ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 + +# Override if pointing at a non-prod billing backend. +# VITE_MAPLE_BILLING_API_URL=https://billing.opensecret.cloud + +# Force a specific model for dev. Leave commented to use the app's default. +# VITE_DEV_MODEL_OVERRIDE=gpt-4o diff --git a/frontend/src-tauri/.gitignore b/frontend/src-tauri/.gitignore index 0446195a..8557d64f 100644 --- a/frontend/src-tauri/.gitignore +++ b/frontend/src-tauri/.gitignore @@ -10,5 +10,9 @@ # ONNX Runtime Linux (downloaded for bundling) /onnxruntime-linux/ +# ONNX Runtime Windows (downloaded for bundling; arch-suffixed by +# provide-windows-onnxruntime.sh so x64 + arm64 can coexist). +/onnxruntime-windows-*/ + # Generated cargo config for iOS builds /.cargo/ diff --git a/frontend/src-tauri/scripts/onnxruntime-pins.sh b/frontend/src-tauri/scripts/onnxruntime-pins.sh index 176981e1..2a501a43 100644 --- a/frontend/src-tauri/scripts/onnxruntime-pins.sh +++ b/frontend/src-tauri/scripts/onnxruntime-pins.sh @@ -72,6 +72,30 @@ onnxruntime_windows_x64_dll_sha256_for_version() { esac } +onnxruntime_windows_arm64_archive_sha256_for_version() { + case "$1" in + 1.22.0) + printf '%s\n' "7008f7ff82f8e7de563a22f2b590e08e706a1289eba606b93de2b56edfb1e04b" + ;; + *) + echo "No pinned Windows arm64 ONNX Runtime archive SHA-256 for version '$1'." >&2 + return 1 + ;; + esac +} + +onnxruntime_windows_arm64_dll_sha256_for_version() { + case "$1" in + 1.22.0) + printf '%s\n' "79281671a386ed1baab9dbdbb09fe55f99577011472e9526cf9d0b468bb6bcc7" + ;; + *) + echo "No pinned Windows arm64 ONNX Runtime DLL SHA-256 for version '$1'." >&2 + return 1 + ;; + esac +} + onnxruntime_ios_commit_for_version() { case "$1" in 1.22.2) diff --git a/frontend/src-tauri/scripts/provide-windows-onnxruntime.sh b/frontend/src-tauri/scripts/provide-windows-onnxruntime.sh index 4fbf3142..b934d049 100755 --- a/frontend/src-tauri/scripts/provide-windows-onnxruntime.sh +++ b/frontend/src-tauri/scripts/provide-windows-onnxruntime.sh @@ -2,17 +2,30 @@ set -euo pipefail ORT_VERSION="${ORT_VERSION:-1.22.0}" +# Default x64 so CI (windows-latest, x64-only) keeps working unchanged. +# tauri-windows.ps1 sets this to 'arm64' when building for aarch64 hosts. +ORT_TARGET_ARCH="${ORT_TARGET_ARCH:-x64}" +case "${ORT_TARGET_ARCH}" in + x64|arm64) ;; + *) + echo "Unsupported ORT_TARGET_ARCH '${ORT_TARGET_ARCH}'. Use 'x64' or 'arm64'." >&2 + exit 1 + ;; +esac + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/onnxruntime-pins.sh" TAURI_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -ORT_ROOT="${TAURI_DIR}/onnxruntime-windows" -ORT_DIR="${ORT_ROOT}/onnxruntime-win-x64-${ORT_VERSION}" -ORT_ARCHIVE="onnxruntime-win-x64-${ORT_VERSION}.zip" +# Arch-suffixed root so a dev who toggles between native arm64 and arm64_amd64 +# cross-builds doesn't re-download every switch (and so both can coexist). +ORT_ROOT="${TAURI_DIR}/onnxruntime-windows-${ORT_TARGET_ARCH}" +ORT_DIR="${ORT_ROOT}/onnxruntime-win-${ORT_TARGET_ARCH}-${ORT_VERSION}" +ORT_ARCHIVE="onnxruntime-win-${ORT_TARGET_ARCH}-${ORT_VERSION}.zip" ORT_URL="https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/${ORT_ARCHIVE}" ORT_DLL="${ORT_DIR}/lib/onnxruntime.dll" -ORT_ARCHIVE_SHA256="$(onnxruntime_windows_x64_archive_sha256_for_version "${ORT_VERSION}")" -ORT_DLL_SHA256="$(onnxruntime_windows_x64_dll_sha256_for_version "${ORT_VERSION}")" +ORT_ARCHIVE_SHA256="$(onnxruntime_windows_${ORT_TARGET_ARCH}_archive_sha256_for_version "${ORT_VERSION}")" +ORT_DLL_SHA256="$(onnxruntime_windows_${ORT_TARGET_ARCH}_dll_sha256_for_version "${ORT_VERSION}")" sha256_file() { local path="$1" diff --git a/frontend/src-tauri/tauri.windows.conf.json b/frontend/src-tauri/tauri.windows.conf.json new file mode 100644 index 00000000..fd72bfdd --- /dev/null +++ b/frontend/src-tauri/tauri.windows.conf.json @@ -0,0 +1,7 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build" + } +} diff --git a/justfile b/justfile index 9bdecc78..268c5bd0 100644 --- a/justfile +++ b/justfile @@ -2,6 +2,11 @@ set dotenv-path := "frontend/.env.local" set dotenv-required := false +# On Windows, default to cmd.exe instead of `sh` (which isn't on PATH unless +# Git Bash's usr/bin is added). Recipes guarded by [windows] are single +# powershell invocations -- cmd /c is enough and avoids a PS-inside-PS hop. +set windows-shell := ["cmd.exe", "/c"] + # List available commands default: @just --list @@ -75,6 +80,19 @@ desktop-build-no-cc: desktop-build-debug-no-cc: cd frontend && unset CC && bun tauri build --debug +# Build Tauri desktop release for Windows. Wraps vcvarsall.bat + ONNX Runtime +# setup so you don't need a Developer Shell open. Pass arch (default arm64 +# native; arm64_amd64 for x64 cross-build from ARM host; x64 for native x64). +[windows] +windows-build arch="arm64": + powershell -ExecutionPolicy Bypass -File scripts/tauri-windows.ps1 -Command build -Arch {{arch}} + +# Tauri dev server for Windows (Vite hot-reload). Same vcvars/arch handling +# as windows-build. +[windows] +windows-dev arch="arm64": + powershell -ExecutionPolicy Bypass -File scripts/tauri-windows.ps1 -Command dev -Arch {{arch}} + # Format Rust code rust-fmt: cd frontend/src-tauri && cargo fmt diff --git a/scripts/setup-windows.ps1 b/scripts/setup-windows.ps1 new file mode 100644 index 00000000..d74210a0 --- /dev/null +++ b/scripts/setup-windows.ps1 @@ -0,0 +1,429 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Install Windows prerequisites for building Maple. + +.DESCRIPTION + Idempotent bootstrap for a fresh Win10/11 (x64 or ARM64) dev machine. + Installs Visual Studio Build Tools 2022 with the exact MSVC + Clang + + Windows SDK components the Rust/Tauri toolchain needs (clang-cl is required + by `ring` and `aws-lc-sys` on aarch64-pc-windows-msvc), plus Node.js LTS + (bun has no Win-ARM binary), rustup, the VC++ runtime redistributables, + standalone LLVM, Git for Windows (provides Git Bash, used by + scripts/tauri-windows.ps1 to invoke the ONNX Runtime helper and by the + helper itself for curl/sha256sum/unzip/cygpath), and just (justfile + runner for the `just windows-build` / `just windows-dev` recipes). + Detects a missing frontend/.env.local and writes a working template + pointing at the production enclave URL. + + Safe to re-run. Every step checks for prior installation first. + +.PARAMETER SkipVsBuildTools + Skip the Visual Studio Build Tools step. Use when full VS or VS Build Tools + is already installed and the required components have been verified by hand. + +.PARAMETER VsInstallerArgs + Extra args appended to vs_BuildTools.exe (e.g. '--quiet' for unattended + CI runs that don't need the installer UI -- note the epic-1 finding that + `--quiet` swallows exit codes on modify-existing-install paths, so the + default here uses `--passive` instead). + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File scripts/setup-windows.ps1 +#> +[CmdletBinding()] +param( + [switch]$SkipVsBuildTools, + [string[]]$VsInstallerArgs = @(), + # Keep in lockstep with the toolchain pinned in + # .github/workflows/desktop-build.yml (dtolnay/rust-toolchain) so local + # dev builds and CI builds use the same compiler. + [string]$RustToolchain = '1.95.0' +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Set-StrictMode -Version Latest + +# ---------- helpers ---------- +function Write-Section { param([string]$M) Write-Host ""; Write-Host "=== $M ===" -ForegroundColor Cyan } +function Write-Step { param([string]$M) Write-Host "[..] $M" -ForegroundColor Yellow } +function Write-Ok { param([string]$M) Write-Host "[OK] $M" -ForegroundColor Green } +function Write-Skip2 { param([string]$M) Write-Host "[--] $M" -ForegroundColor DarkGray } +function Write-Warn2 { param([string]$M) Write-Host "[!!] $M" -ForegroundColor Magenta } + +function Test-IsAdmin { + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $pr = New-Object System.Security.Principal.WindowsPrincipal($id) + return $pr.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-HostArch { + switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { return 'x64' } + 'ARM64' { return 'arm64' } + 'x86' { return 'x86' } + default { return $env:PROCESSOR_ARCHITECTURE } + } +} + +function Test-WingetAvailable { + return [bool](Get-Command winget -ErrorAction SilentlyContinue) +} + +function Test-WingetPackage { + param([Parameter(Mandatory)][string]$Id) + $null = & winget list --id $Id --exact --accept-source-agreements --disable-interactivity 2>$null + return ($LASTEXITCODE -eq 0) +} + +function Install-WingetPackage { + param( + [Parameter(Mandatory)][string]$Id, + [Parameter(Mandatory)][string]$Description + ) + Write-Step "winget: $Description ($Id)" + if (Test-WingetPackage -Id $Id) { + Write-Skip2 "$Description already installed" + return + } + & winget install --id $Id --exact ` + --accept-package-agreements --accept-source-agreements ` + --disable-interactivity --silent + if ($LASTEXITCODE -ne 0) { + throw "winget install failed for $Id (exit $LASTEXITCODE)" + } + Write-Ok "$Description installed" +} + +function Get-VsWherePath { + $candidate = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path $candidate) { return $candidate } + return $null +} + +function Test-VsComponent { + param([Parameter(Mandatory)][string]$Component) + $vsw = Get-VsWherePath + if (-not $vsw) { return $false } + $found = & $vsw -products * -requires $Component -property installationPath 2>$null + return -not [string]::IsNullOrWhiteSpace($found) +} + +function Get-VsBuildToolsInstallPath { + $vsw = Get-VsWherePath + if (-not $vsw) { return $null } + $path = & $vsw -products Microsoft.VisualStudio.Product.BuildTools ` + -property installationPath 2>$null | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($path)) { return $null } + return $path +} + +function Install-VsBuildTools { + param( + [Parameter(Mandatory)][string[]]$Components, + [string[]]$ExtraArgs = @() + ) + + $missing = @($Components | Where-Object { -not (Test-VsComponent $_) }) + if ($missing.Count -eq 0) { + Write-Skip2 'VS Build Tools components already present' + return + } + Write-Step ("VS Build Tools: {0} component(s) missing:" -f $missing.Count) + $missing | ForEach-Object { Write-Host " - $_" } + + $installer = Join-Path $env:TEMP 'vs_BuildTools.exe' + Write-Step 'Downloading vs_BuildTools.exe' + Invoke-WebRequest -UseBasicParsing ` + -Uri 'https://aka.ms/vs/17/release/vs_BuildTools.exe' ` + -OutFile $installer + + # NOTE: --passive (not --quiet). winget + `--quiet --override` for the + # BuildTools bootstrapper has been observed to swallow exit codes when + # adding components to an existing install. --passive shows a progress + # bar but still propagates exit codes. + # + # NOTE: build the command line as a single string with explicit quoting. + # Start-Process -ArgumentList in PowerShell 5.1 does NOT reliably + # quote elements containing spaces, so --installPath "C:\Program Files + # (x86)\..." would be sent through unquoted and the bootstrapper would + # parse the space-broken fragments as orphan args (exit 1, no useful + # message). Single-string ArgumentList + explicit quoting works. + $existingInstall = Get-VsBuildToolsInstallPath + $addParts = ($missing | ForEach-Object { "--add $_" }) -join ' ' + + if ($existingInstall) { + # Modify path: only --add the missing components. Don't include + # already-present ones or --includeRecommended (the bootstrapper has + # been observed to fail with exit 1 when handed redundant --add args + # on top of an existing install). + Write-Step "Modifying existing BuildTools install at: $existingInstall" + $cmdLine = 'modify --installPath "{0}" --wait --norestart --nocache --passive {1}' -f $existingInstall, $addParts + } else { + # Fresh install: --add every requested component; recommended bits OK. + Write-Step 'Installing BuildTools fresh' + $allAddParts = ($Components | ForEach-Object { "--add $_" }) -join ' ' + $cmdLine = '--wait --norestart --nocache --passive {0} --includeRecommended' -f $allAddParts + } + if ($ExtraArgs) { $cmdLine = "$cmdLine $($ExtraArgs -join ' ')" } + + Write-Step "vs_BuildTools.exe $cmdLine" + $proc = Start-Process -FilePath $installer -ArgumentList $cmdLine -Wait -PassThru + # 0 = success; 3010 = success, reboot required; 1602/1605 = user cancel/not installed + if (@(0, 3010) -notcontains $proc.ExitCode) { + $logGlob = Join-Path $env:TEMP 'dd_*.log' + Write-Warn2 "vs_BuildTools.exe failed (exit $($proc.ExitCode))." + Write-Warn2 "Inspect the latest installer log:" + Write-Warn2 " Get-ChildItem '$logGlob' | Sort-Object LastWriteTime -Desc | Select-Object -First 3" + throw "vs_BuildTools.exe failed (exit $($proc.ExitCode))" + } + + # Verify on disk -- the issue spec calls this out: "finished install" from + # the installer doesn't always mean the components landed. + $stillMissing = @($Components | Where-Object { -not (Test-VsComponent $_) }) + if ($stillMissing.Count -gt 0) { + Write-Warn2 'These components are still missing after install:' + $stillMissing | ForEach-Object { Write-Host " - $_" } + throw 'VS Build Tools install did not produce the required components.' + } + Write-Ok 'VS Build Tools components installed' +} + +function Add-CargoBinToPath { + # winget-installed rustup lives at %USERPROFILE%\.cargo\bin but PATH won't + # update in the current PowerShell session. Add it so rustup steps can run + # without forcing the user to open a new shell. + $cargoBin = Join-Path $env:USERPROFILE '.cargo\bin' + if (Test-Path (Join-Path $cargoBin 'rustup.exe')) { + if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $cargoBin })) { + $env:PATH = "$cargoBin;$env:PATH" + } + } +} + +function Set-RustToolchain { + param( + [Parameter(Mandatory)][string]$Toolchain, + [string]$ExtraTarget + ) + + Add-CargoBinToPath + if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) { + throw 'rustup not on PATH after install. Open a new PowerShell window and re-run this script.' + } + + $installedToolchains = @((& rustup toolchain list) -split "`r?`n" | Where-Object { $_ }) + if (-not ($installedToolchains | Where-Object { $_ -like "$Toolchain-*" })) { + Write-Step "rustup toolchain install $Toolchain" + & rustup toolchain install $Toolchain --profile minimal --no-self-update | Out-Null + if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install $Toolchain failed" } + } else { + Write-Skip2 "Toolchain $Toolchain already installed" + } + + Write-Step "rustup default $Toolchain" + & rustup default $Toolchain | Out-Null + if ($LASTEXITCODE -ne 0) { throw "rustup default $Toolchain failed" } + + if ($ExtraTarget) { + $installed = @((& rustup target list --installed) -split "`r?`n" | Where-Object { $_ }) + if ($installed -contains $ExtraTarget) { + Write-Skip2 "rustup target $ExtraTarget already installed" + } else { + Write-Step "rustup target add $ExtraTarget" + & rustup target add $ExtraTarget | Out-Null + if ($LASTEXITCODE -ne 0) { throw "rustup target add $ExtraTarget failed" } + Write-Ok "rustup target $ExtraTarget added" + } + } + Write-Ok "Rust toolchain $Toolchain ready" +} + +function Repair-EnvLocalBom { + # PowerShell 5.1's Out-File / Set-Content / > redirect (and old Notepad) + # default to UTF-8 *with* BOM. just's dotenv parser then fails to parse + # the file with a confusing error pointing at "line index: 0" -- it's the + # BOM bytes. Strip them in place if found; this never destroys content + # (BOM is metadata, not data) and unblocks the dev loop. + param([Parameter(Mandatory)][string]$Path) + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + Write-Step "Stripping UTF-8 BOM from $Path (just's dotenv parser chokes on BOM)" + $stripped = $bytes[3..($bytes.Length - 1)] + [System.IO.File]::WriteAllBytes($Path, $stripped) + Write-Ok 'BOM stripped' + } +} + +function Set-EnvLocalTemplate { + param([Parameter(Mandatory)][string]$RepoRoot) + $envLocal = Join-Path $RepoRoot 'frontend\.env.local' + if (Test-Path $envLocal) { + Repair-EnvLocalBom -Path $envLocal + Write-Skip2 'frontend/.env.local already exists' + return + } + Write-Step 'Writing frontend/.env.local template (prod enclave URL)' + $content = @' +# Maple local dev env. Vite bakes these values into the bundle at BUILD time, +# not runtime -- rerun the dev server or rebuild after editing. A missing or +# empty VITE_OPEN_SECRET_API_URL produces a silent white-screen on launch. +VITE_OPEN_SECRET_API_URL=https://enclave.trymaple.ai +VITE_CLIENT_ID=ba5a14b5-d915-47b1-b7b1-afda52bc5fc6 +# VITE_MAPLE_BILLING_API_URL=https://billing.opensecret.cloud +# VITE_DEV_MODEL_OVERRIDE=gpt-4o +'@ + # Write UTF-8 without BOM -- Vite chokes on a BOM in .env files. + [System.IO.File]::WriteAllText( + $envLocal, + $content, + (New-Object System.Text.UTF8Encoding($false)) + ) + Write-Ok "Created frontend/.env.local (edit to point at a non-prod backend)" +} + +# ---------- preflight ---------- +Write-Section 'Preflight' +$hostArch = Get-HostArch +Write-Host "Host arch: $hostArch" +Write-Host "PowerShell: $($PSVersionTable.PSVersion)" +try { + $osCaption = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).Caption + Write-Host "OS: $osCaption" +} catch { + Write-Host "OS: (unknown -- Get-CimInstance failed)" +} + +if (-not (Test-IsAdmin)) { + Write-Warn2 'Not running as Administrator. VS Build Tools install will UAC-prompt.' +} + +if (-not (Test-WingetAvailable)) { + throw "winget not found. Install 'App Installer' from the Microsoft Store, then re-run." +} + +# npm.ps1 (and many other tool shims) are blocked under the default Restricted +# / AllSigned execution policy. Relax to RemoteSigned for the current user -- +# but only if the EFFECTIVE policy (across all scopes) isn't already permissive. +# Common case: script invoked with `powershell -ExecutionPolicy Bypass` sets the +# Process scope to Bypass, which overrides CurrentUser. Naively setting +# CurrentUser anyway emits a non-terminating "overridden by a more specific +# scope" error that $ErrorActionPreference='Stop' turns into a script-killer. +$permissive = @('RemoteSigned', 'Unrestricted', 'Bypass') +$effective = Get-ExecutionPolicy +if ($permissive -contains $effective) { + Write-Skip2 "ExecutionPolicy already permissive (effective: $effective)" +} else { + $userEp = Get-ExecutionPolicy -Scope CurrentUser + Write-Step "Setting CurrentUser ExecutionPolicy: $userEp -> RemoteSigned" + try { + Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop + Write-Ok 'ExecutionPolicy updated' + } catch { + # Group policy or another scope can block the set. Effective policy is + # what matters at runtime; warn and continue rather than abort the whole + # bootstrap over a shim-permission concern. + Write-Warn2 "Could not update CurrentUser ExecutionPolicy ($($_.Exception.Message.Trim())). Continuing -- if npm/just shims later fail with a policy error, re-run this script from an elevated shell or set the policy manually." + } +} + +# ---------- winget packages ---------- +Write-Section 'winget packages' + +$packages = @( + @{ Id = 'Microsoft.VCRedist.2015+.x64'; Desc = 'VC++ 2015+ Redistributable (x64)' } +) +if ($hostArch -eq 'arm64') { + # rollup's native module ships a prebuilt for ARM64 that links against the + # ARM64 VC++ redistributable. x64-only redist is not enough on ARM hosts. + $packages += @{ Id = 'Microsoft.VCRedist.2015+.arm64'; Desc = 'VC++ 2015+ Redistributable (ARM64)' } +} +$packages += @{ Id = 'OpenJS.NodeJS.LTS'; Desc = 'Node.js LTS (bun has no Win-ARM binary)' } +$packages += @{ Id = 'Rustlang.Rustup'; Desc = 'rustup (Rust toolchain manager)' } +# LLVM.LLVM is a backstop: the VS clang-cl component lives under the VS install +# and only resolves via Developer PowerShell / vcvars; this gives clang on PATH +# in any shell. +$packages += @{ Id = 'LLVM.LLVM'; Desc = 'LLVM / Clang (standalone)' } +# Git for Windows pulls in `git.exe`, Git Bash (`bash.exe`), and the bundled +# unix tools (curl, sha256sum, unzip, awk, cygpath) that +# scripts/tauri-windows.ps1 and provide-windows-onnxruntime.sh both rely on. +$packages += @{ Id = 'Git.Git'; Desc = 'Git for Windows (provides git + Git Bash)' } +# just is the recipe runner the README + docs/windows-build.md document for +# `just windows-build` / `just windows-dev`. Without it the wrappers can only +# be invoked via the underlying PowerShell script directly. +$packages += @{ Id = 'Casey.Just'; Desc = 'just (justfile runner)' } + +foreach ($p in $packages) { Install-WingetPackage -Id $p.Id -Description $p.Desc } + +# ---------- VS Build Tools ---------- +Write-Section 'Visual Studio Build Tools 2022' + +# The component IDs below were validated against PR 1's manual Windows smoke. +# - VC.Llvm.Clang is the one most likely to be forgotten; `ring` 0.17 on +# aarch64-pc-windows-msvc needs clang for ARM64 asm. +# - VC.Tools.ARM64 is required on ARM hosts and harmless elsewhere; the +# VCTools workload alone installs x64 cross-compilers only. +$vsComponents = @( + 'Microsoft.VisualStudio.Workload.VCTools', + 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', + 'Microsoft.VisualStudio.Component.VC.Tools.ARM64', + 'Microsoft.VisualStudio.Component.VC.Llvm.Clang', + 'Microsoft.VisualStudio.Component.Windows11SDK.22621' +) + +if ($SkipVsBuildTools) { + Write-Skip2 'VS Build Tools step skipped (-SkipVsBuildTools)' +} else { + Install-VsBuildTools -Components $vsComponents -ExtraArgs $VsInstallerArgs +} + +# ---------- Rust toolchain ---------- +Write-Section 'Rust toolchain' +# On ARM64 hosts, default host target is aarch64-pc-windows-msvc; add x64 so +# the same machine can also cross-compile the x86_64 Tauri bundle. +$extraTarget = if ($hostArch -eq 'arm64') { 'x86_64-pc-windows-msvc' } else { $null } +try { + Set-RustToolchain -Toolchain $RustToolchain -ExtraTarget $extraTarget +} catch { + Write-Warn2 $_.Exception.Message + Write-Warn2 'Skipping Rust step. Open a new PowerShell window so PATH picks up rustup, then re-run.' +} + +# ---------- .env.local template ---------- +Write-Section 'frontend/.env.local' +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-EnvLocalTemplate -RepoRoot $repoRoot + +# ---------- summary ---------- +Write-Section 'Next steps' +@' + +Setup complete. Close this PowerShell window and open a fresh one so PATH +picks up the freshly-installed git, just, and the updated Rust default. + +Then from the repo root, build / dev via the just recipes (they wrap +vcvarsall, the ONNX Runtime helper, and the tauri.windows.conf.json +overlay -- no need to open Developer PowerShell first): + + just windows-dev # Tauri dev server (Vite hot-reload) + just windows-build # native ARM64 release (default) + just windows-build x64 # native x64 + just windows-build arm64_amd64 # x64 cross-build from ARM host + +Sanity checks first: + rustc --version # rustc 1.95.0 + node --version # v22.x.x or later + just --list # should include windows-build / windows-dev + +Edit frontend/.env.local if you need a non-prod backend. Vite bakes env +values at build time -- restart the dev server after edits. Save as +UTF-8 *without* BOM; PowerShell 5.1's Out-File / Set-Content / > redirect +all add a BOM by default and just's dotenv parser fails on it. Use VS Code +(no BOM by default), or in PS 7+ use `Set-Content -Encoding utf8NoBOM`. +Re-running this script will strip a BOM from an existing .env.local in +place if one is found. + +Full guide: docs/windows-build.md + +'@ | Write-Host diff --git a/scripts/tauri-windows.ps1 b/scripts/tauri-windows.ps1 new file mode 100644 index 00000000..6ad9ba08 --- /dev/null +++ b/scripts/tauri-windows.ps1 @@ -0,0 +1,179 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + One-command wrapper for `tauri build` / `tauri dev` on Windows. + +.DESCRIPTION + Handles the vcvarsall.bat + ORT env-var dance so the developer doesn't + need a Developer PowerShell open, doesn't need to remember the ORT helper, + and doesn't blow up on cargo target dir when the repo lives on a Parallels + shared folder. + + Steps: + 1. Locate VS Build Tools install via vswhere. + 2. Point CARGO_TARGET_DIR at %USERPROFILE%\maple-cargo-target (overridable + via -CargoTargetDir or $env:CARGO_TARGET_DIR) -- Parallels shared-folder + writes don't play with cargo's default target directory. + 3. Source frontend/src-tauri/scripts/provide-windows-onnxruntime.sh via Git + Bash to fetch + SHA-verify ONNX Runtime, then import the script's + ORT_LIB_LOCATION / ORT_SKIP_DOWNLOAD / ORT_DYLIB_PATH outputs as + process env vars (mirrors how desktop-build.yml + desktop-pr-build.yml + feed those into $GITHUB_ENV). + 4. cmd /c chain vcvarsall.bat with the tauri command, applying the + frontend/src-tauri/tauri.windows.conf.json overlay so the Windows-only + knobs (bun -> npm for beforeBuildCommand / beforeDevCommand) take + effect. + +.PARAMETER Command + 'build' or 'dev'. + +.PARAMETER Arch + vcvarsall arch argument. Common values: + arm64 -- native ARM64 build on ARM64 host (default) + arm64_amd64 -- cross-build to x64 from ARM64 host + x64 -- native x64 build on x64 host + +.PARAMETER CargoTargetDir + Override CARGO_TARGET_DIR. Defaults to %USERPROFILE%\maple-cargo-target. + +.PARAMETER SkipOrt + Skip the ONNX Runtime setup step. The ort crate will fall back to its + auto-download (slower, unpinned) on the first build. + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File scripts/tauri-windows.ps1 -Command build -Arch arm64 + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File scripts/tauri-windows.ps1 -Command dev -Arch arm64_amd64 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('build', 'dev')][string]$Command, + [string]$Arch = 'arm64', + [string]$CargoTargetDir = (Join-Path $env:USERPROFILE 'maple-cargo-target'), + [switch]$SkipOrt +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Set-StrictMode -Version Latest + +function Write-Section { param([string]$M) Write-Host ""; Write-Host "=== $M ===" -ForegroundColor Cyan } +function Write-Step { param([string]$M) Write-Host "[..] $M" -ForegroundColor Yellow } +function Write-Ok { param([string]$M) Write-Host "[OK] $M" -ForegroundColor Green } + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$FrontendDir = Join-Path $RepoRoot 'frontend' +$OrtScript = Join-Path $RepoRoot 'frontend\src-tauri\scripts\provide-windows-onnxruntime.sh' +$WinOverlay = 'src-tauri/tauri.windows.conf.json' # path is relative to frontend/ (tauri --config base) + +# ---------- vcvarsall.bat ---------- +Write-Section "Locating Visual Studio Build Tools" +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +if (-not (Test-Path $vswhere)) { + throw "vswhere.exe not found at $vswhere. Run scripts/setup-windows.ps1 to install VS Build Tools." +} +$vsInstallPath = (& $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Workload.VCTools ` + -property installationPath 2>$null | Select-Object -First 1) +if ([string]::IsNullOrWhiteSpace($vsInstallPath)) { + throw "No VS install with VCTools workload found. Run scripts/setup-windows.ps1." +} +$vcvarsall = Join-Path $vsInstallPath 'VC\Auxiliary\Build\vcvarsall.bat' +if (-not (Test-Path $vcvarsall)) { + throw "vcvarsall.bat not found under $vsInstallPath." +} +Write-Ok "vcvarsall.bat: $vcvarsall" + +# ---------- CARGO_TARGET_DIR ---------- +if (-not (Test-Path $CargoTargetDir)) { + New-Item -ItemType Directory -Path $CargoTargetDir -Force | Out-Null +} +$env:CARGO_TARGET_DIR = $CargoTargetDir +Write-Ok "CARGO_TARGET_DIR=$CargoTargetDir" + +# ---------- ONNX Runtime ---------- +if ($SkipOrt) { + Write-Step 'Skipping ONNX Runtime setup (-SkipOrt). The ort crate will auto-download.' +} else { + Write-Section "Provisioning ONNX Runtime" + $bashCandidates = @( + (Join-Path $env:ProgramFiles 'Git\bin\bash.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'Git\bin\bash.exe') + ) + $bashExe = $bashCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $bashExe) { + throw "Git Bash not found (tried: $($bashCandidates -join ', ')). Install Git for Windows, or re-run with -SkipOrt to use the ort crate's auto-download." + } + if (-not (Test-Path $OrtScript)) { + throw "ORT helper not found at $OrtScript." + } + # Map vcvarsall arch (host[_target]) to the *final binary* target arch + # the ort crate needs to link against. arm64_amd64 = arm64 host cross to + # amd64, so target is x64; amd64_arm64 = amd64 host cross to arm64. + $ortTargetArch = if ($Arch -match '_amd64$' -or $Arch -in @('x64', 'amd64')) { + 'x64' + } elseif ($Arch -match '_arm64$' -or $Arch -eq 'arm64') { + 'arm64' + } else { + Write-Warning "Could not map vcvarsall arch '$Arch' to an ORT target; defaulting to x64. Pass an explicit -Arch if this is wrong." + 'x64' + } + Write-Step "ORT_TARGET_ARCH=$ortTargetArch (derived from -Arch $Arch)" + Write-Step "bash $OrtScript" + $ortEnvLines = & $bashExe -c "ORT_TARGET_ARCH=$ortTargetArch ./frontend/src-tauri/scripts/provide-windows-onnxruntime.sh" + if ($LASTEXITCODE -ne 0) { + throw "provide-windows-onnxruntime.sh failed (exit $LASTEXITCODE)." + } + foreach ($line in $ortEnvLines) { + if ($line -match '^([A-Z0-9_]+)=(.+)$') { + [System.Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') + Write-Ok "$($Matches[1])=$($Matches[2])" + } + } +} + +# ---------- npm dependencies ---------- +# Tauri's beforeDevCommand / beforeBuildCommand run `npm run dev` / `npm run +# build`, but neither tauri nor those scripts install npm deps. On a fresh +# clone `npx tauri` fails with "could not determine executable to run" because +# @tauri-apps/cli isn't in node_modules. Bootstrap if missing. +$NodeModules = Join-Path $FrontendDir 'node_modules' +if (-not (Test-Path $NodeModules)) { + Write-Section "Installing npm dependencies (first run)" + Push-Location $FrontendDir + try { + & npm install + if ($LASTEXITCODE -ne 0) { + throw "npm install failed (exit $LASTEXITCODE)." + } + } finally { + Pop-Location + } + Write-Ok 'npm install complete' +} else { + Write-Step "Skipping npm install (frontend/node_modules exists; run 'npm install' in frontend/ manually if package.json changed)" +} + +# ---------- tauri command ---------- +Write-Section "Running tauri $Command ($Arch)" +$tauriCmd = switch ($Command) { + 'build' { "npx tauri build --config $WinOverlay" } + 'dev' { "npx tauri dev --config $WinOverlay" } +} + +# vcvarsall sets MSVC env (INCLUDE / LIB / PATH bits) inside the cmd.exe that +# invokes it; those vars don't survive back into PowerShell. Chain everything +# in one cmd /c so the tauri build inherits the vcvars-set environment. +$chained = '"' + $vcvarsall + '" ' + $Arch + ' && ' + $tauriCmd +Write-Step "cmd /c $chained" + +Push-Location $FrontendDir +try { + & cmd /c $chained + $exit = $LASTEXITCODE +} finally { + Pop-Location +} + +exit $exit