summaryrefslogtreecommitdiffstats
path: root/docs/WINDOWS-PORT.md
blob: e33b6f5669c6fc89bf495b4363e3b6e938dfbf8f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
# 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 — 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-<version>.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 `<bin> 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.

---

## 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.