# Windows packaging (W4) A single per-user installer — `MeshBay-Setup-.exe` — that lays down the Electron **client** and the Python **node** daemon. No hub (server-only, stays Linux). `meshbay-common` rides along inside the node runtime. ## What the installer contains ``` %LOCALAPPDATA%\Programs\MeshBay\ (productName, not the npm package name) ├─ MeshBay.exe Electron client ├─ resources\ │ ├─ app.asar src/ + ui/ (the interface ships in the package) │ ├─ firewall.ps1 adds/removes the four inbound rules (see below) │ ├─ service.ps1 install/remove/status/run/end the boot-time task │ ├─ service-mode.ps1 elevated helper: service.ps1 + firewall.ps1 in one UAC prompt │ └─ node-runtime\ │ ├─ meshbay-node.exe frozen daemon (PyInstaller onedir) │ ├─ _internal\ … its Python + deps (aiortc, av, aioquic, …) │ └─ ffmpeg.exe, ffprobe.exe only if built with -FfmpegDir └─ Uninstall MeshBay.exe ``` Runtime data stays where the node already puts it: `%LOCALAPPDATA%\meshbay\` (`node.toml`, `keystore.enc`, `unlock.key`, `data\`). The installer never writes there and the uninstaller never deletes it — installers place files, not secrets. That is also why service mode needed no code changes to `platform.py`: it runs as this same signed-in user (S4U, see below), so it is the same profile either way — not LocalSystem/NetworkService, which would have none of this. `build/installer.nsh` also adds `…\resources\node-runtime` to the **per-user** `Path` (`HKCU\Environment`) so `meshbay-node` works in a terminal, and takes it back out on uninstall. New shells only — a `WM_SETTINGCHANGE` broadcast nudges open ones. It uses stock `WordFunc.nsh` (the `EnVar` plugin is not in electron-builder's NSIS bundle). ## Autostart: two modes, one choice at install time **Per-user (default, no admin).** A `.vbs` in the Startup folder (`meshbay_node.platform._startup_vbs`), toggled from the Node page or `meshbay-node autostart install|remove`. Starts when *this user* signs in. **Service mode (one admin confirmation, at install time only).** A Scheduled Task, `meshbay_node.platform.service_install` / `packaging/win/service.ps1`, that starts **at boot, before anyone signs in**. A real Windows Service would run under LocalSystem/NetworkService — accounts with no normal user profile, so `%LOCALAPPDATA%\meshbay\` (config, keystore, data) would not exist for it. Relocating storage to make that work is real surgery, deliberately not this. The alternative used instead: `schtasks /create ... /ru /rp ""` with no `/it` registers an **S4U** (Service For User) logon — no password stored anywhere, and unlike LocalSystem it loads *this account's own profile*, so `%LOCALAPPDATA%\meshbay\` keeps working with zero code changes. The cost: S4U carries no network credential (cannot reach a domain share as this user), which the node never needed — everything it touches is local disk plus outbound internet. Creating the task needs admin (a boot trigger touches system-wide scheduler state — the same reason `/sc onlogon` needed it too, back when Task Scheduler was tried for the per-user mode and abandoned for exactly that reason). Querying, starting and stopping an *already-created* task does not — Task Scheduler grants the owning user that much itself, which is what lets the Node page's Start/Stop/Restart drive it with no further UAC prompts (`src/main.js`'s `winServiceTaskStatus/Run/End`, mirroring `service.ps1`). **One elevation, not two.** Choosing service mode needs admin for both the Scheduled Task *and* the firewall rules; `service-mode.ps1` runs both from a single `ExecShellWait "runas"` in `installer.nsh`, so the choice costs exactly one UAC prompt. Re-running setup (an upgrade, a repair install) asks nothing if the firewall rules are already there — checked first, unelevated, the same pattern the per-user-only firewall step already used. ## Build On a Windows machine with **Node ≥ 22**, **Python ≥ 3.12** (`py -3.12`) and **git**: ```powershell cd packages\meshbay-client npm run dist:win ``` That runs [`build-win.ps1`](build-win.ps1): | Step | | |---|---| | 1 | Node ≥ 22 check | | 2 | `npm ci` + download Electron's Chromium | | 3 | bump Electron to the latest release (Chromium CVE policy; `-NoElectronBump` to skip) | | 4 | `npm run sync-ui` — copy the interface from `meshbay-hub/.../static` | | 5 | [`build-node-runtime.ps1`](build-node-runtime.ps1) — PyInstaller freeze → `packages/meshbay-client/node-runtime/` | | 6 | `electron-builder --win nsis` → `packages/meshbay-client/dist/MeshBay-Setup-.exe` | ### ffmpeg Not bundled by default — the node resolves `ffmpeg`/`ffprobe` from `PATH` at startup, and video streaming needs them. To make the installer self-contained: ```powershell npm run dist:win -- -FfmpegDir "C:\path\to\ffmpeg\bin" # or: $env:MESHBAY_FFMPEG_DIR = "C:\path\to\ffmpeg\bin"; npm run dist:win ``` They are copied beside `meshbay-node.exe`, which is on the daemon's search path. ### Iterating ```powershell # rebuild only the installer, reuse an existing node-runtime\ powershell -File ..\..\packaging\win\build-win.ps1 -SkipNodeRuntime # build just the frozen daemon, into an existing venv, keep it for next time powershell -File ..\..\packaging\win\build-node-runtime.ps1 -Python C:\path\python.exe -KeepBuildVenv ``` ## Why PyInstaller and not the python-embed zip The frozen `meshbay-node.exe` is a genuine relocatable single binary. It is what the client spawns (`findNodeBinary` in `src/main.js`) and what the W3 autostart launcher points at (`meshbay_node.platform._node_exe`). The python.org embeddable zip would need pip to make a `meshbay-node.exe` wrapper, and that wrapper bakes in an **absolute** interpreter path — it stops working the moment the tree is installed somewhere other than where it was built. ## Networking (running the node) The node's admin API is loopback-only, but its **transport is not**. WebRTC binds an ephemeral UDP port per connection and — because browsers/Electron publish their host candidate as an unresolvable `.local` mDNS name that `aioice` discards — the browser always dials the node, never the reverse. So the node has to accept unsolicited inbound UDP from its peers. **Windows Defender Firewall.** The setup wizard offers to add four inbound rules in one step — it needs one admin confirmation (`build/installer.nsh` runs `firewall.ps1` via NSIS `ExecShellWait "runas"`; the per-user install itself never elevates): | Rule | Program | Protocol / port | For | |---|---|---|---| | `MeshBay` | `MeshBay.exe` | UDP, any port | WebRTC (ICE checks touch the client too) | | `MeshBay Node` | `meshbay-node.exe` | UDP, any port | WebRTC (the node — unsolicited inbound, see above) | | `MeshBay Cast` | `MeshBay.exe` | TCP 19550–19553 | LAN cast HTTP relay (`src/cast-relay.js`) | | `MeshBay Cast Discovery` | `MeshBay.exe` | UDP 5353 | Chromecast/Smart TV mDNS discovery (`bonjour-service`) | The WebRTC rules have no port restriction because there is no fixed port to name — aiortc binds a fresh one per connection — so they are scoped by *program* instead, which is the Windows-native answer to the same problem the Linux packaging solves with a broad `1024-65535/udp` range (`packaging/firewall/`). The cast rules are scoped by program *and* port, since those two are fixed and known — matching `packaging/firewall/*/meshbay-cast.xml` exactly. Setup checks first, **unelevated** (`firewall.ps1 check` — reading rules needs no admin, only creating them does), so running the installer again — an upgrade, a repair install — asks nothing and never pops UAC a second time once the rules are in place. Say yes the first time and every prompt you'd otherwise hit mid-use — connecting, or the first cast — is gone. Say no, or the UAC prompt is dismissed, and Windows falls back to its own **"Allow access"** dialog the first time each program/port combination is used — tick **both Private and Public** then (a libvirt/VM adapter, and sometimes a plain Ethernet one, registers as Public; a Private-only rule silently drops every peer). Missed one? Add it by hand: *Windows Defender Firewall → Advanced → Inbound Rules → New Rule → Program →* the exe *→ Allow → all profiles*, restricting to the port above if it is a cast rule. `firewall.ps1` is idempotent and re-runnable (`powershell -File resources\firewall.ps1 add`, elevated); it logs to `%TEMP%\meshbay-firewall.log`. **A flat LAN needs nothing else.** The node offers a routable `192.168.x.y` host candidate and browsers on the same subnet connect straight to it — same as the residential-NAT cases that work without TURN. **A routed or multi-subnet LAN** (peers on `192.168.1.x` reaching a node on `192.168.2.x`, or a VM) additionally needs: - routing between the subnets (the two ends must have a path to each other's host-candidate address); - inbound UDP on the ephemeral range allowed for `meshbay-node.exe` **and** for any Linux host in the path — see the packaged `packaging/firewall/` profiles and the "inbound WebRTC" section of `docs/PACKAGING-GUIDE.md`; - `MESHBAY_WEBRTC_EXPOSE_LOCAL_IPS` is not relevant to the node — that switch is the *client's*, and it is already on by default (`src/main.js`). **libvirt NAT is the awkward case.** A guest on the default NAT network is reachable from its own hypervisor but not from other LAN machines, and its only host candidate is the `192.168.122.x`/`192.168.200.x` address no third machine can route to. Give the guest bridged (or macvtap) networking so it gets a real LAN address, or run the node on the host. The node log now prints the host addresses it offered — `WebRTC answer ready for peer=… (… host: 192.168.200.173, 1 srflx)` — so "did the node even offer something routable" is answerable from the journal. ## Dependency surface that needs watching `meshbay-node.spec` pulls the awkward packages in whole (`collect_all`) because they have C/Rust extensions or do dynamic imports: `aiortc`, `av` (bundles FFmpeg DLLs), `aioquic`, `pydantic_core`, `uvicorn`, `watchdog`, `guessit`, `blake3`, `zstandard`, `msgpack`. If a frozen run raises `ModuleNotFoundError`, add the package to `COLLECT_ALL` / `HIDDEN` / `COPY_META` in the spec — that is the expected way the list grows. ## Not done - **Authenticode signing** — Phase 13.9. Unsigned installer triggers SmartScreen. - **CI** — no Windows runner builds this yet (Phase 18.3). Build is manual. - **Delta updates / auto-update** — `electron-updater` is not wired.