From 3ce52774760b222d94d78bc0118e9da2662a809f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 4 Sep 2026 09:28:14 +0200 Subject: feat: Windows installer (W4) — one per-user NSIS package, client + node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run dist:win` produces MeshBay-Setup-.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 --- .gitignore | 4 + docs/PACKAGING-GUIDE.md | 45 +++++++ docs/WINDOWS-PORT.md | 98 ++++++++------ packages/meshbay-client/build/icon.ico | Bin 0 -> 117250 bytes packages/meshbay-client/build/installer.nsh | 24 ++++ packages/meshbay-client/package.json | 27 +++- packages/meshbay-client/src/main.js | 10 +- packages/meshbay-node/tests/test_packaging_win.py | 153 ++++++++++++++++++++++ packaging/win/README.md | 90 +++++++++++++ packaging/win/build-node-runtime.ps1 | 138 +++++++++++++++++++ packaging/win/build-win.ps1 | 117 +++++++++++++++++ packaging/win/bump-electron.mjs | 43 ++++++ packaging/win/meshbay-node.spec | 126 ++++++++++++++++++ packaging/win/node-entry.py | 12 ++ 14 files changed, 841 insertions(+), 46 deletions(-) create mode 100644 packages/meshbay-client/build/icon.ico create mode 100644 packages/meshbay-client/build/installer.nsh create mode 100644 packages/meshbay-node/tests/test_packaging_win.py create mode 100644 packaging/win/README.md create mode 100644 packaging/win/build-node-runtime.ps1 create mode 100644 packaging/win/build-win.ps1 create mode 100644 packaging/win/bump-electron.mjs create mode 100644 packaging/win/meshbay-node.spec create mode 100644 packaging/win/node-entry.py 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-.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-.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) - -The `dist` script gets a platform flag: -`"dist:win": "npm run sync-ui && electron-builder --win nsis"` - -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. - -**Estimated scope:** config + build script, no code changes. +| 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`) | + +**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. + +**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. + +**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 Binary files /dev/null and b/packages/meshbay-client/build/icon.ico 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-.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-.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-.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() -- cgit v1.2.3