aboutsummaryrefslogtreecommitdiffstats
path: root/docs/WINDOWS-PORT.md
blob: da281c3e04bc50e988ee7fbcb3864caf9e178e36 (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
# MeshBay — Windows Port

> Status: **W1–W3 + W5–W8 done; W4 packaging built, not yet run on a clean machine.**
> Created 2026-09-03 from a full codebase scan; progress notes added 2026-09-04.
> This document is both the audit results and the implementation plan.
> It does not repeat the design decisions already in `desktop-client-v1.md`
> (§6.8, §7.5, decisions E8/E12) — read that first.
>
> **What shipped (branch `win-webrtc-stun`):** platform dirs / signals / perms /
> ffmpeg discovery / CLI messages (W1-2-5-6-7-8, commits through `c2620a5`);
> event loop stays Proactor (`5098e6c`); JWT clock-skew leeway (`dad2157`);
> daemon lifecycle via a Startup-folder `.vbs` launcher, **not** Task Scheduler
> (`220e6e7` — `schtasks /create /sc ONLOGON` needs elevation, see §5.3). W4 is
> `packaging/win/` + `package.json` `build.win` — see §5.4.

---

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

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

**Scope:** `daemon.py` CLI + `src/main.js` IPC handlers

This is the largest work item. Two modes, matching `desktop-client-v1.md`
§7.5:

#### Per-user mode (default)

The daemon runs as a regular process. Autostart via:
- **Startup folder shortcut**, or
- **Task Scheduler** logon task (preferred — survives "disable startup
  apps" and has retry semantics).

The Electron client manages this through Task Scheduler COM or `schtasks.exe`:
- `node:start` → `schtasks /create /tn MeshBayNode /tr "meshbay-node run" /sc ONLOGON /rl LIMITED`
- `node:service-stop` → `schtasks /end /tn MeshBayNode` + kill the process
- `node:service-status` → `schtasks /query /tn MeshBayNode` + check if the process is running
- `node:installed` → check if `meshbay-node.exe` exists in known locations

The Python CLI equivalents:
- `reload` on Windows → find the running daemon process and send a custom
  event (named pipe or a reload-flag file the daemon watches), or just
  restart
- `restart-daemon` → kill + start
- `reset` → remove scheduled task + delete data dirs

#### Service mode (optional, elevated)

A Windows Service under a dedicated low-privilege account. Uses
`pywin32`'s `win32serviceutil` or a wrapper like NSSM. This is **Phase 2**
of the Windows port — per-user mode ships first.

**Estimated scope:** ~200 lines Python + ~150 lines JS for per-user mode.
Service mode is a separate milestone.

### 5.4 Packaging (W4) — **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.

**Still open:** Authenticode signing (Phase 13.9 — unsigned ⇒ SmartScreen),
a Windows CI runner (Phase 18.3), `electron-updater`. First clean-machine
install + `safeStorage`/DPAPI + autostart round-trip is a manual check.

### 5.5 File permissions (W5)

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

---

## 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 (Task Scheduler needs admin)
    ──── milestone: daemon starts/stops on Windows ✅ ────
W4  Packaging                 ✅ built — one per-user NSIS installer, client + node
    ──── milestone: installable on Windows — pending a clean-machine run ────
    CTRL_CLOSE_EVENT handler  ← open: window-close / bare taskkill skips _shutdown()
    Service mode (W3b)        ← Phase 2, optional
    Authenticode signing      ← Phase 13.9
    CI Windows matrix         ← Phase 18.3
```

W1–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.

---

## 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 | `python-embed` zip or full install |
| ffmpeg / ffprobe | system package | `winget install ffmpeg` or bundled |
| Node.js 22+ | `/opt/nodejs` | installer from nodejs.org |
| 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) |

No new Python dependency is needed for per-user mode. Service mode (W3b)
would need `pywin32` or NSSM.

---

## 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.
- **Service mode** — Phase 2 of the Windows port, after per-user mode is
  validated.