# MeshBay — Windows Port > 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 `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 **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 `desktop-client-v1.md` §7.5, 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 — `schtasks /create ... /sc onstart /ru /rp ""`, no `/it` — 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. `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. --- ## 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.