summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-04 09:28:14 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-04 09:28:14 +0200
commit3ce52774760b222d94d78bc0118e9da2662a809f (patch)
tree52a16c7e05080869fbb17b83665b2fb2048b21e2
parent220e6e701806213a576ce80fa655cd9cf4a51880 (diff)
downloadmeshbay-3ce52774760b222d94d78bc0118e9da2662a809f.tar.gz
feat: Windows installer (W4) — one per-user NSIS package, client + node
`npm run dist:win` produces MeshBay-Setup-<version>.exe: the Electron client and, beside it under resources/node-runtime/, the frozen meshbay-node daemon (meshbay-common inside it). No hub. Per-user, no elevation — matches the W3 constraint that a logon-triggered scheduled task needs admin. electron-builder / package.json build.win nsis, build/icon.ico, extraResources -> node-runtime/ build.nsis oneClick:false perMachine:false allowElevation:false allowToChangeInstallationDirectory:true dist:win -> packaging/win/build-win.ps1 (mirrors dist -> build-client.sh) packaging/win/ meshbay-node.spec + node-entry.py PyInstaller freeze of meshbay_node.daemon:main. The awkward deps (aiortc, av, aioquic, pydantic_core, uvicorn, watchdog, guessit, blake3, tzdata) are pulled in whole with collect_all — that list is expected to grow when a frozen run raises ModuleNotFoundError. build-node-runtime.ps1 throwaway venv -> pip install -> PyInstaller -> packages/meshbay-client/node-runtime/ (gitignored) build-win.ps1 Node>=22 check, npm ci, Electron bump, sync-ui, node runtime, electron-builder --win nsis bump-electron.mjs the Chromium-CVE "build against latest Electron" policy, out of the PS script (5.1 here-string terminator rules) README.md PyInstaller, not the python-embed zip: the frozen meshbay-node.exe is a genuine relocatable single binary, which is what src/main.js:findNodeBinary spawns (process.resourcesPath/node-runtime/meshbay-node.exe when packaged) and what the W3 autostart launcher points at. The embeddable zip needs pip to make that wrapper and the wrapper bakes in an absolute interpreter path. build/installer.nsh: on uninstall, taskkill meshbay-node.exe and delete the W3 Startup .vbs (it would point wscript at a deleted binary every sign-in). %LOCALAPPDATA%\meshbay\ — node.toml, keystore.enc — is never touched. ffmpeg is not bundled by default (node finds it on PATH); build-win.ps1 -FfmpegDir copies ffmpeg.exe/ffprobe.exe in for a self-contained installer. Verified on the Windows guest: PyInstaller freeze builds first try (node-runtime 147 MB), frozen `meshbay-node status` talks to the live daemon's loopback API; electron-builder --win nsis produces MeshBay-Setup-0.1.0.exe (155 MB), oneClick/perMachine flags applied, node-runtime bundled at the path findNodeBinary expects. test_packaging_win.py (14) pins the config invariants and the NSIS <-> platform.py autostart seam. Node suite 798 pass / 34 skip. Open: Authenticode signing (13.9 — unsigned => SmartScreen), Windows CI (18.3), electron-updater. First clean-machine install + DPAPI + autostart round-trip is a manual check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--.gitignore4
-rw-r--r--docs/PACKAGING-GUIDE.md45
-rw-r--r--docs/WINDOWS-PORT.md92
-rw-r--r--packages/meshbay-client/build/icon.icobin0 -> 117250 bytes
-rw-r--r--packages/meshbay-client/build/installer.nsh24
-rw-r--r--packages/meshbay-client/package.json27
-rw-r--r--packages/meshbay-client/src/main.js10
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py153
-rw-r--r--packaging/win/README.md90
-rw-r--r--packaging/win/build-node-runtime.ps1138
-rw-r--r--packaging/win/build-win.ps1117
-rw-r--r--packaging/win/bump-electron.mjs43
-rw-r--r--packaging/win/meshbay-node.spec126
-rw-r--r--packaging/win/node-entry.py12
14 files changed, 838 insertions, 43 deletions
diff --git a/.gitignore b/.gitignore
index f08b5c8..90d2536 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,6 +59,10 @@ poc/bob_state.json
poc/testfile.bin
packages/meshbay-client/ui/
packages/meshbay-client/node_modules/
+# The bundled Python node daemon (PyInstaller output), assembled by
+# packaging/win/build-node-runtime.ps1 and carried into the installer as an
+# electron-builder extraResource. Build artifact, never committed.
+packages/meshbay-client/node-runtime/
# electron-builder's buildResources dir — a real source (app icon), not a
# build artifact, despite living under a path the generic `build/` rule above
diff --git a/docs/PACKAGING-GUIDE.md b/docs/PACKAGING-GUIDE.md
index 0f9997d..5a5c388 100644
--- a/docs/PACKAGING-GUIDE.md
+++ b/docs/PACKAGING-GUIDE.md
@@ -59,6 +59,51 @@ sudo rpm -e meshbay-client meshbay-hub meshbay-node meshbay-common
---
+## Windows
+
+One installer, **`MeshBay-Setup-<version>.exe`**, carries the client **and** the
+node (with `meshbay-common` inside it). There is no Windows hub.
+
+### Install
+
+Run the installer. It is **per-user** — no administrator prompt — and lands in
+`%LOCALAPPDATA%\Programs\meshbay-client\`. The node daemon ships beside the app
+at `resources\node-runtime\meshbay-node.exe`; the client finds it automatically.
+
+**ffmpeg** is required for video streaming and is *not* in the installer unless
+it was built with `-FfmpegDir`. Otherwise install it separately
+(`winget install ffmpeg`) so the node finds it on `PATH`.
+
+### First run
+
+Open MeshBay and sign in. Use the **Node** page (or a terminal) to provision:
+
+```
+meshbay-node init
+meshbay-node autostart install # run the daemon at every sign-in (no admin)
+```
+
+Runtime data — `node.toml`, `keystore.enc`, `unlock.key`, `data\` — lives in
+`%LOCALAPPDATA%\meshbay\` and **survives uninstall/reinstall**.
+
+### Uninstall
+
+*Apps & features → MeshBay → Uninstall*, or the Start-menu *Uninstall MeshBay*
+entry. It stops a running daemon and removes the sign-in launcher; it does not
+touch `%LOCALAPPDATA%\meshbay\` (the keystore).
+
+### Build from source
+
+See [`packaging/win/README.md`](../packaging/win/README.md). On a machine with
+Node ≥ 22 and Python ≥ 3.12:
+
+```powershell
+cd packages\meshbay-client
+npm run dist:win
+```
+
+---
+
## Post-install: Node (desktop user)
### 1. Initialize
diff --git a/docs/WINDOWS-PORT.md b/docs/WINDOWS-PORT.md
index 9b3caee..239dbd1 100644
--- a/docs/WINDOWS-PORT.md
+++ b/docs/WINDOWS-PORT.md
@@ -1,10 +1,17 @@
# MeshBay — Windows Port
-> Status: **audit complete, work not started.**
-> Created 2026-09-03 from a full codebase scan.
+> Status: **W1–W3 + W5–W8 done; W4 packaging built, not yet run on a clean machine.**
+> Created 2026-09-03 from a full codebase scan; progress notes added 2026-09-04.
> This document is both the audit results and the implementation plan.
> It does not repeat the design decisions already in `desktop-client-v1.md`
> (§6.8, §7.5, decisions E8/E12) — read that first.
+>
+> **What shipped (branch `win-webrtc-stun`):** platform dirs / signals / perms /
+> ffmpeg discovery / CLI messages (W1-2-5-6-7-8, commits through `c2620a5`);
+> event loop stays Proactor (`5098e6c`); JWT clock-skew leeway (`dad2157`);
+> daemon lifecycle via a Startup-folder `.vbs` launcher, **not** Task Scheduler
+> (`220e6e7` — `schtasks /create /sc ONLOGON` needs elevation, see §5.3). W4 is
+> `packaging/win/` + `package.json` `build.win` — see §5.4.
---
@@ -298,37 +305,41 @@ of the Windows port — per-user mode ships first.
**Estimated scope:** ~200 lines Python + ~150 lines JS for per-user mode.
Service mode is a separate milestone.
-### 5.4 Packaging (W4)
+### 5.4 Packaging (W4) — **built 2026-09-04**
-**Scope:** `package.json` + `packaging/win/`
+**Scope:** `package.json` `build.win`/`build.nsis` + `packaging/win/` + one
+`src/main.js` line. **One installer**, `MeshBay-Setup-<version>.exe`, per-user,
+carrying the **client** and the **node** (with `meshbay-common` inside it). No
+hub.
-Add to `package.json`:
+`packaging/win/README.md` is the build guide. In brief:
-```json
-"win": {
- "target": "nsis",
- "icon": "build/icons/icon.ico"
-},
-"nsis": {
- "oneClick": false,
- "perMachine": false,
- "allowToChangeInstallationDirectory": true
-}
-```
-
-Add a `packaging/win/` directory with:
-- Icon in `.ico` format
-- Optional: Authenticode signing script (Phase 13.9)
+| File | |
+|---|---|
+| `package.json` `build.win` | `target: nsis`, `icon: build/icon.ico`, `extraResources` → `node-runtime/` |
+| `package.json` `build.nsis` | `oneClick:false`, `perMachine:false`, `allowElevation:false`, `allowToChangeInstallationDirectory:true` — no admin, ever |
+| `packages/meshbay-client/build/icon.ico` | multi-res, generated from `build/icon.png` |
+| `packages/meshbay-client/build/installer.nsh` | uninstall: `taskkill meshbay-node.exe` + delete the W3 Startup `.vbs` |
+| `packaging/win/meshbay-node.spec` + `node-entry.py` | PyInstaller freeze of `meshbay_node.daemon:main` |
+| `packaging/win/build-node-runtime.ps1` | runs PyInstaller → `packages/meshbay-client/node-runtime/` (gitignored) |
+| `packaging/win/build-win.ps1` | orchestrator: Node check → `npm ci` → Electron bump → `sync-ui` → node runtime → `electron-builder --win nsis` |
+| `packaging/win/bump-electron.mjs` | the Chromium-CVE "build against latest Electron" policy, factored out of the PS script |
+| `npm run dist:win` | → `build-win.ps1` (mirrors how `dist` → `build-client.sh`) |
-The `dist` script gets a platform flag:
-`"dist:win": "npm run sync-ui && electron-builder --win nsis"`
+**PyInstaller, not the python-embed zip.** The frozen `meshbay-node.exe` is a
+genuine relocatable single binary — which is what `findNodeBinary` in
+`src/main.js` spawns (`process.resourcesPath/node-runtime/meshbay-node.exe`) and
+what the W3 autostart launcher points at (`platform._node_exe`). The embeddable
+zip needs pip to produce that wrapper, and the wrapper bakes in an **absolute**
+interpreter path that breaks the moment the tree is installed elsewhere.
-Python node on Windows: **bundled with the Electron app** or installed
-separately. The simplest path is embedding Python via `python-embed` (the
-official embeddable zip from python.org) and installing meshbay-node into
-it. This avoids requiring a system Python install.
+**ffmpeg** is not bundled by default (the node finds it on `PATH`); pass
+`-FfmpegDir` to `build-win.ps1` to copy `ffmpeg.exe`/`ffprobe.exe` in beside the
+daemon for a self-contained installer.
-**Estimated scope:** config + build script, no code changes.
+**Still open:** Authenticode signing (Phase 13.9 — unsigned ⇒ SmartScreen),
+a Windows CI runner (Phase 18.3), `electron-updater`. First clean-machine
+install + `safeStorage`/DPAPI + autostart round-trip is a manual check.
### 5.5 File permissions (W5)
@@ -388,24 +399,25 @@ when `sys.platform == "win32"`.
## 6. Execution order
```
-W1 Platform directories ← unblocks everything; testable on Linux
-W2 Signal handling ← 10 lines; do it with W1
-W5 File permissions ← trivial guards; do it with W1
-W7 CLI messages ← cosmetic; do it with W1
-W6 ffmpeg discovery ← small; do it with W1
- ──── milestone: daemon runs on Windows ────
-W3 Daemon lifecycle ← largest item; per-user mode first
- ──── milestone: daemon starts/stops on Windows ────
-W4 Packaging ← electron-builder config + build script
- ──── milestone: installable on Windows ────
+W1 Platform directories ✅ done
+W2 Signal handling ✅ done (SIGINT/SIGTERM; CTRL_CLOSE still open — see below)
+W5 File permissions ✅ done
+W7 CLI messages ✅ done
+W6 ffmpeg discovery ✅ done
+W8 test suite green on win32 ✅ done (encoding sweep + skipif; 784 pass)
+ ──── milestone: daemon runs on Windows ✅ (verified end to end) ────
+W3 Daemon lifecycle ✅ done — Startup-folder .vbs (Task Scheduler needs admin)
+ ──── milestone: daemon starts/stops on Windows ✅ ────
+W4 Packaging ✅ built — one per-user NSIS installer, client + node
+ ──── milestone: installable on Windows — pending a clean-machine run ────
+ CTRL_CLOSE_EVENT handler ← open: window-close / bare taskkill skips _shutdown()
Service mode (W3b) ← Phase 2, optional
Authenticode signing ← Phase 13.9
CI Windows matrix ← Phase 18.3
```
-W1 through W7 (minus W3 and W4) can ship as a **single commit** — they are
-mechanical, self-contained, and testable on Linux. W3 and W4 are the real
-work and can be developed in parallel.
+W1–W2–W5–W6–W7–W8 shipped as a run of mechanical commits, testable on Linux.
+W3 and W4 were the real work.
---
diff --git a/packages/meshbay-client/build/icon.ico b/packages/meshbay-client/build/icon.ico
new file mode 100644
index 0000000..2766f24
--- /dev/null
+++ b/packages/meshbay-client/build/icon.ico
Binary files differ
diff --git a/packages/meshbay-client/build/installer.nsh b/packages/meshbay-client/build/installer.nsh
new file mode 100644
index 0000000..cf8e5ff
--- /dev/null
+++ b/packages/meshbay-client/build/installer.nsh
@@ -0,0 +1,24 @@
+; electron-builder NSIS customisation (auto-included: build/installer.nsh).
+;
+; Per-user install, no elevation (package.json build.nsis). These macros only
+; deal with the one piece of state that lives outside the install directory:
+; the W3 "run at sign-in" launcher the node's own CLI can create.
+;
+; Deliberately NOT touched here:
+; - %LOCALAPPDATA%\meshbay\ (node.toml, keystore.enc, unlock.key, data/) --
+; the keystore must survive an uninstall/reinstall; installers place files,
+; never remove secrets.
+
+!macro customInstall
+ ; resources\node-runtime\meshbay-node.exe is overwritten by this install; a
+ ; daemon still running from a previous version holds the file open.
+ nsExec::Exec 'taskkill /IM meshbay-node.exe /F'
+!macroend
+
+!macro customUnInstall
+ nsExec::Exec 'taskkill /IM meshbay-node.exe /F'
+ ; meshbay_node.platform._startup_vbs() -- if the user ran "meshbay-node
+ ; autostart install" (or toggled it in the client), this points wscript at
+ ; the binary we are about to delete, and would error at every sign-in.
+ Delete "$APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\MeshBay Node.vbs"
+!macroend
diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json
index 42af9c5..57acff8 100644
--- a/packages/meshbay-client/package.json
+++ b/packages/meshbay-client/package.json
@@ -10,7 +10,8 @@
"scripts": {
"start": "electron .",
"sync-ui": "node scripts/sync-ui.js",
- "dist": "bash ../../packaging/build/build-client.sh"
+ "dist": "bash ../../packaging/build/build-client.sh",
+ "dist:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ../../packaging/win/build-win.ps1"
},
"devDependencies": {
"electron": "^44.1.1",
@@ -22,7 +23,29 @@
"files": [
"src/**",
"ui/**"
- ]
+ ],
+ "win": {
+ "target": "nsis",
+ "icon": "build/icon.ico",
+ "artifactName": "${productName}-Setup-${version}.${ext}",
+ "extraResources": [
+ {
+ "from": "node-runtime",
+ "to": "node-runtime",
+ "filter": ["**/*"]
+ }
+ ]
+ },
+ "nsis": {
+ "oneClick": false,
+ "perMachine": false,
+ "allowElevation": false,
+ "allowToChangeInstallationDirectory": true,
+ "createDesktopShortcut": true,
+ "createStartMenuShortcut": true,
+ "deleteAppDataOnUninstall": false,
+ "runAfterFinish": true
+ }
},
"dependencies": {
"bonjour-service": "^1.4.4",
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index d341619..bd82342 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -792,7 +792,15 @@ function registerBridge() {
});
function findNodeBinary() {
- if (process.platform !== 'win32') {
+ if (process.platform === 'win32') {
+ // A packaged Windows build carries the frozen daemon as an
+ // extraResource (package.json build.win, packaging/win/). Prefer it —
+ // it is the version that shipped with this client.
+ if (app.isPackaged) {
+ const bundled = path.join(process.resourcesPath, 'node-runtime', 'meshbay-node.exe');
+ if (fs.existsSync(bundled)) return bundled;
+ }
+ } else {
const local = path.join(os.homedir(), '.local', 'bin', 'meshbay-node');
if (fs.existsSync(local)) return local;
}
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
new file mode 100644
index 0000000..1b7521a
--- /dev/null
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -0,0 +1,153 @@
+"""
+The Windows installer (W4): a single per-user NSIS package carrying the Electron
+client and the frozen node daemon.
+
+Like test_packaging_units.py this reads the config rather than building anything
+— there is no electron-builder or PyInstaller run here. Weak evidence, and the
+right kind for the defects it guards against: a per-machine flag that would make
+the installer demand admin, a build step wired to the wrong file, the 150 MB
+node-runtime artifact slipping into git, the autostart seam between the NSIS
+uninstaller and meshbay_node.platform drifting apart.
+"""
+
+import json
+import re
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[3]
+CLIENT = ROOT / "packages" / "meshbay-client"
+PKG_JSON = CLIENT / "package.json"
+WIN = ROOT / "packaging" / "win"
+NSH = CLIENT / "build" / "installer.nsh"
+
+pytestmark = pytest.mark.skipif(
+ not PKG_JSON.exists() or not WIN.exists(),
+ reason="Windows packaging not present")
+
+
+def _pkg() -> dict:
+ return json.loads(PKG_JSON.read_text(encoding="utf-8"))
+
+
+# ── electron-builder: Windows target ────────────────────────────────────────
+
+def test_the_windows_target_is_nsis_with_the_committed_icon():
+ win = _pkg()["build"]["win"]
+ assert win["target"] == "nsis"
+ icon = CLIENT / win["icon"]
+ assert icon.suffix == ".ico" and icon.exists(), f"{icon} is missing"
+
+
+def test_the_installer_is_per_user_and_never_asks_for_admin():
+ """
+ A logon-triggered scheduled task needs elevation (that is why W3 uses the
+ Startup folder), and the whole desktop design is no-admin. perMachine or
+ allowElevation here would undo that at install time.
+ """
+ nsis = _pkg()["build"]["nsis"]
+ assert nsis["oneClick"] is False
+ assert nsis["perMachine"] is False
+ assert nsis["allowElevation"] is False
+ assert nsis["allowToChangeInstallationDirectory"] is True
+
+
+def test_the_node_runtime_is_carried_as_an_extraresource():
+ """
+ PyInstaller output lands in packages/meshbay-client/node-runtime/ and rides
+ into the package under resources/node-runtime/. src/main.js:findNodeBinary
+ resolves exactly that path (process.resourcesPath / node-runtime /
+ meshbay-node.exe), so the two names must agree.
+ """
+ extra = _pkg()["build"]["win"]["extraResources"]
+ entry = next((e for e in extra if e.get("to") == "node-runtime"), None)
+ assert entry, "no extraResources entry mapping to node-runtime"
+ assert entry["from"] == "node-runtime"
+
+ main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8")
+ assert "'node-runtime', 'meshbay-node.exe'" in main_js, (
+ "findNodeBinary no longer looks for the bundled daemon where "
+ "extraResources puts it")
+
+
+def test_dist_win_delegates_to_the_build_script():
+ """`dist` (Linux) delegates to build-client.sh; `dist:win` is its
+ counterpart and must not be a second inline electron-builder invocation."""
+ scripts = _pkg()["scripts"]
+ assert "dist:win" in scripts
+ assert "build-win.ps1" in scripts["dist:win"]
+ # `dist` stays Linux-only and unchanged (test_desktop_shell.py guards it too).
+ assert "win" not in scripts["dist"].lower()
+
+
+# ── the build scripts exist and point at real files ────────────────────────
+
+@pytest.mark.parametrize("name", [
+ "build-win.ps1",
+ "build-node-runtime.ps1",
+ "meshbay-node.spec",
+ "node-entry.py",
+ "README.md",
+])
+def test_packaging_win_ships_its_scripts(name):
+ assert (WIN / name).exists(), f"packaging/win/{name} is missing"
+
+
+def test_the_pyinstaller_entry_point_is_the_daemon_main():
+ src = (WIN / "node-entry.py").read_text(encoding="utf-8")
+ assert "from meshbay_node.daemon import main" in src
+ assert "main()" in src
+
+
+def test_the_spec_pulls_in_the_awkward_dependencies_whole():
+ """
+ The C/Rust-extension and dynamic-import packages are the ones PyInstaller's
+ static pass drops. If someone trims collect_all to shrink the build, the
+ frozen daemon fails at runtime, not at build time.
+ """
+ spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8")
+ for pkg in ("aiortc", "av", "aioquic", "pydantic_core", "uvicorn",
+ "watchdog", "guessit", "blake3", "meshbay_node", "meshbay_common"):
+ assert re.search(rf'["\']{re.escape(pkg)}["\']', spec), (
+ f"{pkg} dropped from the PyInstaller spec's collect list")
+
+
+# ── the artifact never gets committed ──────────────────────────────────────
+
+def test_the_node_runtime_output_is_gitignored():
+ """It is ~150 MB of frozen Python. The `ui/` fork guard in
+ test_desktop_shell.py exists for the same reason."""
+ gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8")
+ assert "packages/meshbay-client/node-runtime/" in gitignore
+
+
+# ── the NSIS ↔ platform.py autostart seam ─────────────────────────────────
+
+def test_the_uninstaller_clears_the_autostart_launcher():
+ """
+ W3's `meshbay-node autostart install` drops a .vbs in the Startup folder
+ (meshbay_node.platform._startup_vbs). After an uninstall it would point
+ wscript at a deleted binary every sign-in, so customUnInstall must delete
+ it — and at the path platform.py actually uses.
+ """
+ from meshbay_node import platform as plat
+
+ nsh = NSH.read_text(encoding="utf-8")
+ assert "!macro customUnInstall" in nsh
+ assert "taskkill /IM meshbay-node.exe /F" in nsh
+
+ # The tail platform.py builds, made NSIS-relative ($APPDATA == %APPDATA%).
+ tail = plat._startup_vbs()
+ parts = tail.parts
+ i = parts.index("Microsoft")
+ rel = "\\".join(parts[i:]) # Microsoft\...\Startup\MeshBay Node.vbs
+ assert rel in nsh, (
+ f"customUnInstall does not delete {rel!r} — the W3 autostart path "
+ "changed and installer.nsh was not updated")
+
+
+def test_customInstall_stops_a_running_daemon_before_overwriting_it():
+ nsh = NSH.read_text(encoding="utf-8")
+ body = nsh.split("!macro customInstall", 1)[1].split("!macroend", 1)[0]
+ assert "taskkill /IM meshbay-node.exe /F" in body
diff --git a/packaging/win/README.md b/packaging/win/README.md
new file mode 100644
index 0000000..d02adca
--- /dev/null
+++ b/packaging/win/README.md
@@ -0,0 +1,90 @@
+# Windows packaging (W4)
+
+A single per-user installer — `MeshBay-Setup-<version>.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-client\
+├─ MeshBay.exe Electron client
+├─ resources\
+│ ├─ app.asar src/ + ui/ (the interface ships in the package)
+│ └─ 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.
+
+## 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-<version>.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.
+
+## 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.
diff --git a/packaging/win/build-node-runtime.ps1 b/packaging/win/build-node-runtime.ps1
new file mode 100644
index 0000000..c432e01
--- /dev/null
+++ b/packaging/win/build-node-runtime.ps1
@@ -0,0 +1,138 @@
+<#
+.SYNOPSIS
+ Freeze the MeshBay node daemon into a relocatable Windows binary.
+
+.DESCRIPTION
+ Produces packages/meshbay-client/node-runtime/meshbay-node.exe (+ its
+ onedir payload) with PyInstaller. electron-builder then carries that tree
+ into the NSIS installer as an extraResource (see package.json build.win).
+
+ The frozen binary is what the desktop client spawns and what the W3
+ autostart launcher points at, so it must be a genuine single .exe -- which
+ is why this uses PyInstaller and not the python-embed zip (whose pip
+ console-script wrapper bakes in an absolute interpreter path and does not
+ survive being installed somewhere else).
+
+ ffmpeg/ffprobe are NOT bundled by default. Pass -FfmpegDir (or set
+ MESHBAY_FFMPEG_DIR) to copy them in beside the daemon; otherwise the node
+ resolves them from PATH at startup (meshbay_node.platform.check_media_tools)
+ and streaming needs ffmpeg installed separately.
+
+.PARAMETER Python
+ Interpreter used to build. Must be 3.12+ and able to install the packages.
+ Default: a throwaway venv this script creates under build/_node-build-venv.
+
+.PARAMETER FfmpegDir
+ Directory containing ffmpeg.exe and ffprobe.exe to bundle. Optional.
+
+.PARAMETER KeepBuildVenv
+ Do not delete the throwaway build venv on success (faster re-runs).
+#>
+[CmdletBinding()]
+param(
+ [string]$Python = "",
+ [string]$FfmpegDir = $env:MESHBAY_FFMPEG_DIR,
+ [switch]$KeepBuildVenv
+)
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+$WinDir = $PSScriptRoot
+$Repo = (Resolve-Path (Join-Path $WinDir "..\..")).Path
+$Client = Join-Path $Repo "packages\meshbay-client"
+$OutDir = Join-Path $Client "node-runtime"
+$BuildVenv = Join-Path $Client "build\_node-build-venv"
+$SpecFile = Join-Path $WinDir "meshbay-node.spec"
+
+function Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
+
+# --- 1. interpreter --------------------------------------------------------
+$createdVenv = $false
+if (-not $Python) {
+ Step "creating build venv ($BuildVenv)"
+ if (Get-Command py -ErrorAction SilentlyContinue) {
+ & py -3.12 -m venv --clear $BuildVenv
+ } else {
+ & python -m venv --clear $BuildVenv
+ }
+ if ($LASTEXITCODE -ne 0) { throw "venv creation failed" }
+ $Python = Join-Path $BuildVenv "Scripts\python.exe"
+ $createdVenv = $true
+}
+if (-not (Test-Path $Python)) { throw "Python not found: $Python" }
+
+$verOut = (& $Python -c "import sys; print(sys.version_info[0]); print(sys.version_info[1])")
+$verMajor = [int]$verOut[0]
+$verMinor = [int]$verOut[1]
+if ($verMajor -lt 3 -or ($verMajor -eq 3 -and $verMinor -lt 12)) {
+ throw "need Python 3.12 or newer, got $verMajor.$verMinor"
+}
+Step "python $verMajor.$verMinor ($Python)"
+
+# --- 2. dependencies -----------------------------------------------------
+Step "installing meshbay-common, meshbay-node and PyInstaller"
+& $Python -m pip install --upgrade pip --quiet
+& $Python -m pip install --quiet `
+ (Join-Path $Repo "packages\meshbay-common") `
+ (Join-Path $Repo "packages\meshbay-node") `
+ "pyinstaller>=6.10" `
+ "tzdata"
+# tzdata: Windows ships no IANA zone database, so zoneinfo (pulled in
+# transitively) has nothing to read without it. PyInstaller warns
+# 'Hidden import "tzdata" not found' when it is absent.
+if ($LASTEXITCODE -ne 0) { throw "pip install failed" }
+
+# --- 3. freeze --------------------------------------------------------
+if (Test-Path $OutDir) { Remove-Item -Recurse -Force $OutDir }
+$pyiWork = Join-Path $Client "build\_pyinstaller"
+$pyiDist = Join-Path $Client "build\_pyinstaller-dist"
+Step "running PyInstaller (this takes a few minutes)"
+Push-Location $WinDir
+try {
+ & $Python -m PyInstaller --noconfirm --clean `
+ --workpath $pyiWork --distpath $pyiDist `
+ $SpecFile
+ if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed" }
+}
+finally {
+ Pop-Location
+}
+
+$frozen = Join-Path $pyiDist "meshbay-node"
+if (-not (Test-Path (Join-Path $frozen "meshbay-node.exe"))) {
+ throw "PyInstaller did not produce meshbay-node.exe at $frozen"
+}
+
+# --- 4. optional ffmpeg ----------------------------------------------
+if ($FfmpegDir) {
+ foreach ($tool in @("ffmpeg.exe", "ffprobe.exe")) {
+ $src = Join-Path $FfmpegDir $tool
+ if (-not (Test-Path $src)) { throw "$tool not found in $FfmpegDir" }
+ Copy-Item $src (Join-Path $frozen $tool)
+ Step "bundled $tool"
+ }
+}
+else {
+ Write-Host " ffmpeg not bundled -- the node will look for it on PATH" -ForegroundColor Yellow
+}
+
+# --- 5. publish ----------------------------------------------------
+Move-Item $frozen $OutDir
+Remove-Item -Recurse -Force $pyiWork, $pyiDist -ErrorAction SilentlyContinue
+if ($createdVenv -and -not $KeepBuildVenv) {
+ Remove-Item -Recurse -Force $BuildVenv -ErrorAction SilentlyContinue
+}
+
+# --- 6. smoke test -----------------------------------------------
+# Capture, do NOT pipe to Select-Object -First: that stops the native process
+# mid-write and reports a spurious non-zero exit.
+Step "smoke test: meshbay-node --help"
+$exe = Join-Path $OutDir "meshbay-node.exe"
+$help = & $exe --help 2>&1
+if ($LASTEXITCODE -ne 0) { throw "frozen meshbay-node --help exited $LASTEXITCODE" }
+if ($help -notmatch "meshbay-node") { throw "frozen --help output looks wrong" }
+
+$mb = (Get-ChildItem $OutDir -Recurse | Measure-Object Length -Sum).Sum / 1MB
+Write-Host ""
+Write-Host ("OK node-runtime ready at {0} ({1:N0} MB)" -f $OutDir, $mb) -ForegroundColor Green
diff --git a/packaging/win/build-win.ps1 b/packaging/win/build-win.ps1
new file mode 100644
index 0000000..4784071
--- /dev/null
+++ b/packaging/win/build-win.ps1
@@ -0,0 +1,117 @@
+<#
+.SYNOPSIS
+ Build the Windows installer: Electron client + bundled Python node.
+
+.DESCRIPTION
+ The Windows counterpart of packaging/build/build-client.sh. Produces a
+ single per-user NSIS installer (MeshBay-Setup-<version>.exe) that lays down
+ the Electron app and, beside it under resources/node-runtime/, the frozen
+ meshbay-node daemon. No hub -- a desktop machine installs client + node
+ (+ common, which is inside the node runtime).
+
+ Steps:
+ 1. Node >= 22 check
+ 2. npm ci (+ approve Electron's install script, download Chromium)
+ 3. bump Electron to the latest release (Chromium CVE policy -- see
+ build-client.sh; skip with -NoElectronBump)
+ 4. npm run sync-ui (copy the interface from the hub package)
+ 5. build-node-runtime.ps1 (PyInstaller freeze of the daemon)
+ 6. electron-builder --win nsis
+
+.PARAMETER FfmpegDir
+ Passed through to build-node-runtime.ps1 -- directory with ffmpeg.exe /
+ ffprobe.exe to bundle. Optional; without it the node uses PATH.
+
+.PARAMETER NoElectronBump
+ Keep the pinned Electron instead of upgrading to the latest release.
+
+.PARAMETER SkipNodeRuntime
+ Reuse an existing packages/meshbay-client/node-runtime/ (faster iteration
+ on the electron-builder side).
+#>
+[CmdletBinding()]
+param(
+ [string]$FfmpegDir = $env:MESHBAY_FFMPEG_DIR,
+ [switch]$NoElectronBump,
+ [switch]$SkipNodeRuntime
+)
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+$WinDir = $PSScriptRoot
+$Repo = (Resolve-Path (Join-Path $WinDir "..\..")).Path
+$Client = Join-Path $Repo "packages\meshbay-client"
+
+function Step($msg) { Write-Host "==> $msg" -ForegroundColor Cyan }
+
+# --- 1. Node -------------------------------------------------------------
+if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
+ throw "Node.js not found. Install Node 22 or newer from nodejs.org."
+}
+$nodeMajor = [int](& node -e "process.stdout.write(String(process.versions.node.split('.')[0]))")
+if ($nodeMajor -lt 22) { throw "Node $nodeMajor is too old -- need 22 or newer for Electron" }
+Step "Node $(& node --version)"
+
+Push-Location $Client
+try {
+ # --- 2. deps ------------------------------------------------------
+ Step "npm ci"
+ & npm ci --ignore-scripts
+ if ($LASTEXITCODE -ne 0) { throw "npm ci failed" }
+
+ # --- 3. Electron: build against the latest release --------------
+ # Writes package.json + package-lock.json, so the build leaves the repo
+ # dirty on purpose -- commit the new pin.
+ if (-not $NoElectronBump) {
+ Step "checking for a newer Electron"
+ $bumped = & node (Join-Path $WinDir "bump-electron.mjs")
+ if ($LASTEXITCODE -ne 0) { throw "electron bump failed" }
+ if ($bumped) {
+ Write-Host " Electron -> $bumped (package.json + lock updated, commit them)" -ForegroundColor Yellow
+ } else {
+ Write-Host " Electron is already current"
+ }
+ }
+
+ Step "downloading Electron's Chromium"
+ & npm approve-scripts electron 2>$null
+ & node node_modules/electron/install.js
+
+ # --- 4. UI -----------------------------------------------------
+ Step "npm run sync-ui"
+ & npm run sync-ui
+ if ($LASTEXITCODE -ne 0) { throw "sync-ui failed" }
+
+ # --- 5. node runtime ---------------------------------------
+ $rtExe = Join-Path $Client "node-runtime\meshbay-node.exe"
+ if ($SkipNodeRuntime -and (Test-Path $rtExe)) {
+ Step "reusing existing node-runtime/"
+ } else {
+ Step "building the bundled node (PyInstaller)"
+ $rtArgs = @{}
+ if ($FfmpegDir) { $rtArgs["FfmpegDir"] = $FfmpegDir }
+ & (Join-Path $WinDir "build-node-runtime.ps1") @rtArgs
+ if ($LASTEXITCODE -ne 0) { throw "build-node-runtime.ps1 failed" }
+ }
+
+ # --- 6. installer ---------------------------------------
+ Step "electron-builder --win nsis"
+ & npx electron-builder --win nsis
+ if ($LASTEXITCODE -ne 0) { throw "electron-builder failed" }
+}
+finally {
+ Pop-Location
+}
+
+$setup = Get-ChildItem (Join-Path $Client "dist") -Filter "*Setup*.exe" -ErrorAction SilentlyContinue |
+ Sort-Object LastWriteTime | Select-Object -Last 1
+Write-Host ""
+if ($setup) {
+ Write-Host "OK installer: $($setup.FullName)" -ForegroundColor Green
+ Write-Host (" ({0:N0} MB)" -f ($setup.Length / 1MB))
+}
+else {
+ Write-Host "!! no *Setup*.exe found in $Client\dist" -ForegroundColor Red
+ exit 1
+}
diff --git a/packaging/win/bump-electron.mjs b/packaging/win/bump-electron.mjs
new file mode 100644
index 0000000..78189bf
--- /dev/null
+++ b/packaging/win/bump-electron.mjs
@@ -0,0 +1,43 @@
+// Bump the pinned Electron to the latest release, in package.json and
+// package-lock.json, and keep the `allowScripts` key matching.
+//
+// Chromium CVEs are fixed in Electron releases; a client built against an old
+// one ships those holes. This is the same policy packaging/build/build-client.sh
+// applies on Linux -- factored out to a file so build-win.ps1 does not have to
+// carry a PowerShell here-string (whose terminator rules make it fragile).
+//
+// Run from packages/meshbay-client. Writes both manifests and exits 0 whether
+// or not a bump happened; exits non-zero only on an actual error. Prints the
+// new version to stdout when it changed, nothing when it did not.
+
+import { readFileSync, writeFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+
+const pinned = JSON.parse(readFileSync('package-lock.json', 'utf8'))
+ .packages['node_modules/electron'].version;
+
+let latest;
+try {
+ latest = execFileSync('npm', ['view', 'electron', 'version'], { encoding: 'utf8' }).trim();
+} catch {
+ process.stderr.write('npm registry unreachable -- keeping Electron ' + pinned + '\n');
+ process.exit(0);
+}
+
+if (latest === pinned) process.exit(0);
+
+execFileSync('npm', ['install', '--save-dev', '--ignore-scripts', `electron@${latest}`],
+ { stdio: 'inherit' });
+
+const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
+if (pkg.allowScripts) {
+ for (const k of Object.keys(pkg.allowScripts)) {
+ if (k.startsWith('electron@')) {
+ delete pkg.allowScripts[k];
+ pkg.allowScripts[`electron@${latest}`] = true;
+ }
+ }
+ writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
+}
+
+process.stdout.write(latest + '\n');
diff --git a/packaging/win/meshbay-node.spec b/packaging/win/meshbay-node.spec
new file mode 100644
index 0000000..f1f994d
--- /dev/null
+++ b/packaging/win/meshbay-node.spec
@@ -0,0 +1,126 @@
+# PyInstaller spec for the bundled Windows node daemon.
+#
+# Output: dist/meshbay-node/meshbay-node.exe (--onedir, relocatable)
+# Consumed by: packaging/win/build-node-runtime.ps1, which moves the tree to
+# packages/meshbay-client/node-runtime/ for electron-builder to carry as an
+# extraResource.
+#
+# The dependency set (aiortc, av, aioquic, pydantic-core, uvicorn, watchdog,
+# guessit, blake3, ...) has C/Rust extensions and does dynamic imports, so
+# each awkward package is pulled in whole with collect_all rather than left to
+# PyInstaller's static analysis. Add to COLLECT_ALL / HIDDEN / COPY_META when a
+# frozen run raises ModuleNotFoundError — that is the expected way this list grows.
+
+from PyInstaller.utils.hooks import collect_all, copy_metadata
+
+# Packages taken in full: data files + shared libs + every submodule.
+COLLECT_ALL = [
+ "meshbay_node",
+ "meshbay_common",
+ "aiortc",
+ "aioice",
+ "aioquic",
+ "av",
+ "cryptography",
+ "pylibsrtp",
+ "pyee",
+ "google_crc32c",
+ "pydantic",
+ "pydantic_core",
+ "fastapi",
+ "starlette",
+ "uvicorn",
+ "anyio",
+ "sniffio",
+ "h11",
+ "websockets",
+ "httpx",
+ "httpcore",
+ "watchdog",
+ "guessit",
+ "rebulk",
+ "babelfish",
+ "mutagen",
+ "PIL",
+ "blake3",
+ "msgpack",
+ "zstandard",
+ "aiosqlite",
+ "jwt",
+ "dns",
+ "tzdata",
+]
+
+# importlib.metadata lookups that survive freezing only if the dist-info is copied.
+COPY_META = [
+ "meshbay-node",
+ "meshbay-common",
+ "guessit",
+ "rebulk",
+ "babelfish",
+ "uvicorn",
+ "fastapi",
+]
+
+# Imports PyInstaller's static pass misses even with collect_all.
+HIDDEN = [
+ "uvicorn.logging",
+ "uvicorn.loops.auto",
+ "uvicorn.loops.asyncio",
+ "uvicorn.protocols.http.auto",
+ "uvicorn.protocols.http.h11_impl",
+ "uvicorn.protocols.websockets.auto",
+ "uvicorn.protocols.websockets.websockets_impl",
+ "uvicorn.lifespan.on",
+]
+
+datas, binaries, hiddenimports = [], [], list(HIDDEN)
+for pkg in COLLECT_ALL:
+ d, b, h = collect_all(pkg)
+ datas += d
+ binaries += b
+ hiddenimports += h
+for dist in COPY_META:
+ datas += copy_metadata(dist)
+
+a = Analysis(
+ ["node-entry.py"],
+ pathex=[],
+ binaries=binaries,
+ datas=datas,
+ hiddenimports=hiddenimports,
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=["tkinter", "pytest", "ruff", "piexif", "IPython"],
+ noarchive=False,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name="meshbay-node",
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=False,
+ console=True,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+ icon="../../packages/meshbay-client/build/icon.ico",
+)
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=False,
+ upx=False,
+ upx_exclude=[],
+ name="meshbay-node",
+)
diff --git a/packaging/win/node-entry.py b/packaging/win/node-entry.py
new file mode 100644
index 0000000..6fadad7
--- /dev/null
+++ b/packaging/win/node-entry.py
@@ -0,0 +1,12 @@
+"""PyInstaller entry point for the bundled Windows node daemon.
+
+`meshbay_node.daemon:main` is a module function; PyInstaller freezes a script.
+This is that script — nothing more. The frozen binary is `meshbay-node.exe`,
+relocatable, and is what the desktop client spawns and what the W3 autostart
+launcher points at (`meshbay_node.platform._node_exe`).
+"""
+
+from meshbay_node.daemon import main
+
+if __name__ == "__main__":
+ main()