# MeshBay — Windows Port > **Superseded by `MESHBAY_DESIGN.md`.** This was the Windows port audit and plan; its design > content now lives in §10 (portability), §11.2 (platform), §13.7 (W1–W9). > > It is kept because code comments, tests and other documents cite its > sections and its labels, and because it records reasoning a synthesis > compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design > document is right; where either disagrees with the code, the code is.** > `MESHBAY_DESIGN.md` §16 maps every section reference here onto its > replacement, and §13 defines every label. > Status: **W1–W9 done. Packaging (W4) built with both autostart modes, a > post-install mode toggle, and a static dependency audit — clean-machine > install still not run.** > Created 2026-09-03 from a full codebase scan; progress notes added 2026-09-04, > 2026-09-05. > This document is both the audit results and the implementation plan. > It does not repeat the design decisions already in `MESHBAY_DESIGN.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 **or** an S4U Scheduled > Task service mode, chosen at install time and switchable afterwards from the > Node page (§5.3). W4 is `packaging/win/` + `package.json` `build.win` — see > §5.4, and `docs/windows-build.md` for the step-by-step build guide. --- ## 1. Current state — what is already portable The bulk of the codebase is ready. The architecture documents said "exFAT/NTFS and Windows are the common case" (decision E12) and the code reflects it where it matters most: | Area | Status | Notes | |---|---|---| | `meshbay_common/paths.py` | **Ready** | `fold()`, `nfc()`, `long_path()` with `\\?\` prefix, `WINDOWS_RESERVED`, `sanitize_for_download()`, `find_fold_collisions()` | | `meshbay_common/` (all other modules) | **Ready** | Crypto, protocol, keyderive — no OS dependency | | `roots.py` | **Ready** | Uses `fold()`, `portable_name_problem()`, `as_posix()` for virtual paths, Windows drive examples in docstrings | | `indexer/indexer.py` | **Ready** | `as_posix()` for index paths, `long_path()` for file I/O, reconciliation loop for dropped `ReadDirectoryChangesW` events | | Transport (WebRTC, QUIC, MNP) | **Ready** | asyncio + aiortc. One platform dependency: `ice_filter.py` matches adapters by `ifaddr` name, which is a kernel name on Linux and a GUID on Windows — see W9 | | Roster, bundle store, audit, chat store | **Ready** | SQLite + pathlib throughout | | `test_paths.py` | **Ready** | Case folding, NFC, reserved names, reserved characters, trailing dot/space — already covers Windows filesystem rules | | Electron `preload.js` | **Ready** | Pure IPC bridge, no platform code | | Electron `safeStorage` | **Ready** | `secretsBackend()` (`src/main.js:229-232`) already handles `win32 → 'dpapi'` | | Electron `app://` protocol | **Ready** | Cross-platform Electron API | | Electron `scripts/sync-ui.js` | **Ready** | Uses `path.join`/`path.resolve` | | Hub (`meshbay-hub`) | **N/A** | Server-only, stays Linux | --- ## 2. Blockers — will crash or fail on Windows ### 2.1 Hardcoded XDG paths (~20 locations) The central problem. Every default path assumes `~/.config/meshbay` and `~/.local/share/meshbay`. On Windows these must be `%LOCALAPPDATA%\meshbay` (or `%APPDATA%\meshbay` for roaming config). **Python — meshbay-node:** | File | Line | Path | |---|---|---| | `config.py` | 28 | `DEFAULT_CONFIG_PATH = Path.home() / ".config" / "meshbay" / "node.toml"` | | `config.py` | 237 | `data_dir` default → `~/.local/share/meshbay` | | `keystore.py` | 60 | `DEFAULT_KEYSTORE_PATH` → `~/.config/meshbay/keystore.enc` | | `keystore.py` | 61 | `DEFAULT_UNLOCK_FILE` → `~/.config/meshbay/unlock.key` | | `hub_client.py` | 64 | `cache_dir` → `~/.config/meshbay` | | `tls_cert.py` | 26-27 | `DEFAULT_CERT` / `DEFAULT_KEY` → `~/.config/meshbay/` | | `quic_server.py` | 610-611 | Fallback cert/key paths → `~/.config/meshbay/` | | `daemon.py` | 1760-1762 | `reset` command hardcodes `~/.config`, `~/.local/share`, `~/.local/state` | **JavaScript — meshbay-client:** | File | Line | Path | |---|---|---| | `src/main.js` | 700 | `nodeConfigPath()` → `os.homedir() + '/.config/meshbay/node.toml'` | | `src/main.js` | 706 | `readNodeConfig()` → `os.homedir() + '/.local/share/meshbay'` | | `src/main.js` | 753 | `findNodeBinary()` → `os.homedir() + '/.local/bin/meshbay-node'` | | `src/main.js` | 836 | data dir fallback → `os.homedir() + '/.local/share/meshbay'` | | `src/main.js` | 855-856 | `provisionNode()` → hardcoded config and data dirs | **Fix:** one `platform_dirs()` function in Python (`meshbay_common` or `meshbay_node`) and one in JS, each returning `{config_dir, data_dir, state_dir, bin_dir}` per platform. Called from a single place; every default path derived from it. See §5.1. ### 2.2 Signal handling in the daemon `daemon.py:679-680` — `loop.add_signal_handler(SIGINT, SIGTERM)` raises `NotImplementedError` on Windows' `ProactorEventLoop`. SIGHUP (`daemon.py:683-687`) is already guarded with `try/except`; SIGINT/SIGTERM is not. **Fix:** on Windows, use `signal.signal(signal.SIGINT, handler)` — works with `ProactorEventLoop`. Or use `SetConsoleCtrlHandler` via ctypes for `CTRL_C_EVENT` and `CTRL_CLOSE_EVENT`. See §5.2 (SIGINT/SIGTERM, done at the time of the original audit) and §5.9 (the `CTRL_CLOSE_EVENT` family, done 2026-09-05). ### 2.3 systemd-hardwired daemon lifecycle The CLI and the Electron client assume systemd for start/stop/reload/restart: **Python CLI (`daemon.py`):** | Line | What | |---|---| | 1590-1605 | `_systemctl_user()` → `subprocess.run(["systemctl", "--user", ...])` | | 1806 | `reset` → `systemctl --user disable --now` | | 2058 | `reload` → `_systemctl_user("reload", ...)` | | 2070 | `restart-daemon` → `_systemctl_user("restart", ...)` | | 1751 | `init` prints `systemctl --user enable --now` instructions | **Electron (`src/main.js`):** | Line | IPC handler | |---|---| | 763 | `node:installed` → `systemctl --user show` (returns `{ installed: false }` on non-Linux) | | 781 | `node:service-status` → `systemctl --user show` (returns `{ supported: false }`) | | 806-810 | `node:service-stop` → throws on non-Linux | | 820-824 | `node:service-restart` → throws on non-Linux | | 898-954 | `node:start` → throws on non-Linux, then various systemctl calls | The Electron guards are correct (no crash on Windows), but the entire Node management panel is inoperative. **This is the largest piece of new code in the port.** See §5.3. ### 2.4 `which` command in Electron `src/main.js:756` — `execFile('which', ['meshbay-node'])` to locate the binary. `which` does not exist on Windows. **Fix:** use `where.exe` on Windows, or use a JS-native lookup (`child_process.execFileSync` with `process.env.PATH` split on `;`). See §5.1. ### 2.5 Electron packaging — no Windows target `package.json:11` — `"dist": "npm run sync-ui && electron-builder --linux deb rpm"`. The `build` section has only `linux`, `deb`, and `rpm` keys. No `build.win`, no NSIS/MSI configuration. `packaging/` contains only Linux artifacts: `deb/`, `rpm/`, `systemd/`, `firewall/`, `caddy/`, `desktop/`. **Fix:** add `build.win` to `package.json` with NSIS per-user target, and a `packaging/win/` directory. See §5.4. --- ## 3. Needs work — no crash, but wrong or incomplete behavior ### 3.1 File permissions (`chmod 0o600`) `os.chmod(path, 0o600)` and `path.chmod(0o600)` are no-ops on NTFS — the mode bits are ignored. The files are left with default Windows permissions (readable by the user and administrators), which is acceptable for per-user data but not explicit. | File | Line | What | |---|---|---| | `keystore.py` | 236 | keystore file | | `keystore.py` | 97-98 | **Reads** `st_mode` and warns if not `"600"` — will always warn on Windows | | `roster.py` | 918 | roster database | | `tls_cert.py` | 67-68 | TLS cert and key | | `daemon.py` | 221 | UI token file | | `daemon.py` | 1726, 1734 | node.toml and unlock.key during `init` | | `src/main.js` | 193, 226, 866, 882, 888 | config files | **Fix:** guard `chmod` calls with `sys.platform != "win32"`. For the `keystore.py:97` permission check, skip the mode verification on Windows. Windows ACLs (via `icacls` or `pywin32`) are an option for defense in depth but not required — the files are under `%LOCALAPPDATA%`, which is per-user by default. See §5.5. ### 3.2 ffmpeg/ffprobe discovery All calls use bare `"ffmpeg"` / `"ffprobe"` strings: | File | Line | |---|---| | `media_probe.py` | 51 | | `indexer/enrich.py` | 165 | | `webrtc_server.py` | 3468, 4387, 4607 | | `quic_server.py` | 559 | Python's `subprocess` resolves `.exe` transparently on Windows, so these work **if ffmpeg is in `%PATH%`**. But there is no startup check and no clear error message if it is missing. **Fix:** add a `node.toml` config key `[node] ffmpeg_path` with a default of `"ffmpeg"`, checked at startup with `shutil.which()`. Report a clear error at daemon startup if not found. See §5.6. ### 3.3 CLI messages assume Linux `daemon.py:1751` — `init` unconditionally prints `systemctl --user enable --now meshbay-node`. The example config (`config.py:91-109`) uses Unix paths (`/home/user/Media`, `/run/media/user/USB/`). **Fix:** platform-conditional messages and example paths. Minor. --- ## 4. Not a problem — confirmed portable These were checked and need no work: - **watchdog `Observer`** — auto-selects `ReadDirectoryChangesW` on Windows. The reconciliation scan (`_reconcile_loop`, `indexer.py:513`) already compensates for dropped events. - **`asyncio.create_subprocess_exec`** — works on Windows with `ProactorEventLoop` (default since Python 3.10). `proc.kill()` sends `TerminateProcess` rather than SIGKILL — functionally correct. - **`as_posix()` for index paths** — the MNP protocol uses forward slashes; the indexer already normalizes (`indexer.py:128`). - **SQLite** — fully cross-platform. - **aiortc** — pure Python, no OS dependency. - **No Unix-only imports** — no `fcntl`, `grp`, `pwd`, `resource` anywhere in the codebase. - **`proc.kill()` semantics** — different (no signal) but the pipe-draining and timeout patterns already in place handle it. --- ## 5. Implementation plan ### Principles 1. **Write under Linux, validate under Windows.** The code changes are testable on Linux with `sys.platform` mocking. The Windows VM is for integration testing, packaging and visual validation. 2. **One function, one place.** Platform-specific logic is concentrated in helper functions, never scattered across call sites. 3. **No new files if a function in an existing module suffices.** 4. **Tests must run on both platforms.** Anything that touches signals or paths must have a `@pytest.mark.skipif` for the platform it cannot run on, not a crash. ### 5.1 Platform directories (W1) **Scope:** `meshbay_node` + `meshbay-client` Create `meshbay_node/platform.py`: ```python def config_dir() -> Path: if sys.platform == "win32": return Path(os.environ.get("LOCALAPPDATA", Path.home())) / "meshbay" return Path.home() / ".config" / "meshbay" def data_dir() -> Path: ... def state_dir() -> Path: ... ``` Replace every hardcoded XDG path in `config.py`, `keystore.py`, `hub_client.py`, `tls_cert.py`, `quic_server.py`, `daemon.py` with calls to these functions. In `src/main.js`, equivalent `meshbayConfigDir()` / `meshbayDataDir()` using `process.env.LOCALAPPDATA` on win32, `os.homedir() + '/.config/meshbay'` elsewhere. Replace the 8 hardcoded locations. For `findNodeBinary()`: use `where.exe meshbay-node` on Windows, `which meshbay-node` on others. **Estimated scope:** ~20 call sites, mechanical replacement. ### 5.2 Signal handling (W2) **Scope:** `daemon.py` only ```python if sys.platform == "win32": signal.signal(signal.SIGINT, lambda *_: stop_event.set()) signal.signal(signal.SIGTERM, lambda *_: stop_event.set()) else: for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, stop_event.set) ``` SIGHUP is already guarded. No other signal handling in the codebase. **Estimated scope:** ~10 lines changed. ### 5.3 Daemon lifecycle on Windows (W3) — done, both modes **Scope:** `daemon.py` CLI + `meshbay_node.platform` + `src/main.js` IPC handlers + `node-page.js` Two modes ship, matching `MESHBAY_DESIGN.md` §11.2, chosen at install time and **switchable afterwards** (see below — that "afterwards" part was not the original design and exists because of a real bug): #### Per-user mode (default, no admin) The daemon runs as a regular process. Autostart is a `.vbs` in the Startup folder (`meshbay_node.platform._startup_vbs`), toggled from the Node page (`platform.node.autostart`) or `meshbay-node autostart install|remove`. **Not** a Task Scheduler `/sc ONLOGON` task as originally planned — that needs elevation for the same reason a boot trigger does, so it was abandoned for the same no-admin-by-default reason service mode below needs one prompt. #### Service mode (one admin confirmation) A Scheduled Task via **S4U** (Service For User) logon — not a real Windows Service (`pywin32`/NSSM), because a Service runs under LocalSystem/NetworkService, accounts with no normal user profile, so `%LOCALAPPDATA%\meshbay\` would not exist for it. S4U starts at boot with no sign-in and no stored password, and — the whole reason S4U and not LocalSystem — loads the signed-in user's own profile, so nothing in `platform.py` needed to change to support it. Registered via `Register-ScheduledTask -LogonType S4U` (the `ScheduledTasks` PowerShell module), **not** `schtasks /create`: schtasks has no flag naming the logon type, only inference from whether `/rp` is present, and both readings broke live on a blank-password account (common on a personal PC, confirmed 2026-09-05) — `/rp ""` fails schtasks' own credential validation under Windows' default blank-password policy, and omitting `/rp` registers "Interactive only" instead of S4U, which installs cleanly but never actually launches the process, at boot or on demand. `-LogonType S4U` names it explicitly. Verified live end-to-end after the fix: install, manual start, and unattended boot-time start (the process already running on next login, no manual action taken). `packaging/win/service.ps1` + `service-mode.ps1` (one elevation, folds the firewall rules into the same UAC prompt) + `meshbay_node.platform.service_install/_remove/_status/_run/_end` + CLI `meshbay-node service ...`. Start/Stop/Restart on the Node page drive whichever mode is active (`main.js` `winServiceTaskStatus/Run/End`, checked first in `node:installed`/`service-status`/`service-stop`/`service-restart`/ `node:start`). **Verified empirically:** a bare `meshbay-node.exe` run with zero config present — the exact case if the S4U task fires at boot before the user has ever provisioned anything — exits cleanly in <1s, code 1, "Error: hub.username not set... Run: meshbay-node init", nothing written to disk. So an unprovisioned first boot is already safe; the client's provision-then-start flow (`provisionNode()` writes `node.toml` before `node:start` ever calls `winServiceTaskRun()`) already sequences correctly for both modes. #### Found 2026-09-05: the installer's mode question is effectively one-shot `installer.nsh` decides whether to ask the mode question by checking **firewall state only** (`firewall.ps1 check`, unelevated) — reasoning "service-mode.ps1 always sets up the task and firewall together, so rules present ⇒ a mode was already chosen." False: the per-user branch also sets up firewall on its own, no Scheduled Task involved. So once firewall is satisfied by **any** path — including dev/testing calling `firewall.ps1 add` directly — the mode dialog never shows again, on a fresh install or a repair, since uninstall defaults to leaving both alone. This is exactly what happened during this session's own testing: the dialog never appeared on either real install run, and "no UAC prompt" was reasonably but wrongly read as "already accepted earlier." **Fixed** by adding the other door in rather than patching the skip-check: a "run as a background service" checkbox on the Node page (`node-page.js`, next to autostart), wired `main.js` (`winElevateServiceMode` — `Start-Process -Verb RunAs` against a temp `param()`-based `.ps1`, so the target path/args bind through real PowerShell parameters, not string quoting) → `preload.js` (`serviceMode`) → `platform.js` (`node.serviceMode.install()/remove()`). It drives the exact same `service-mode.ps1` the installer runs, so the two paths can never disagree. `installer.nsh`'s skip-check itself was deliberately left as-is (harmless for the "asks nothing on reinstall" case it was written for) — the toggle is the durable fix, the mode is no longer a one-shot decision. **Estimated scope (actual):** ~200 lines Python (`platform.py` + CLI) + the PowerShell scripts + ~150 lines JS across `main.js`/`preload.js`/`platform.js`/ `node-page.js` for the toggle. ### 5.4 Packaging (W4) — **built 2026-09-04** **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. `packaging/win/README.md` is the build guide. In brief: | 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 bundled by default** (`packaging/win/fetch-ffmpeg.ps1` — downloaded from a pinned, checksum-verified BtbN/FFmpeg-Builds release, ~161 MB, GPLv3 because the transcode path needs libx264 and no LGPL-only build has one; `LICENSE-ffmpeg.txt` ships with it). `winget install ffmpeg` was considered and rejected as the mechanism: it needs network + winget present at install time and fails silently. `-SkipFfmpeg` opts out for a smaller local-iteration build. #### Dependency audit 2026-09-05: no VC++ Redistributable needed (static analysis) The user asked, in effect, "is the Setup EXE fully self-contained on a clean machine" — Python, PyInstaller and the VC++ runtime specifically. Checked by inspecting the actual built `node-runtime/` tree rather than assuming: - **The frozen node** (`_internal/`, 92 `.pyd`/`.dll` files across aiortc, av, aioquic, cryptography, pydantic_core, etc.) imports only `VCRUNTIME140.dll`/`VCRUNTIME140_1.dll` (bundled by PyInstaller automatically — both files are present in `_internal/`) and `api-ms-win-crt-*.dll` (the Universal CRT). `msvcp140.dll` — the actual "Visual C++ Redistributable" DLL — appears **nowhere** in the dependency graph. The Universal CRT is an OS component since Windows 10 1607, not a separately-installed package, so nothing here needs the redistributable installed. - **ffmpeg** (BtbN gpl-shared, MinGW-built): `avformat`/`avfilter`/`avdevice` carry `libstdc++`/`libgcc` symbol strings but no separate `libstdc++-6.dll`/`libgcc_s_seh-1.dll`/`libwinpthread-1.dll` ships or is needed — BtbN links its GCC runtime statically. `fetch-ffmpeg.ps1`'s own smoke test (a real libx264 encode + an ffprobe run, immediately after extraction) already exercises this on every build. - **The Electron client**: `dependencies` are `bonjour-service` + `castv2-client`, both pure JS — no native Node addon ships in the app (only `extract-zip`, a build-time-only tool of electron-builder, has one). Modern Electron/Chromium needs nothing beyond the Universal CRT either. - Confirmed independently: nothing in the repo installs or bundles a VC++ Redistributable (`grep` for "vc_redist"/"redistributable"/"msvcp" across the whole tree → zero hits) — consistent with it not being needed. **Caveat — this is static, not a live test.** No genuine clean Windows 11 VM run (no dev tools, no pre-existing redistributables) has been done; the build machine already has everything installed, so a passing smoke test there proves nothing about a truly bare machine. This narrows, but does not close, the "clean-machine install check" item below — and a future dependency bump (a new PyPI package with a C++ extension, a future Electron native module) could silently reintroduce a need for it with nothing here to catch that ahead of time. **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 still a manual check that has not been run. ### 5.5 File permissions (W5) **Scope:** scattered, mechanical Guard all `chmod` calls: ```python if sys.platform != "win32": path.chmod(0o600) ``` For `keystore.py:97` — skip the mode check on Windows: ```python if sys.platform != "win32": mode = oct(key_file.stat().st_mode)[-3:] if mode != "600": log.warning(...) ``` For JS `{ mode: 0o600 }` — harmless on Windows (Node.js ignores it on NTFS), leave as-is. **Estimated scope:** ~10 lines, trivial guards. ### 5.6 ffmpeg discovery (W6) **Scope:** `config.py` + `daemon.py` startup Add to `node.toml`: ```toml [node] # ffmpeg_path = "ffmpeg" # default: found via PATH ``` At daemon startup, resolve with `shutil.which(cfg.ffmpeg_path)` and fail with a clear message if not found. Pass the resolved path to every subprocess call. On Windows, recommend installing ffmpeg via winget (`winget install ffmpeg`) or bundling it with the installer. **Estimated scope:** ~30 lines + plumbing the resolved path. ### 5.7 CLI messages (W7) **Scope:** `daemon.py`, `config.py` Platform-conditional output in `init`, example configs with Windows paths when `sys.platform == "win32"`. **Estimated scope:** ~20 lines, cosmetic. ### 5.8 ICE interface naming (W9) **Scope:** `transport/ice_filter.py` `ice_interfaces` restricts ICE gathering to named adapters. The filter compared the operator's entry against `ifaddr`'s `adapter.name` only — the kernel name on Linux (`wlp3s0f0`), but the adapter **GUID** on Windows (`{846EE342-7039-11DE-9D20-806E6F6E6963}`). A setting written on Linux, or copied into a Windows guest's `node.toml`, therefore matched no adapter at all. The failure was silent and total rather than partial: aioice binds one socket per host address, so an empty list means no sockets, no host candidates, and an SDP offering only a server-reflexive address. Behind two NATs — a VM on a libvirt NAT inside a LAN — that address is unreachable for a peer on the intermediate LAN, so ICE never completes and the symptom reads as a network fault, not a config one. An entry now matches the adapter name, the device description (`adapter.nice_name`, which is what Windows populates), or one of the adapter's own IPv4 addresses — the settings field is free text with no picker, so operators write whichever identifier they can see. A filter that matches nothing falls back to the unfiltered list with a warning: losing the 5 s timeout saving is a regression, being unconnectable is a defect. ### 5.9 CTRL_CLOSE_EVENT / taskkill orphaning ffmpeg (2026-09-05) **Scope:** `meshbay_node.platform` + `daemon.py` + `src/main.js` Two separate gaps, both flagged in project memory as "not a missing dependency, but may leave a zombie ffmpeg process": **Closing the console.** CPython's own console-control handler claims `CTRL_C_EVENT`/`CTRL_BREAK_EVENT` (delivered as `SIGINT`/`SIGBREAK`) but returns "not handled" for `CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT` and `CTRL_SHUTDOWN_EVENT` — none of the three has a Python signal. Without a handler of our own, Windows just ends the process for these — no `_shutdown()`, no closed WebRTC sessions, no killed ffmpeg. Covers: closing the console window of an interactively-run `meshbay-node run`, user logoff, system shutdown. `platform.install_console_close_handler()` registers one via `ctypes.windll.kernel32.SetConsoleCtrlHandler`, wired into `daemon.py`'s existing win32 signal block. MSDN's own rule for these three events — "the process is ended after the handler returns, or after 5 seconds, whichever occurs first" — means the handler (which runs on a thread Windows creates, never the main one) must *block* rather than return immediately: it nudges the asyncio loop the thread-safe way, then waits (capped just under that ceiling) for a `threading.Event` that `run()` sets right after `await self._shutdown()` actually finishes. **`taskkill` / the Stop button.** `autostart_end()` and Electron's `killNodeProcesses()` both used `taskkill /IM meshbay-node.exe /F` — a hard `TerminateProcess`, uncatchable on any OS (like `SIGKILL`), so no handler above could ever help here regardless. Fixed differently: `autostart_run()` now spawns with `CREATE_NEW_PROCESS_GROUP` instead of `DETACHED_PROCESS` (the child keeps no visible window, but does keep a console object of its own and becomes the root of a signalable process group — `DETACHED_PROCESS` has no console at all, so nothing could ever be signalled that way), and records its pid in `state_dir()/node.pid`. `autostart_end()` now tries `os.kill(pid, signal.CTRL_BREAK_EVENT)` first — which `daemon.py`'s win32 signal block (now also catching `SIGBREAK`) turns into the same `stop_event.set()` SIGINT/SIGTERM already use — polls (bounded, 5 s) for exit, and only falls back to the hard `taskkill` if that pid is stale (reused by an unrelated process — checked by image name before ever signalling it), already gone, or does not exit in time. Electron's `killNodeProcesses()` no longer does its own `taskkill`; it shells out to ` autostart stop` instead, so the graceful-then-forceful logic lives in exactly one place rather than two that could silently diverge. **Not covered, deliberately:** `taskkill /F` itself, run by a human or another tool, bypasses all of the above the same way `kill -9` would on Linux — this narrows how often that path is *taken* (the Node page's own Stop no longer takes it first), not what happens on the rare occasion something forces it. Service mode's Scheduled Task stop (`schtasks /end`) was deliberately left alone — Task Scheduler owns that process's creation flags and its own stop semantics are a separate, unverified surface, not something `service.ps1` controls. ### 5.10 System tray (2026-09-05) **Scope:** `preload.js` + `src/main.js` (two lines) + `test_desktop_shell.py` The GNOME tray (minimise-to-indicator, Start/Stop menu) was built against `nodeService.status()/stop()/restart()` — the same abstraction the Node page's own Stop/Restart buttons use, which already had full win32 branches (§5.3) covering both startup modes. So the tray needed no new logic for Windows, only its two platform gates widened from Linux-only to Linux-or-win32: the `tray` capability in `preload.js`, and the `window:minimize-to-tray` IPC handler in `main.js`. Reuses the existing `tray-icon.png`/`@2x.png` (already packaged for every OS via `package.json`'s `files`, no separate Windows list). `test_desktop_shell.py` now pins that both gates name the same platform set — nothing did before. ### 5.11 The tray is created at launch (2026-09-08) **Scope:** `src/main.js`, `src/preload.js`, `static/platform.js`, `static/app.js`, `test_desktop_shell.py` `ensureTray()` was reachable only from the `window:minimize-to-tray` handler, so the indicator did not exist until you had already hidden the window into it. That is backwards on both desktops — most of what a tray is for is finding an application that is *not* in front of you — and on Windows it read as the app having no tray presence at all. It is now created during `app.whenReady()`, after `registerBridge()` and before `createWindow()`. The order matters: `buildTrayMenu` reads the `nodeService` that `registerBridge` assigns, so creating the tray first would put the Start/Stop entry on the menu one five-second poll late. The menu's labels were the one thing that came *from* the minimise call, since the main process has no i18n. A new `tray:labels` IPC (`platform.setTrayLabels`) carries them instead, sent from the renderer's boot once `initLocale()` has a catalogue; a language change reloads the page, so the same call covers it. The window between launch and that first message shows `TRAY_FALLBACK`, in English. The two platform gates of §5.10 are now one — `trayOS()` in `main.js`, which every tray path calls — and `test_desktop_shell.py` pins the launch ordering, that no tray path tests `process.platform` inline instead of calling the gate, and (as before) that the gate and the `preload.js` capability name the same platform set. **Not fixed here, because it cannot be:** Windows puts a new tray icon in the hidden-icons overflow until the person drags it onto the taskbar. No API promotes it, and an installer that edits the registry to force it is the kind of thing this project does not do. --- ## 6. Execution order ``` 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) W9 ICE interface naming ✅ done (name/description/IP match + fail-open) ──── milestone: daemon runs on Windows ✅ (verified end to end) ──── W3 Daemon lifecycle ✅ done — Startup-folder .vbs (default) + S4U Scheduled Task service mode, both switchable post-install from the Node page ──── milestone: daemon starts/stops on Windows ✅ ──── W4 Packaging ✅ built — one per-user NSIS installer, client + node Dependency audit ✅ done (2026-09-05, static) — no VC++ Redistributable needed; see §5.4 CTRL_CLOSE_EVENT handler ✅ done (2026-09-05) — see §5.9 ──── milestone: installable on Windows — pending a clean-machine run ──── Clean-machine install ← open: no live test on a genuinely bare Windows 11 VM Authenticode signing ← Phase 13.9 CI Windows matrix ← Phase 18.3 ``` W1–W2–W5–W6–W7–W8 shipped as a run of mechanical commits, testable on Linux. W9 came out of a live guest that could not connect at all. W3 and W4 were the real work; the service-mode toggle (§5.3) followed from a real bug found while testing W4, not from the original plan. --- ## 7. Testing strategy ### What can be tested on Linux - **Platform directories:** mock `sys.platform` and `os.environ`, verify the returned paths match `%LOCALAPPDATA%` layout. - **Signal handling:** the guard itself (`sys.platform` branch) is testable; the actual Windows signal behavior needs a Windows run. - **File permissions:** verify the `chmod` calls are skipped when `sys.platform == "win32"` is mocked. - **ffmpeg discovery:** mock `shutil.which` returning `None`, verify the startup error. - **CLI messages:** snapshot test the output with mocked platform. - **Path construction:** `test_paths.py` already covers Windows rules. ### What needs the Windows VM - **Integration:** daemon startup, WebRTC connection, file indexing on NTFS, video streaming with ffmpeg, upload/download cycle. - **Removable media:** plug/unplug a USB drive, verify the per-root unavailable state and the `ReadDirectoryChangesW` watcher recovery. - **Packaging:** build the NSIS installer, install, verify file placement, verify `safeStorage`/DPAPI works, verify Task Scheduler autostart. - **Electron:** visual check of the UI, native save dialog, node management panel (once W3 is done). - **Long paths:** a library with a path exceeding 260 characters on NTFS with long paths enabled. - **Case collisions:** a group indexed on ext4 containing `Film.mkv` and `film.mkv`, opened from a Windows client. ### CI (future, Phase 18.3) Add a Windows runner to GitHub Actions: - `pytest` on Windows with a real NTFS filesystem - Electron build for Windows - The filesystem portability tests from `test_paths.py` run natively rather than via mock --- ## 8. Dependencies and prerequisites | Dependency | Linux | Windows | |---|---|---| | Python 3.12+ | system / venv | build machine only — end users get a frozen `.exe`, no install needed | | ffmpeg / ffprobe | system package | bundled in the installer by default (§5.4) | | Node.js 22+ | `/opt/nodejs` | installer from nodejs.org, build machine only | | Electron 42+ | npm | npm (same) | | aiortc | pip | pip (same — pure Python) | | watchdog | pip | pip (same — uses `ReadDirectoryChangesW`) | | Argon2 WASM | vendored in `static/vendor/` | same | | safeStorage backend | libsecret / kwallet | DPAPI (built into Windows) | | VC++ Redistributable | n/a | **not needed** — confirmed by static dependency audit, §5.4 | No new Python dependency was needed for either autostart mode. Service mode uses a Scheduled Task (S4U logon), not `pywin32`/NSSM — see §5.3. --- ## 9. What is NOT in scope - **The hub** — stays Linux. No Windows port planned or needed. - **macOS** — not planned. `safeStorage` already handles darwin, but no packaging, no daemon lifecycle, no testing. - **Windows ARM** — not considered. Electron supports it; Python and ffmpeg availability would need checking. - **Windows Store / MSIX** — not planned for v1. NSIS per-user installer is the target.