aboutsummaryrefslogtreecommitdiffstats
path: root/packaging/win/README.md
blob: 3c141d1754735520ee4888c99c1aaa176198d95e (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
# Windows packaging (W4)

A single per-user installer — `MeshBay-Setup-<version>.exe` — that lays down the
Electron **client** and the Python **node** daemon. No hub (server-only, stays
Linux). `meshbay-common` rides along inside the node runtime.

## What the installer contains

```
%LOCALAPPDATA%\Programs\MeshBay\          (productName, not the npm package name)
├─ MeshBay.exe                     Electron client
├─ resources\
│  ├─ app.asar                     src/ + ui/ (the interface ships in the package)
│  ├─ firewall.ps1                 adds/removes the four inbound rules (see below)
│  ├─ service.ps1                  install/remove/status/run/end the boot-time task
│  ├─ service-mode.ps1             elevated helper: service.ps1 + firewall.ps1 in one UAC prompt
│  └─ node-runtime\
│     ├─ meshbay-node.exe          frozen daemon (PyInstaller onedir)
│     ├─ _internal\ …              its Python + deps (aiortc, av, aioquic, …)
│     ├─ ffmpeg.exe, ffprobe.exe   bundled by default, see Video (ffmpeg) below
│     ├─ av*.dll, swscale/swresample*.dll   what those two link against
│     └─ LICENSE-ffmpeg.txt        GPLv3 notice + where the source is
└─ Uninstall MeshBay.exe
```

Runtime data stays where the node already puts it: `%LOCALAPPDATA%\meshbay\`
(`node.toml`, `keystore.enc`, `unlock.key`, `data\`). The installer never writes
there and the uninstaller never deletes it — installers place files, not secrets.
That is also why service mode needed no code changes to `platform.py`: it runs
as this same signed-in user (S4U, see below), so it is the same profile either
way — not LocalSystem/NetworkService, which would have none of this.

`build/installer.nsh` also adds `…\resources\node-runtime` to the **per-user**
`Path` (`HKCU\Environment`) so `meshbay-node` works in a terminal, and takes it
back out on uninstall. New shells only — a `WM_SETTINGCHANGE` broadcast nudges
open ones. It uses stock `WordFunc.nsh` (the `EnVar` plugin is not in
electron-builder's NSIS bundle).

## When the node runs: three modes, chosen at install time

Setup's radio page, and the Node page's *Start automatically* selector
afterwards, offer the same three:

| Mode | What runs it | Stops when |
|---|---|---|
| **Only while MeshBay is open** | the desktop app, at launch (once the node has been set up) | the app quits — only a node the app started |
| **At sign-in** (no admin) | a `.vbs` in the Startup folder (`meshbay_node.platform._startup_vbs`); `meshbay-node autostart install` / `remove` | sign-out (a hidden console of its own delivers CTRL_LOGOFF) |
| **Background service** (one admin confirmation) | the boot-time Scheduled Task below | shutdown |

The first mode used to do neither half: nothing started the node with the app
(after a reboot a group stayed offline with MeshBay open) and nothing stopped it
at quit. The two automatic ones are mutually exclusive — both would start the
node twice — and `autostart install` refuses while the boot task exists.

**Starting, stopping, restarting — one implementation.** The CLI's
(`meshbay_node/cli/lifecycle.py`); the Node page, the tray, `node:start` and a
terminal all go through it. A stop asks the node through its own control API
first (`POST /api/shutdown`, loopback, per-run token): the only channel that
reaches a node in any session with no elevation, and the one that runs its
`_shutdown()` — WebRTC sessions closed, transcodes stopped. Then Task Scheduler,
then `taskkill`. Before this, every stop of a Windows node was a
TerminateProcess (nine in a row, not one shutdown logged), the CLI's own
CTRL_BREAK reached every process on *its* console and killed itself, and the app
reported a service node it could not reach as stopped. A start launches the node
with nothing of the caller's inherited (a child of Electron held Electron's
sockets after the app quit) and reports the version that answered.

**Switching modes.** Leaving service mode stops the node first — deleting a
task does not end its running instance, which ran on in session 0 with nothing
able to stop it — removes the task, keeps the firewall rules (every mode needs
them; removing them left a node that silently accepted no connections), and
starts the node again in the new mode. Entering it stops the running node first,
or the service's own finds the control API's port taken and quits.

**Background service.** A Scheduled Task,
`meshbay_node.platform.service_install` / `packaging/win/service.ps1`, that
starts **at boot, before anyone signs in**. A real Windows Service would run
under LocalSystem/NetworkService — accounts with no normal user profile, so
`%LOCALAPPDATA%\meshbay\` (config, keystore, data) would not exist for it.
Relocating storage to make that work is real surgery, deliberately not this.

The alternative used instead: `schtasks /create ... /ru <user> /rp ""` with no
`/it` registers an **S4U** (Service For User) logon — no password stored
anywhere, and unlike LocalSystem it loads *this account's own profile*, so
`%LOCALAPPDATA%\meshbay\` keeps working with zero code changes. The cost: S4U
carries no network credential (cannot reach a domain share as this user),
which the node never needed — everything it touches is local disk plus
outbound internet.

Creating the task needs admin (a boot trigger touches system-wide scheduler
state — the same reason `/sc onlogon` needed it too, back when Task Scheduler
was tried for the per-user mode and abandoned for exactly that reason).
Querying, starting and stopping an *already-created* task does not — Task
Scheduler grants the owning user that much itself, which is what lets the Node
page's Start/Stop/Restart drive it with no further UAC prompts.

**The task's settings.** Registered with no execution time limit, allowed to
start and keep running on battery, `MultipleInstances IgnoreNew` and
`StartWhenAvailable`. Task Scheduler's defaults end a task after 72 hours,
never start it on battery and stop it when the cable comes out — each one a
node that was simply down. `service.ps1 status` reports a task that still has
those defaults, or that runs another executable than this install's, as stale
(exit 2), and setup registers it again (its one elevation).

**Upgrading a running node.** A service node lives in the task's S4U session,
so an unelevated `taskkill` from setup gets "Access is denied" — and
electron-builder's `customInstall` only runs after the files are copied anyway.
Left running, a node keeps `meshbay-node.exe` locked, the copy fails, and
electron-builder's last-resort extract ignores that. So `customCheckAppRunning`,
which electron-builder runs before `uninstallOldVersion` and before extraction,
runs `build/stop-node.ps1` (embedded in the installer — the installed copy of
anything may be what is being replaced): the control API first, then
`schtasks /end`, then `taskkill`, until no `meshbay-node.exe` is left; if one
will not stop, setup says so and quits rather than half-upgrade.
An upgrade keeps the mode it finds (`customInit` reads the task, then the
launcher, then a previous install), restores the sign-in launcher — the previous
version's uninstaller deletes it — and starts the node again the way that mode
runs it. A silent upgrade of an "at sign-in" install used to come out with no
autostart at all and its node stopped.

**Logs.** A daemon started by the task or the Startup launcher has no console,
so it also logs to `%LOCALAPPDATA%\meshbay\state\node.log` (rotated at 5 MB,
three kept). That file is where to look when the app says the node did not
start. `meshbay-node service start` waits for the daemon's control API and
reports the version that answered, or points at this file.

**One elevation, not two.** Choosing service mode needs admin for both the
Scheduled Task *and* the firewall rules; `service-mode.ps1` runs both from a
single `ExecShellWait "runas"` in `installer.nsh`, so the choice costs exactly
one UAC prompt. Re-running setup (an upgrade, a repair install) asks nothing
if the firewall rules are already there — checked first, unelevated, the same
pattern the per-user-only firewall step already used.

## Build

On a Windows machine with **Node ≥ 22**, **Python ≥ 3.12** (`py -3.12`) and
**git**:

```powershell
cd packages\meshbay-client
npm run dist:win
```

That runs [`build-win.ps1`](build-win.ps1):

| Step | |
|---|---|
| 1 | Node ≥ 22 check |
| 2 | `npm ci` + download Electron's Chromium |
| 3 | bump Electron to the latest release (Chromium CVE policy; `-NoElectronBump` to skip) |
| 4 | `npm run sync-ui` — copy the interface from `meshbay-hub/.../static` |
| 5 | [`build-node-runtime.ps1`](build-node-runtime.ps1) — PyInstaller freeze → `packages/meshbay-client/node-runtime/`, then [`smoke-node-runtime.ps1`](smoke-node-runtime.ps1) starts the frozen daemon in a throwaway profile and checks it answers with the right version and writes its log |
| 6 | `electron-builder --win nsis` → `packages/meshbay-client/dist/MeshBay-Setup-<version>.exe` |

### Video (ffmpeg)

**Bundled by default** — [`fetch-ffmpeg.ps1`](fetch-ffmpeg.ps1) downloads,
checksum-verifies and stages it into `node-runtime/` on every build, ~161 MB.
Not optional in practice: MeshBay transcodes browser-incompatible video to
H.264 (a real encode, not remux), and no LGPL-only ffmpeg build includes an
H.264 *encoder* — libx264 itself is GPL. libx264 is also the floor rather
than the only path: this build carries `h264_qsv` and `h264_nvenc` as well,
which is what lets `hwaccel.py` move the encode onto an Intel or NVIDIA GPU
on Windows without a second download. **A re-pin that dropped those would
cost every low-power Windows node its hardware encoding, silently** — the
node would simply go back to libx264. Asking an end user
to separately run `winget install ffmpeg` was considered and rejected: it
needs network access and `winget`/App Installer present at that exact moment,
and its failure mode is silent — video just does not stream, with nothing
pointing back at ffmpeg.

Source: [BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds), the
Windows x86_64 **gpl-shared** preset — `ffmpeg.exe`/`ffprobe.exe` plus the
DLLs they both link against, rather than two independent static binaries
(the "full" static build many devs already have via `winget install ffmpeg`
is ~220 MB *per executable*; the equivalent Linux install used a shared
build for the same reason). `ffplay.exe` (an SDL2 player, ~17 MB) is dropped
— the daemon never invokes it. Pinned to one dated release tag, not the
`latest` alias BtbN repoints on every auto-build; both the tag and the sha256
are hardcoded in the script and re-pinning is a deliberate edit, not
automatic. `LICENSE-ffmpeg.txt` (GPLv3 notice + where the source is) rides
along in the same directory — required, since this redistributes a GPL
binary even though it is unmodified and invoked only as a subprocess.

```powershell
npm run dist:win -- -SkipFfmpeg   # smaller, streaming-less build for local iteration only
```

### Iterating

```powershell
# rebuild only the installer, reuse an existing node-runtime\
powershell -File ..\..\packaging\win\build-win.ps1 -SkipNodeRuntime

# build just the frozen daemon, into an existing venv, keep it for next time
powershell -File ..\..\packaging\win\build-node-runtime.ps1 -Python C:\path\python.exe -KeepBuildVenv
```

## Why PyInstaller and not the python-embed zip

The frozen `meshbay-node.exe` is a genuine relocatable single binary. It is what
the client spawns (`findNodeBinary` in `src/main.js`) and what the W3 autostart
launcher points at (`meshbay_node.platform._node_exe`). The python.org
embeddable zip would need pip to make a `meshbay-node.exe` wrapper, and that
wrapper bakes in an **absolute** interpreter path — it stops working the moment
the tree is installed somewhere other than where it was built.

## Networking (running the node)

The node's admin API is loopback-only, but its **transport is not**. WebRTC
binds an ephemeral UDP port per connection and — because browsers/Electron
publish their host candidate as an unresolvable `<uuid>.local` mDNS name that
`aioice` discards — the browser always dials the node, never the reverse. So the
node has to accept unsolicited inbound UDP from its peers.

**Windows Defender Firewall.** The setup wizard offers to add four inbound
rules in one step — it needs one admin confirmation (`build/installer.nsh`
runs `firewall.ps1` via NSIS `ExecShellWait "runas"`; the per-user install
itself never elevates):

| Rule | Program | Protocol / port | For |
|---|---|---|---|
| `MeshBay` | `MeshBay.exe` | UDP, any port | WebRTC (ICE checks touch the client too) |
| `MeshBay Node` | `meshbay-node.exe` | UDP, any port | WebRTC (the node — unsolicited inbound, see above) |
| `MeshBay Cast` | `MeshBay.exe` | TCP 19550–19553 | LAN cast HTTP relay (`src/cast-relay.js`) |
| `MeshBay Cast Discovery` | `MeshBay.exe` | UDP 5353 | Chromecast/Smart TV mDNS discovery (`bonjour-service`) |

The WebRTC rules have no port restriction because there is no fixed port to
name — aiortc binds a fresh one per connection — so they are scoped by
*program* instead, which is the Windows-native answer to the same problem the
Linux packaging solves with a broad `1024-65535/udp` range
(`packaging/firewall/`). The cast rules are scoped by program *and* port,
since those two are fixed and known — matching
`packaging/firewall/*/meshbay-cast.xml` exactly.

Setup checks first, **unelevated** (`firewall.ps1 check` — reading rules needs
no admin, only creating them does), so running the installer again — an
upgrade, a repair install — asks nothing and never pops UAC a second time once
the rules are in place. Say yes the first time and every prompt you'd
otherwise hit mid-use — connecting, or the first cast — is gone. Say no, or
the UAC prompt is dismissed, and Windows
falls back to its own **"Allow access"** dialog the first time each
program/port combination is used — tick **both Private and Public** then (a
libvirt/VM adapter, and sometimes a plain Ethernet one, registers as Public; a
Private-only rule silently drops every peer). Missed one? Add it by hand:
*Windows Defender Firewall → Advanced → Inbound Rules → New Rule → Program →*
the exe *→ Allow → all profiles*, restricting to the port above if it is a
cast rule. `firewall.ps1` is idempotent and re-runnable
(`powershell -File resources\firewall.ps1 add`, elevated); it logs to
`%TEMP%\meshbay-firewall.log`.

**A flat LAN needs nothing else.** The node offers a routable `192.168.x.y` host
candidate and browsers on the same subnet connect straight to it — same as the
residential-NAT cases that work without TURN.

**A routed or multi-subnet LAN** (peers on `192.168.1.x` reaching a node on
`192.168.2.x`, or a VM) additionally needs:

- routing between the subnets (the two ends must have a path to each other's
  host-candidate address);
- inbound UDP on the ephemeral range allowed for `meshbay-node.exe` **and** for
  any Linux host in the path — see the packaged `packaging/firewall/` profiles
  and the "inbound WebRTC" section of `docs/PACKAGING-GUIDE.md`;
- `MESHBAY_WEBRTC_EXPOSE_LOCAL_IPS` is not relevant to the node — that switch is
  the *client's*, and it is already on by default (`src/main.js`).

**libvirt NAT is the awkward case.** A guest on the default NAT network is
reachable from its own hypervisor but not from other LAN machines, and its only
host candidate is the `192.168.122.x`/`192.168.200.x` address no third machine
can route to. Give the guest bridged (or macvtap) networking so it gets a real
LAN address, or run the node on the host. The node log now prints the host
addresses it offered — `WebRTC answer ready for peer=… (… host: 192.168.200.173,
1 srflx)` — so "did the node even offer something routable" is answerable from
the journal.

## Dependency surface that needs watching

`meshbay-node.spec` pulls the awkward packages in whole (`collect_all`) because
they have C/Rust extensions or do dynamic imports: `aiortc`, `av` (bundles
FFmpeg DLLs), `aioquic`, `pydantic_core`, `uvicorn`, `watchdog`, `guessit`,
`blake3`, `zstandard`, `msgpack`. If a frozen run raises `ModuleNotFoundError`,
add the package to `COLLECT_ALL` / `HIDDEN` / `COPY_META` in the spec — that is
the expected way the list grows.

## Not done

- **Authenticode signing** — Phase 13.9. Unsigned installer triggers SmartScreen.
- **CI** — no Windows runner builds this yet (Phase 18.3). Build is manual.
- **Delta updates / auto-update** — `electron-updater` is not wired.