aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/WINDOWS-PORT.md225
-rw-r--r--docs/windows-build.md92
-rw-r--r--packages/meshbay-client/src/main.js52
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js111
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py18
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py169
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py19
-rw-r--r--packages/meshbay-node/tests/test_platform.py192
18 files changed, 831 insertions, 147 deletions
diff --git a/docs/WINDOWS-PORT.md b/docs/WINDOWS-PORT.md
index da281c3..8fc169b 100644
--- a/docs/WINDOWS-PORT.md
+++ b/docs/WINDOWS-PORT.md
@@ -1,7 +1,10 @@
# 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.
+> 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.
@@ -9,9 +12,10 @@
> **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.
+> 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.
---
@@ -82,7 +86,9 @@ 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.
+`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
@@ -269,41 +275,78 @@ SIGHUP is already guarded. No other signal handling in the codebase.
**Estimated scope:** ~10 lines changed.
-### 5.3 Daemon lifecycle on Windows (W3)
+### 5.3 Daemon lifecycle on Windows (W3) — done, both modes
-**Scope:** `daemon.py` CLI + `src/main.js` IPC handlers
+**Scope:** `daemon.py` CLI + `meshbay_node.platform` + `src/main.js` IPC handlers
++ `node-page.js`
-This is the largest work item. Two modes, matching `desktop-client-v1.md`
-§7.5:
+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)
+#### Per-user mode (default, no admin)
-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 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.
-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
+#### Service mode (one admin confirmation)
-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
+A Scheduled Task via **S4U** (Service For User) logon —
+`schtasks /create ... /sc onstart /ru <user> /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`).
-#### Service mode (optional, elevated)
+**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.
-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.
+#### Found 2026-09-05: the installer's mode question is effectively one-shot
-**Estimated scope:** ~200 lines Python + ~150 lines JS for per-user mode.
-Service mode is a separate milestone.
+`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**
@@ -340,9 +383,47 @@ because the transcode path needs libx264 and no LGPL-only build has one;
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 a manual check.
+install + `safeStorage`/DPAPI + autostart round-trip is still a manual check
+that has not been run.
### 5.5 File permissions (W5)
@@ -421,6 +502,58 @@ 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.
+
---
## 6. Execution order
@@ -434,19 +567,24 @@ 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)
+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 ────
- CTRL_CLOSE_EVENT handler ← open: window-close / bare taskkill skips _shutdown()
- Service mode (W3b) ← Phase 2, optional
+ 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.
+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.
---
@@ -494,17 +632,18 @@ Add a Windows runner to GitHub Actions:
| 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 |
+| 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 is needed for per-user mode. Service mode (W3b)
-would need `pywin32` or NSSM.
+No new Python dependency was needed for either autostart mode. Service mode
+uses a Scheduled Task (S4U logon), not `pywin32`/NSSM — see §5.3.
---
@@ -517,5 +656,3 @@ would need `pywin32` or NSSM.
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.
diff --git a/docs/windows-build.md b/docs/windows-build.md
new file mode 100644
index 0000000..88133cb
--- /dev/null
+++ b/docs/windows-build.md
@@ -0,0 +1,92 @@
+# Building MeshBay on Windows
+
+A quick, linear path from a fresh `git clone` to `MeshBay-Setup-<version>.exe`.
+For *why* things are done this way (firewall rules, autostart modes, the
+dependency surface, networking) see [`packaging/win/README.md`](../packaging/win/README.md)
+— this page is deliberately just the steps.
+
+## Prerequisites
+
+Install on the Windows machine that will build the installer:
+
+| Tool | Version | Check |
+|---|---|---|
+| [Node.js](https://nodejs.org) | 22 or newer | `node --version` |
+| Python | 3.12 or newer | `py -3.12 --version` (or `python --version`) |
+| Git | any recent | `git --version` |
+
+Nothing else — no VC++ Redistributable, no separate ffmpeg install. The build
+scripts create their own throwaway Python venv and fetch ffmpeg themselves.
+
+## 1. Clone
+
+```powershell
+git clone https://github.com/<owner>/meshbay.git
+cd meshbay
+```
+
+## 2. Build
+
+```powershell
+cd packages\meshbay-client
+npm run dist:win
+```
+
+This runs [`packaging/win/build-win.ps1`](../packaging/win/build-win.ps1), which:
+
+1. checks Node ≥ 22
+2. `npm ci` and downloads Electron's Chromium
+3. bumps Electron to its latest release (skip with `-NoElectronBump`)
+4. `npm run sync-ui` — copies the SPA from `meshbay-hub/.../static`
+5. runs [`build-node-runtime.ps1`](../packaging/win/build-node-runtime.ps1) —
+ creates a throwaway venv, installs `meshbay-node` + `meshbay-common`,
+ freezes the daemon with PyInstaller, fetches and verifies ffmpeg
+6. `electron-builder --win nsis`
+
+Expect this to take several minutes the first time (Chromium download,
+PyInstaller freeze, ffmpeg fetch). Output:
+
+```
+packages\meshbay-client\dist\MeshBay-Setup-<version>.exe
+```
+
+## 3. Options
+
+```powershell
+# smaller, streaming-less build for local iteration (skips the ~161 MB ffmpeg fetch)
+npm run dist:win -- -SkipFfmpeg
+
+# reuse an already-built node-runtime\ (faster iteration on the Electron side)
+powershell -File ..\..\packaging\win\build-win.ps1 -SkipNodeRuntime
+
+# keep Electron pinned instead of bumping to the latest release
+npm run dist:win -- -NoElectronBump
+```
+
+To rebuild just the frozen daemon on its own:
+
+```powershell
+powershell -File ..\..\packaging\win\build-node-runtime.ps1
+```
+
+## 4. Install and run
+
+Run `MeshBay-Setup-<version>.exe`. It is a **per-user** installer — no admin
+prompt unless you opt into service mode (background daemon that starts at
+boot, before sign-in) or accept the firewall rules, both offered during setup
+and both switchable afterwards from the Node page. Installs to
+`%LOCALAPPDATA%\Programs\MeshBay\`; runtime data lives in
+`%LOCALAPPDATA%\meshbay\`.
+
+## What's not automated yet
+
+- The installer is **unsigned** — Windows SmartScreen will warn on first run
+ (Authenticode signing is planned, not done).
+- No Windows CI runner builds this — it's a manual build today.
+- No genuine clean-machine (bare Windows 11 VM) install has been verified —
+ see `docs/WINDOWS-PORT.md` §5.4 for what has been checked instead.
+
+For everything else — what the installer actually contains, the two autostart
+modes, the firewall rules and why they're scoped the way they are, networking
+across NATs/VMs, and the dependency surface to watch when adding a Python
+package — read [`packaging/win/README.md`](../packaging/win/README.md).
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index dd7f38e..e996829 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -813,7 +813,16 @@ function registerBridge() {
const cmd = process.platform === 'win32' ? 'where.exe' : 'which';
return new Promise((resolve) => {
execFile(cmd, ['meshbay-node'], (err, stdout) => {
- resolve(err ? null : stdout.trim().split('\n')[0]);
+ if (err) { resolve(null); return; }
+ // where.exe/which can list more than one match on PATH, and each
+ // line keeps its own trailing \r on Windows -- `stdout.trim()` only
+ // strips the ends of the *whole* string, so with 2+ matches a stray
+ // \r stayed glued to the end of the first line. That \r then landed
+ // inside the quoted path this function's caller writes into the
+ // Startup .vbs, breaking VBScript's parser with "Unterminated
+ // string constant" the next time Windows tried to run it at sign-in.
+ const first = stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
+ resolve(first || null);
});
});
}
@@ -844,9 +853,24 @@ function registerBridge() {
try { fs.rmSync(WIN_STARTUP_VBS, { force: true }); } catch { /* not there */ }
}
- function killNodeProcesses() {
+ // Prefers a graceful stop: `autostart stop` now tries CTRL_BREAK_EVENT
+ // against the pid autostart_run() recorded first (meshbay_node.platform.
+ // autostart_end()), which daemon.py's SIGBREAK handler turns into a real
+ // _shutdown() -- closed WebRTC sessions, killed ffmpeg -- before that same
+ // function falls back to a hard `taskkill /F` itself. Keeping the
+ // graceful-then-forceful logic in that one place, rather than this
+ // function *also* going straight to taskkill, is what actually fixed it:
+ // two independent hard-kill call sites would still bypass shutdown one of
+ // the times. Only genuinely falls back to taskkill here when the binary
+ // cannot even be located.
+ async function killNodeProcesses() {
+ const bin = await findNodeBinary();
return new Promise((resolve) => {
- execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve());
+ if (bin) {
+ execFile(bin, ['autostart', 'stop'], () => resolve());
+ } else {
+ execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve());
+ }
});
}
@@ -897,7 +921,16 @@ function registerBridge() {
return new Promise((resolve, reject) => {
const script = path.join(process.resourcesPath, 'service-mode.ps1');
if (!fs.existsSync(script)) {
- reject(new Error('service-mode.ps1 not found — only available in an installed build'));
+ // service-mode.ps1 is an extraResource -- only present once installed
+ // (package.json build.win.extraResources); nothing under `npm start`.
+ // The Node page already disables the "background service" option
+ // when node:service-status reports canElevate: false, so this should
+ // only ever be reached if that guard is bypassed somehow -- keep the
+ // message actionable regardless.
+ reject(new Error(
+ 'Switching to a background service needs an installed build. For '
+ + 'local testing, run "meshbay-node service install" from an '
+ + 'elevated PowerShell instead.'));
return;
}
// Start-Process -Verb RunAs is the one UAC prompt; -Wait -PassThru hands
@@ -992,6 +1025,12 @@ function registerBridge() {
installed: true,
activeState: running ? 'active' : 'inactive',
subState: svc.state,
+ // Whether switching startup mode can actually elevate right now —
+ // service-mode.ps1 is an extraResource, only present in a packaged
+ // build. Already installed here, so removing it always works
+ // regardless; this only gates the Node page offering to switch
+ // *into* service mode.
+ canElevate: app.isPackaged,
};
}
// Per-user Startup mode. `installed` used to be winAutostartInstalled(),
@@ -1008,6 +1047,7 @@ function registerBridge() {
autostart: winAutostartInstalled(),
activeState: p ? 'active' : 'inactive',
subState: p ? 'running' : '',
+ canElevate: app.isPackaged,
};
}
if (process.platform !== 'linux') return { supported: false };
@@ -1038,8 +1078,8 @@ function registerBridge() {
if (process.platform === 'win32') {
const svc = await winServiceTaskStatus();
if (svc.installed) await winServiceTaskEnd();
- await killNodeProcesses(); // hard kill — no CTRL_CLOSE handler yet;
- // also the belt-and-suspenders in case /end left the process running
+ await killNodeProcesses(); // graceful-then-forceful; also the
+ // belt-and-suspenders in case /end left the process running
return { stopped: true };
}
if (process.platform !== 'linux') {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index bb81fee..49b5f9e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -678,11 +678,13 @@ export default {
'node.service_stopping': 'Wird angehalten…',
'node.service_restart': 'Neu starten',
'node.service_restarting': 'Wird neu gestartet…',
- 'node.autostart_label': 'Automatisch bei der Anmeldung starten',
- 'node.autostart_updating': 'Wird aktualisiert…',
'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.',
- 'node.service_mode_label': 'Als Hintergrunddienst ausführen (startet beim Booten, vor der Anmeldung)',
- 'node.service_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…',
+ 'node.startup_mode_label': 'Automatisch starten:',
+ 'node.startup_mode_off': 'Aus (manuell starten)',
+ 'node.startup_mode_signin': 'Bei der Anmeldung',
+ 'node.startup_mode_service': 'Als Hintergrunddienst (startet beim Booten)',
+ 'node.startup_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…',
+ 'node.startup_mode_service_unavailable_hint': 'Der Hintergrunddienst-Modus erfordert eine installierte Version. Führen Sie zum lokalen Testen "meshbay-node service install" in einer PowerShell mit Administratorrechten aus.',
'node.not_operator': 'Ihr Node konnte nicht erreicht werden. Stellen Sie sicher, dass er läuft.',
'node.offline': 'Node ist offline',
'node.retry': 'Erneut versuchen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 2817432..7ea8989 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -734,11 +734,13 @@ export default {
'node.service_stopping': 'Stopping…',
'node.service_restart': 'Restart',
'node.service_restarting': 'Restarting…',
- 'node.autostart_label': 'Start automatically at sign-in',
- 'node.autostart_updating': 'Updating…',
'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.',
- 'node.service_mode_label': 'Run as a background service (starts at boot, before sign-in)',
- 'node.service_mode_updating': 'Switching mode — check for an administrator prompt…',
+ 'node.startup_mode_label': 'Start automatically:',
+ 'node.startup_mode_off': 'Off (start manually)',
+ 'node.startup_mode_signin': 'At sign-in',
+ 'node.startup_mode_service': 'As a background service (starts at boot)',
+ 'node.startup_mode_updating': 'Switching mode — check for an administrator prompt…',
+ 'node.startup_mode_service_unavailable_hint': 'Background service mode needs an installed build. For local testing, run "meshbay-node service install" from an elevated PowerShell.',
'node.offline': 'Node is offline',
'node.no_groups': 'No groups configured on this node.',
'node.retry': 'Retry',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 0f3ce21..6109d4f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -674,11 +674,13 @@ export default {
'node.service_stopping': 'Deteniendo…',
'node.service_restart': 'Reiniciar',
'node.service_restarting': 'Reiniciando…',
- 'node.autostart_label': 'Iniciar automáticamente al iniciar sesión',
- 'node.autostart_updating': 'Actualizando…',
'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.',
- 'node.service_mode_label': 'Ejecutar como servicio en segundo plano (se inicia al arrancar, antes de iniciar sesión)',
- 'node.service_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…',
+ 'node.startup_mode_label': 'Iniciar automáticamente:',
+ 'node.startup_mode_off': 'Desactivado (iniciar manualmente)',
+ 'node.startup_mode_signin': 'Al iniciar sesión',
+ 'node.startup_mode_service': 'Como servicio en segundo plano (se inicia al arrancar)',
+ 'node.startup_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…',
+ 'node.startup_mode_service_unavailable_hint': 'El modo de servicio en segundo plano requiere una versión instalada. Para pruebas locales, ejecute "meshbay-node service install" desde una PowerShell con privilegios de administrador.',
'node.not_operator': 'No se pudo contactar con su node. Asegúrese de que esté en ejecución.',
'node.offline': 'Node sin conexión',
'node.retry': 'Reintentar',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c3c4846..926f64a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -677,11 +677,13 @@ export default {
'node.service_stopping': 'Arrêt…',
'node.service_restart': 'Redémarrer',
'node.service_restarting': 'Redémarrage…',
- 'node.autostart_label': 'Démarrer automatiquement à l\'ouverture de session',
- 'node.autostart_updating': 'Mise à jour…',
'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.',
- 'node.service_mode_label': 'Exécuter comme service en arrière-plan (démarre au boot, avant l\'ouverture de session)',
- 'node.service_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…',
+ 'node.startup_mode_label': 'Démarrer automatiquement :',
+ 'node.startup_mode_off': 'Désactivé (démarrage manuel)',
+ 'node.startup_mode_signin': 'À l\'ouverture de session',
+ 'node.startup_mode_service': 'Comme service en arrière-plan (démarre au boot)',
+ 'node.startup_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…',
+ 'node.startup_mode_service_unavailable_hint': 'Le mode service en arrière-plan nécessite une version installée. Pour un test local, exécutez "meshbay-node service install" depuis un PowerShell administrateur.',
'node.not_operator': 'Impossible de joindre votre node. Vérifiez qu\'il est en cours d\'exécution.',
'node.offline': 'Node hors ligne',
'node.retry': 'Réessayer',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index f39ab3c..f7011ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -676,11 +676,13 @@ export default {
'node.service_stopping': 'Arresto…',
'node.service_restart': 'Riavvia',
'node.service_restarting': 'Riavvio…',
- 'node.autostart_label': 'Avvia automaticamente all\'accesso',
- 'node.autostart_updating': 'Aggiornamento…',
'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.',
- 'node.service_mode_label': 'Esegui come servizio in background (si avvia all\'avvio del sistema, prima dell\'accesso)',
- 'node.service_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…',
+ 'node.startup_mode_label': 'Avvia automaticamente:',
+ 'node.startup_mode_off': 'Disattivato (avvio manuale)',
+ 'node.startup_mode_signin': 'All\'accesso',
+ 'node.startup_mode_service': 'Come servizio in background (si avvia all\'avvio del sistema)',
+ 'node.startup_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…',
+ 'node.startup_mode_service_unavailable_hint': 'La modalità servizio in background richiede una build installata. Per test locali, eseguire "meshbay-node service install" da un PowerShell con privilegi di amministratore.',
'node.not_operator': 'Impossibile raggiungere il suo node. Si assicuri che sia in esecuzione.',
'node.offline': 'Node non in linea',
'node.retry': 'Riprova',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 1544940..592f939 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -664,11 +664,13 @@ export default {
'node.service_stopping': '停止中…',
'node.service_restart': '再起動',
'node.service_restarting': '再起動中…',
- 'node.autostart_label': 'サインイン時に自動的に開始する',
- 'node.autostart_updating': '更新中…',
'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。',
- 'node.service_mode_label': 'バックグラウンドサービスとして実行する(サインインより前、起動時に開始)',
- 'node.service_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…',
+ 'node.startup_mode_label': '自動的に開始:',
+ 'node.startup_mode_off': 'オフ(手動で開始)',
+ 'node.startup_mode_signin': 'サインイン時',
+ 'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)',
+ 'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…',
+ 'node.startup_mode_service_unavailable_hint': 'バックグラウンドサービスモードにはインストール済みのビルドが必要です。ローカルでテストする場合は、管理者権限の PowerShell で "meshbay-node service install" を実行してください。',
'node.not_operator': 'node に接続できませんでした。node が実行中であることをご確認ください。',
'node.offline': 'Node はオフラインです',
'node.retry': '再試行',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 19feec2..eba234e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -678,11 +678,13 @@ export default {
'node.service_stopping': 'Stoppen…',
'node.service_restart': 'Herstarten',
'node.service_restarting': 'Herstarten…',
- 'node.autostart_label': 'Automatisch starten bij aanmelden',
- 'node.autostart_updating': 'Bijwerken…',
'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.',
- 'node.service_mode_label': 'Uitvoeren als achtergrondservice (start bij het opstarten, vóór het aanmelden)',
- 'node.service_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…',
+ 'node.startup_mode_label': 'Automatisch starten:',
+ 'node.startup_mode_off': 'Uit (handmatig starten)',
+ 'node.startup_mode_signin': 'Bij aanmelden',
+ 'node.startup_mode_service': 'Als achtergrondservice (start bij het opstarten)',
+ 'node.startup_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…',
+ 'node.startup_mode_service_unavailable_hint': 'Achtergrondservice-modus vereist een geïnstalleerde build. Voer voor lokaal testen "meshbay-node service install" uit vanuit een PowerShell met beheerdersrechten.',
'node.not_operator': 'Uw node is niet bereikbaar. Controleer of hij draait.',
'node.offline': 'Node is offline',
'node.retry': 'Opnieuw proberen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 512c4f3..999e689 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -696,11 +696,13 @@ export default {
'node.service_stopping': 'Zatrzymywanie…',
'node.service_restart': 'Uruchom ponownie',
'node.service_restarting': 'Ponowne uruchamianie…',
- 'node.autostart_label': 'Uruchamiaj automatycznie przy logowaniu',
- 'node.autostart_updating': 'Aktualizowanie…',
'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.',
- 'node.service_mode_label': 'Uruchom jako usługę w tle (uruchamia się przy starcie systemu, przed zalogowaniem)',
- 'node.service_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…',
+ 'node.startup_mode_label': 'Uruchamiaj automatycznie:',
+ 'node.startup_mode_off': 'Wyłączone (uruchamianie ręczne)',
+ 'node.startup_mode_signin': 'Przy logowaniu',
+ 'node.startup_mode_service': 'Jako usługa w tle (uruchamia się przy starcie systemu)',
+ 'node.startup_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…',
+ 'node.startup_mode_service_unavailable_hint': 'Tryb usługi w tle wymaga zainstalowanej wersji. Aby przetestować lokalnie, uruchom "meshbay-node service install" w PowerShell z uprawnieniami administratora.',
'node.not_operator': 'Nie udało się połączyć z Pana/Pani node. Upewnij się, że działa.',
'node.offline': 'Node jest niedostępny',
'node.retry': 'Ponów',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 8e33d74..79d2079 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -675,11 +675,13 @@ export default {
'node.service_stopping': 'Parando…',
'node.service_restart': 'Reiniciar',
'node.service_restarting': 'Reiniciando…',
- 'node.autostart_label': 'Iniciar automaticamente ao entrar na sessão',
- 'node.autostart_updating': 'Atualizando…',
'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.',
- 'node.service_mode_label': 'Executar como serviço em segundo plano (inicia na inicialização, antes do login)',
- 'node.service_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…',
+ 'node.startup_mode_label': 'Iniciar automaticamente:',
+ 'node.startup_mode_off': 'Desativado (iniciar manualmente)',
+ 'node.startup_mode_signin': 'Ao entrar na sessão',
+ 'node.startup_mode_service': 'Como serviço em segundo plano (inicia na inicialização)',
+ 'node.startup_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…',
+ 'node.startup_mode_service_unavailable_hint': 'O modo de serviço em segundo plano requer uma versão instalada. Para testes locais, execute "meshbay-node service install" em um PowerShell com privilégios de administrador.',
'node.not_operator': 'Não foi possível alcançar seu node. Verifique se ele está em execução.',
'node.offline': 'Node está off-line',
'node.retry': 'Tentar novamente',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 299dc4f..d67c34b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -651,11 +651,13 @@ export default {
'node.service_stopping': '正在停止…',
'node.service_restart': '重启',
'node.service_restarting': '正在重启…',
- 'node.autostart_label': '登录时自动启动',
- 'node.autostart_updating': '正在更新…',
'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。',
- 'node.service_mode_label': '以后台服务方式运行(在开机时启动,早于登录)',
- 'node.service_mode_updating': '正在切换模式 — 请留意管理员权限提示…',
+ 'node.startup_mode_label': '自动启动:',
+ 'node.startup_mode_off': '关闭(手动启动)',
+ 'node.startup_mode_signin': '登录时',
+ 'node.startup_mode_service': '作为后台服务(开机时启动)',
+ 'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…',
+ 'node.startup_mode_service_unavailable_hint': '后台服务模式需要已安装的版本。如需本地测试,请在具有管理员权限的 PowerShell 中运行 "meshbay-node service install"。',
'node.not_operator': '无法连接到您的 node。请确保它正在运行。',
'node.offline': 'Node 已离线',
'node.retry': '重试',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index 2a0b0d7..ec73128 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -47,21 +47,35 @@ function NodeServicePanel({ onChanged }) {
}
}, [refresh, onChanged]);
- const toggleAutostart = useCallback(() => {
- act('autostart', () => (info && info.autostart
- ? platform.node.autostart.remove()
- : platform.node.autostart.install()));
- }, [act, info]);
+ // "off" / "signin" / "service" -- derived from the status payload, no new
+ // backend field needed: mode/autostart already distinguish all three.
+ const startupMode = (i) => {
+ if (!i) return 'off';
+ if (i.mode === 'service') return i.mode;
+ return i.autostart ? 'signin' : 'off';
+ };
// Switching mode itself — the installer's own choice is effectively one-shot
// (it skips the question once the firewall rules exist for any reason, and
// per-user mode sets those up on its own with no Scheduled Task), so this is
// the only way back in if service mode was declined, or out if it is no
// longer wanted. One elevation, task + firewall together, same script.
- const toggleServiceMode = useCallback(() => {
- act('serviceMode', () => (info && info.mode === 'service'
- ? platform.node.serviceMode.remove()
- : platform.node.serviceMode.install()));
+ //
+ // The two mechanisms are mutually exclusive by construction here: never
+ // both installed at once, which would start the daemon twice (once at
+ // boot via the Scheduled Task, again at sign-in via the Startup .vbs).
+ // Always remove whichever one is currently active before installing the
+ // target, so every transition -- not just the two that used to be
+ // separate toggles -- keeps that invariant.
+ const changeStartupMode = useCallback((target) => {
+ const current = startupMode(info);
+ if (target === current) return;
+ act('startupMode', async () => {
+ if (current === 'service') await platform.node.serviceMode.remove();
+ else if (current === 'signin') await platform.node.autostart.remove();
+ if (target === 'service') await platform.node.serviceMode.install();
+ else if (target === 'signin') await platform.node.autostart.install();
+ });
}, [act, info]);
if (!platform.node.service.available) return null;
@@ -79,44 +93,53 @@ function NodeServicePanel({ onChanged }) {
const label = info.installed ? t('node.service_state_' + stateKey)
: t('node.service_not_installed');
+ // Own row, below the status/actions card rather than a further item
+ // crammed into its flex-wrap line -- that (plus two independent toggles
+ // for what is really one choice) is what made this a mess before.
+ const showStartupRow = (platform.node.autostart.available
+ || platform.node.serviceMode.available) && typeof info.mode === 'string';
+
return html`
- <div class="node-service">
- <div class="node-service-status">
- <span class="presence presence-${dot}" title="${label}" aria-label="${label}"></span>
- <span>${label}</span>
- </div>
- ${err && html`<div class="error-msg">${err}</div>`}
- <div class="node-service-actions">
- <button class="btn btn-small btn-secondary" disabled=${!!busy || running}
- onClick=${() => act('start', () => platform.node.start())}>
- ${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button>
- ${info.installed && html`
- <button class="btn btn-small btn-secondary" disabled=${!!busy || !running}
- onClick=${() => act('stop', () => platform.node.service.stop())}>
- ${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button>
- <button class="btn btn-small btn-secondary" disabled=${!!busy}
- onClick=${() => act('restart', () => platform.node.service.restart())}>
- ${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}</button>
+ <div>
+ <div class="node-service">
+ <div class="node-service-status">
+ <span class="presence presence-${dot}" title="${label}" aria-label="${label}"></span>
+ <span>${label}</span>
+ </div>
+ ${err && html`<div class="error-msg">${err}</div>`}
+ <div class="node-service-actions">
+ <button class="btn btn-small btn-secondary" disabled=${!!busy || running}
+ onClick=${() => act('start', () => platform.node.start())}>
+ ${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button>
+ ${info.installed && html`
+ <button class="btn btn-small btn-secondary" disabled=${!!busy || !running}
+ onClick=${() => act('stop', () => platform.node.service.stop())}>
+ ${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button>
+ <button class="btn btn-small btn-secondary" disabled=${!!busy}
+ onClick=${() => act('restart', () => platform.node.service.restart())}>
+ ${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}</button>
+ `}
+ </div>
+ ${info.mode === 'service' && html`
+ <p class="node-hint">${t('node.service_mode_hint')}</p>
`}
</div>
- ${info.mode === 'service' && html`
- <p class="node-hint">${t('node.service_mode_hint')}</p>
- `}
- ${platform.node.autostart.available && typeof info.autostart === 'boolean' && html`
- <label class="toggle-switch ${busy ? 'toggle-switch-disabled' : ''}">
- <input type="checkbox" checked=${info.autostart} disabled=${!!busy}
- onChange=${toggleAutostart} />
- <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
- ${' '}${busy === 'autostart' ? t('node.autostart_updating') : t('node.autostart_label')}
- </label>
- `}
- ${platform.node.serviceMode.available && typeof info.mode === 'string' && html`
- <label class="toggle-switch ${busy ? 'toggle-switch-disabled' : ''}">
- <input type="checkbox" checked=${info.mode === 'service'} disabled=${!!busy}
- onChange=${toggleServiceMode} />
- <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span>
- ${' '}${busy === 'serviceMode' ? t('node.service_mode_updating') : t('node.service_mode_label')}
- </label>
+ ${showStartupRow && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('node.startup_mode_label')}</span>
+ <select class="settings-select" disabled=${!!busy}
+ value=${startupMode(info)}
+ onChange=${(e) => changeStartupMode(e.target.value)}>
+ <option value="off">${t('node.startup_mode_off')}</option>
+ <option value="signin">${t('node.startup_mode_signin')}</option>
+ <option value="service" disabled=${!info.canElevate}>
+ ${t('node.startup_mode_service')}</option>
+ </select>
+ </div>
+ ${busy === 'startupMode' && html`
+ <p class="settings-hint">${t('node.startup_mode_updating')}</p>`}
+ ${!info.canElevate && html`
+ <p class="settings-hint">${t('node.startup_mode_service_unavailable_hint')}</p>`}
`}
</div>`;
}
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index b932c16..ea13680 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -678,9 +678,18 @@ class NodeDaemon:
# 12. Wait for shutdown
stop_event = asyncio.Event()
loop = asyncio.get_event_loop()
+ console_shutdown_done = None
if sys.platform == "win32":
- for sig in (signal.SIGINT, signal.SIGTERM):
+ # SIGBREAK: CTRL_BREAK_EVENT, how platform.autostart_end() asks
+ # a per-user-mode daemon to stop gracefully instead of only
+ # ever taskkill /F.
+ for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGBREAK):
signal.signal(sig, lambda *_: stop_event.set())
+ # CTRL_CLOSE/LOGOFF/SHUTDOWN reach no Python signal at all --
+ # see platform.install_console_close_handler for why this is
+ # a separate mechanism rather than another signal.signal() line.
+ from meshbay_node.platform import install_console_close_handler
+ console_shutdown_done = install_console_close_handler(loop, stop_event)
else:
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop_event.set)
@@ -694,6 +703,13 @@ class NodeDaemon:
await stop_event.wait()
await self._shutdown()
+ if console_shutdown_done is not None:
+ # Releases the console-control handler's blocking wait (see
+ # platform.install_console_close_handler) so it can return and
+ # let Windows actually end the process for CTRL_CLOSE/LOGOFF/
+ # SHUTDOWN, now that cleanup is genuinely done rather than just
+ # started.
+ console_shutdown_done.set()
async def _reload_config(self) -> None:
"""
diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py
index 9b18618..981d466 100644
--- a/packages/meshbay-node/src/meshbay_node/platform.py
+++ b/packages/meshbay-node/src/meshbay_node/platform.py
@@ -1,12 +1,18 @@
"""Platform-specific paths and tool resolution for meshbay-node."""
import asyncio
+import logging
import os
import shutil
+import signal
import subprocess
import sys
+import threading
+import time
from pathlib import Path
+log = logging.getLogger(__name__)
+
# ── Console ──────────────────────────────────────────────────────────────────
@@ -213,6 +219,24 @@ def _startup_vbs() -> Path:
/ "Startup" / "MeshBay Node.vbs")
+def _pid_file() -> Path:
+ """Where autostart_run() records the pid it spawned, for autostart_end()
+ to signal later -- possibly from a different process (a new Electron
+ session, or a fresh CLI invocation), so this cannot be an in-memory
+ handle."""
+ return state_dir() / "node.pid"
+
+
+def _pid_is_meshbay_node(pid: int) -> bool:
+ """True if `pid` is currently running *and* is meshbay-node.exe. Guards
+ against a stale pidfile whose pid Windows has since handed to an
+ unrelated process -- autostart_end() would otherwise send CTRL_BREAK_EVENT
+ to whatever that is instead."""
+ r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"],
+ capture_output=True, text=True)
+ return "meshbay-node.exe" in r.stdout.lower()
+
+
def _node_exe() -> str | None:
"""Best guess at the meshbay-node launcher: PATH first, then next to the
interpreter (a venv's Scripts/ dir, or a bundled runtime), then argv[0]."""
@@ -260,22 +284,76 @@ def autostart_remove() -> None:
def autostart_run() -> None:
- """Start the daemon now, detached and windowless. Raises RuntimeError if
- the launcher cannot be located."""
+ """Start the daemon now, windowless. Raises RuntimeError if the launcher
+ cannot be located."""
if not autostart_supported():
raise RuntimeError("autostart is Windows-only")
exe = _node_exe()
if not exe:
raise RuntimeError("cannot locate the meshbay-node launcher")
- subprocess.Popen([exe], creationflags=0x00000008 | 0x08000000, # DETACHED | NO_WINDOW
- close_fds=True)
+ # CREATE_NEW_PROCESS_GROUP, not DETACHED_PROCESS: still no visible window
+ # (CREATE_NO_WINDOW), but the child keeps a console object of its own and
+ # becomes the root of its own process group -- what autostart_end() needs
+ # to target it with CTRL_BREAK_EVENT instead of only ever a hard taskkill.
+ # DETACHED_PROCESS has no console at all, so nothing could be signalled.
+ proc = subprocess.Popen([exe], creationflags=0x00000200 | 0x08000000,
+ close_fds=True)
+ try:
+ pid_file = _pid_file()
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
+ pid_file.write_text(str(proc.pid), encoding="utf-8")
+ except OSError:
+ pass # best effort -- autostart_end() falls back to taskkill by image name
+
+
+# How long autostart_end() waits for a graceful CTRL_BREAK_EVENT stop before
+# giving up and force-killing. A chosen grace period, not an OS-enforced one
+# (unlike the ~5 s Windows itself allows a CTRL_CLOSE/LOGOFF/SHUTDOWN handler,
+# see install_console_close_handler below -- CTRL_BREAK carries no such ceiling).
+_GRACEFUL_STOP_TIMEOUT_SECS = 5.0
def autostart_end() -> None:
- """Stop any running daemon (hard: there is no CTRL_CLOSE handler yet)."""
- if autostart_supported():
- subprocess.run(["taskkill", "/IM", "meshbay-node.exe", "/F"],
- capture_output=True)
+ """
+ Stop the running daemon.
+
+ Tries a graceful stop first: CTRL_BREAK_EVENT to the pid autostart_run()
+ recorded. Because that process is the root of its own group
+ (CREATE_NEW_PROCESS_GROUP), daemon.py's own SIGBREAK handler turns this
+ into the same stop_event.set() SIGINT/SIGTERM already use, running the
+ real _shutdown() -- closes WebRTC sessions, kills any in-flight ffmpeg
+ transcode. Falls back to a hard `taskkill /F`, by image name, when there
+ is no pidfile, the recorded process is already gone, or it does not exit
+ within the grace period -- same as before this existed, just no longer
+ the only path. `taskkill /F` itself is TerminateProcess and cannot be made
+ graceful; nothing can catch it, on any OS.
+ """
+ if not autostart_supported():
+ return
+ pid_file = _pid_file()
+ try:
+ pid = int(pid_file.read_text(encoding="utf-8").strip())
+ except (OSError, ValueError):
+ pid = None
+ if pid is not None and not _pid_is_meshbay_node(pid):
+ pid = None # stale pidfile -- Windows may have reused the pid since
+ if pid is not None:
+ try:
+ os.kill(pid, signal.CTRL_BREAK_EVENT)
+ except OSError:
+ pid = None # already gone, or never existed
+ else:
+ deadline = time.monotonic() + _GRACEFUL_STOP_TIMEOUT_SECS
+ while time.monotonic() < deadline:
+ if not _pid_is_meshbay_node(pid):
+ pid_file.unlink(missing_ok=True)
+ return
+ time.sleep(0.2)
+ log.warning("pid %d did not exit within %.1fs of CTRL_BREAK_EVENT, "
+ "falling back to taskkill /F", pid, _GRACEFUL_STOP_TIMEOUT_SECS)
+ pid_file.unlink(missing_ok=True)
+ subprocess.run(["taskkill", "/IM", "meshbay-node.exe", "/F"],
+ capture_output=True)
# ── Service mode (Windows, opt-in at install time) ───────────────────────────
@@ -340,9 +418,17 @@ def service_install(exe: str | None = None) -> None:
Register the boot-time Scheduled Task. Needs admin — raises RuntimeError
with schtasks' own message on failure, which is "Access is denied." when
not elevated.
+
+ Removes the per-user Startup launcher first, if present: the two
+ mechanisms are mutually exclusive by design (both installed would start
+ the daemon twice, once at boot and again at sign-in), and this is a
+ separate front door from the Node page's own startup-mode selector (which
+ enforces the same thing on its side) -- the CLI (`meshbay-node service
+ install`) must not be able to leave that invariant broken.
"""
if not service_supported():
raise RuntimeError("service mode is Windows-only")
+ autostart_remove()
exe = exe or _node_exe()
if not exe:
raise RuntimeError(
@@ -374,3 +460,70 @@ def service_end() -> None:
"""Stop the running instance, if any. No admin needed."""
if service_supported():
_schtasks("/end", "/tn", TASK_NAME)
+
+
+# ── Console close / logoff / shutdown handler (Windows) ──────────────────────
+#
+# CPython's own console handler claims CTRL_C_EVENT and CTRL_BREAK_EVENT --
+# delivered as SIGINT/SIGBREAK, handled in daemon.py's win32 signal block --
+# but returns "not handled" for CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT and
+# CTRL_SHUTDOWN_EVENT: there is no Python signal for any of the three. 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. Does NOT cover `taskkill /F` -- TerminateProcess is
+# uncatchable on any OS, the same as SIGKILL; see autostart_end() for how the
+# Node page's Stop button avoids relying on it instead.
+
+_CONSOLE_HANDLER_REFS: list = [] # ctypes callbacks must be kept referenced or they may be freed
+
+CTRL_CLOSE_EVENT = 2
+CTRL_LOGOFF_EVENT = 5
+CTRL_SHUTDOWN_EVENT = 6
+
+
+def install_console_close_handler(
+ loop: asyncio.AbstractEventLoop, stop_event: asyncio.Event,
+) -> "threading.Event | None":
+ """
+ Register the handler. Returns a threading.Event the caller must set once
+ its own graceful shutdown has actually finished -- daemon.py does this
+ right after `await self._shutdown()` -- or None off-Windows, or if
+ registration itself failed (logged, not raised: losing this is a
+ regression, refusing to start the daemon over it would not be).
+
+ MSDN: for these three events the process is ended "after the process
+ returns from the handler function, or after 5 seconds, whichever occurs
+ first" -- so the handler, which Windows runs on a thread of its own and
+ never the main one, blocks here instead of returning immediately, and
+ nudges the asyncio loop the thread-safe way since it is not the loop's
+ own thread. The wait is capped just under that ceiling so the process
+ still exits by itself if cleanup runs long, rather than the OS treating an
+ unresponsive handler as a hang.
+ """
+ if sys.platform != "win32":
+ return None
+ import ctypes
+ from ctypes import wintypes
+
+ handled = {CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT}
+ shutdown_done = threading.Event()
+ handler_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD)
+
+ def _handler(ctrl_type: int) -> bool:
+ if ctrl_type not in handled:
+ return False # not ours -- let Python's own handler or the default action take it
+ log.info("Console control event %d (close/logoff/shutdown) -- shutting down", ctrl_type)
+ loop.call_soon_threadsafe(stop_event.set)
+ shutdown_done.wait(4.5)
+ return True
+
+ handler_ref = handler_type(_handler)
+ if not ctypes.windll.kernel32.SetConsoleCtrlHandler(handler_ref, True):
+ log.warning("SetConsoleCtrlHandler failed (%s) -- closing the console window, "
+ "logging off or shutting down will not run a clean shutdown; "
+ "SIGINT/SIGTERM/SIGBREAK are unaffected",
+ ctypes.WinError())
+ return None
+ _CONSOLE_HANDLER_REFS.append(handler_ref)
+ return shutdown_done
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
index 21aaf29..0cf6367 100644
--- a/packages/meshbay-node/tests/test_packaging_win.py
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -71,6 +71,25 @@ def test_the_node_runtime_is_carried_as_an_extraresource():
"extraResources puts it")
+def test_find_node_binary_strips_stray_cr_from_multiline_where_output():
+ """
+ where.exe/which can list more than one match on PATH, and each line
+ keeps its own trailing \\r on Windows. `stdout.trim().split('\\n')[0]`
+ only strips the ends of the *whole* string, so with 2+ matches a stray
+ \\r stayed glued to the end of the first line -- which then landed
+ inside the quoted path written into the Startup .vbs and broke
+ VBScript's parser with "Unterminated string constant" the next time
+ Windows ran it at sign-in. Reproduced live 2026-09-05 (this user's own
+ machine has both a dev venv and an installed build on PATH) and fixed
+ by splitting on \\r?\\n and trimming each candidate line individually.
+ """
+ main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8")
+ assert "stdout.split(/\\r?\\n/)" in main_js, (
+ "findNodeBinary must split where.exe/which output on \\r?\\n and "
+ "trim each line, not a single stdout.trim() over the whole blob")
+ assert "stdout.trim().split('\\n')[0]" not in main_js
+
+
def test_firewall_helper_is_carried_as_an_extraresource():
"""packaging/win/firewall.ps1 must ride into resources/, at the fixed
path installer.nsh invokes it from ($INSTDIR\\resources\\firewall.ps1)."""
diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py
index 7042afb..91713be 100644
--- a/packages/meshbay-node/tests/test_platform.py
+++ b/packages/meshbay-node/tests/test_platform.py
@@ -164,14 +164,26 @@ def test_autostart_install_refuses_off_windows(monkeypatch):
plat.autostart_install(exe="/usr/bin/meshbay-node")
-def test_autostart_run_launches_the_resolved_exe_detached(win_startup, monkeypatch):
+def test_autostart_run_launches_the_resolved_exe_windowless_and_records_its_pid(
+ win_startup, monkeypatch, tmp_path):
monkeypatch.setattr(plat, "_node_exe", lambda: r"C:\x\meshbay-node.exe")
+ monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) # state_dir() -> pidfile location
calls = {}
- monkeypatch.setattr(plat.subprocess, "Popen",
- lambda argv, **kw: calls.update(argv=argv, kw=kw))
+
+ def fake_popen(argv, **kw):
+ calls.update(argv=argv, kw=kw)
+ return Mock(pid=4242)
+
+ monkeypatch.setattr(plat.subprocess, "Popen", fake_popen)
plat.autostart_run()
assert calls["argv"] == [r"C:\x\meshbay-node.exe"]
- assert calls["kw"]["creationflags"] & 0x08000000 # CREATE_NO_WINDOW
+ flags = calls["kw"]["creationflags"]
+ assert flags & 0x08000000 # CREATE_NO_WINDOW
+ assert flags & 0x00000200 # CREATE_NEW_PROCESS_GROUP
+ assert not flags & 0x00000008 # not DETACHED_PROCESS -- that has no
+ # console at all, so CTRL_BREAK_EVENT
+ # would have nothing to signal
+ assert plat._pid_file().read_text(encoding="utf-8") == "4242"
def test_autostart_run_refuses_off_windows(monkeypatch):
@@ -180,6 +192,178 @@ def test_autostart_run_refuses_off_windows(monkeypatch):
plat.autostart_run()
+# ── Graceful stop (CTRL_BREAK_EVENT + taskkill fallback) ────────────────────
+#
+# autostart_end() references signal.CTRL_BREAK_EVENT, which genuinely does not
+# exist in the `signal` module off Windows -- monkeypatching sys.platform
+# cannot manufacture it, unlike the pure-Python behaviour tested above. Skip
+# rather than mock around it, matching test_configure_event_loop_selector_opt_in.
+
+@pytest.mark.skipif(sys.platform != "win32",
+ reason="signal.CTRL_BREAK_EVENT exists only on win32")
+def test_autostart_end_stops_gracefully_when_ctrl_break_is_enough(monkeypatch, tmp_path):
+ monkeypatch.setattr(sys, "platform", "win32")
+ monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
+ plat._pid_file().parent.mkdir(parents=True, exist_ok=True)
+ plat._pid_file().write_text("4242", encoding="utf-8")
+
+ kill_calls = []
+ monkeypatch.setattr(plat.os, "kill", lambda pid, sig: kill_calls.append((pid, sig)))
+ # Alive (our exe) on the pre-signal check, gone by the first poll after --
+ # a plain constant can't tell those two calls apart.
+ seen = {"n": 0}
+
+ def fake_check(pid):
+ seen["n"] += 1
+ return seen["n"] == 1
+
+ monkeypatch.setattr(plat, "_pid_is_meshbay_node", fake_check)
+ run_calls = []
+ monkeypatch.setattr(plat.subprocess, "run",
+ lambda argv, **kw: run_calls.append(argv))
+
+ plat.autostart_end()
+
+ assert kill_calls == [(4242, plat.signal.CTRL_BREAK_EVENT)]
+ assert run_calls == [] # no taskkill needed
+ assert not plat._pid_file().exists()
+
+
+@pytest.mark.skipif(sys.platform != "win32",
+ reason="signal.CTRL_BREAK_EVENT exists only on win32")
+def test_autostart_end_falls_back_to_taskkill_when_the_pid_never_exits(
+ monkeypatch, tmp_path):
+ monkeypatch.setattr(sys, "platform", "win32")
+ monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
+ plat._pid_file().parent.mkdir(parents=True, exist_ok=True)
+ plat._pid_file().write_text("4242", encoding="utf-8")
+
+ monkeypatch.setattr(plat.os, "kill", lambda pid, sig: None)
+ monkeypatch.setattr(plat, "_pid_is_meshbay_node", lambda pid: True) # never exits
+ monkeypatch.setattr(plat.time, "sleep", lambda s: None) # don't really wait
+ clock = iter([0.0, 1.0, 6.0]) # deadline = 0.0 + 5.0; third read is past it
+ monkeypatch.setattr(plat.time, "monotonic", lambda: next(clock))
+ run_calls = []
+ monkeypatch.setattr(plat.subprocess, "run",
+ lambda argv, **kw: run_calls.append(argv))
+
+ plat.autostart_end()
+
+ assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]]
+ assert not plat._pid_file().exists()
+
+
+def test_autostart_end_falls_back_to_taskkill_without_a_pidfile(monkeypatch, tmp_path):
+ """No CTRL_BREAK_EVENT dependency here -- there is no pid to signal, so
+ this one runs everywhere, same as the pre-existing behaviour it replaces."""
+ monkeypatch.setattr(sys, "platform", "win32")
+ monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
+ run_calls = []
+ monkeypatch.setattr(plat.subprocess, "run",
+ lambda argv, **kw: run_calls.append(argv))
+ plat.autostart_end()
+ assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]]
+
+
+def test_autostart_end_ignores_a_stale_pid_reused_by_another_process(monkeypatch, tmp_path):
+ """The recorded pid is alive but is not meshbay-node.exe -- Windows reused
+ it after the daemon exited. Must not send CTRL_BREAK_EVENT to whatever
+ that is; falls straight to taskkill (by image name, so harmless here)."""
+ monkeypatch.setattr(sys, "platform", "win32")
+ monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
+ plat._pid_file().parent.mkdir(parents=True, exist_ok=True)
+ plat._pid_file().write_text("4242", encoding="utf-8")
+
+ monkeypatch.setattr(plat, "_pid_is_meshbay_node", lambda pid: False)
+ kill_calls = []
+ monkeypatch.setattr(plat.os, "kill", lambda pid, sig: kill_calls.append((pid, sig)))
+ run_calls = []
+ monkeypatch.setattr(plat.subprocess, "run",
+ lambda argv, **kw: run_calls.append(argv))
+
+ plat.autostart_end()
+
+ assert kill_calls == [] # never signalled the reused pid
+ assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]]
+ assert not plat._pid_file().exists()
+
+
+def test_autostart_end_is_a_noop_off_windows(monkeypatch):
+ monkeypatch.setattr(sys, "platform", "linux")
+ run_calls = []
+ monkeypatch.setattr(plat.subprocess, "run",
+ lambda argv, **kw: run_calls.append(argv))
+ plat.autostart_end()
+ assert run_calls == []
+
+
+# ── Console close / logoff / shutdown handler ───────────────────────────────
+
+def test_install_console_close_handler_is_a_noop_off_windows(monkeypatch):
+ monkeypatch.setattr(sys, "platform", "linux")
+ loop = Mock()
+ stop_event = Mock()
+ assert plat.install_console_close_handler(loop, stop_event) is None
+
+
+@pytest.mark.skipif(sys.platform != "win32",
+ reason="ctypes.windll/wintypes exist only on win32")
+def test_install_console_close_handler_registers_and_the_callback_sets_stop_event():
+ import asyncio as _asyncio
+
+ loop = _asyncio.new_event_loop()
+ try:
+ stop_event = _asyncio.Event()
+ shutdown_done = plat.install_console_close_handler(loop, stop_event)
+ assert shutdown_done is not None
+ # Drive the registered handler directly rather than actually closing a
+ # console window -- exercises the same code path SetConsoleCtrlHandler
+ # would invoke, without needing a live console to close.
+ handler = plat._CONSOLE_HANDLER_REFS[-1]
+ shutdown_done.set() # so the handler's bounded wait returns immediately
+ # ctypes marshals the WINFUNCTYPE's BOOL restype back as a plain int
+ # (1/0), not a Python bool, when called directly like this.
+ assert handler(plat.CTRL_CLOSE_EVENT)
+ loop.run_until_complete(_asyncio.sleep(0)) # let call_soon_threadsafe land
+ assert stop_event.is_set()
+ # An event this handler does not own (CTRL_C_EVENT) is left unhandled
+ # so Python's own console handler (or the default action) gets it.
+ assert not handler(0)
+ finally:
+ loop.close()
+
+
+# ── Service mode ─────────────────────────────────────────────────────────────
+
+def test_service_install_removes_the_startup_launcher_first(win_startup, monkeypatch):
+ """The two mechanisms are mutually exclusive by design -- both installed
+ would start the daemon twice, once at boot and again at sign-in. This is
+ the CLI's own front door to that invariant, separate from (but agreeing
+ with) the Node page's startup-mode selector."""
+ plat.autostart_install(exe=r"C:\x\meshbay-node.exe")
+ assert win_startup.exists()
+
+ monkeypatch.setattr(plat, "_current_user", lambda: "DOMAIN\\user")
+ calls = []
+ monkeypatch.setattr(
+ plat, "_schtasks",
+ lambda *args: calls.append(args) or Mock(returncode=0, stdout="", stderr=""))
+
+ plat.service_install(exe=r"C:\x\meshbay-node.exe")
+
+ assert not win_startup.exists() # removed as part of service_install
+ assert calls and calls[0][0] == "/create"
+
+
+def test_service_install_tolerates_no_startup_launcher_present(win_startup, monkeypatch):
+ assert not win_startup.exists()
+ monkeypatch.setattr(plat, "_current_user", lambda: "DOMAIN\\user")
+ monkeypatch.setattr(plat, "_schtasks",
+ lambda *args: Mock(returncode=0, stdout="", stderr=""))
+ plat.service_install(exe=r"C:\x\meshbay-node.exe") # no error
+ assert not win_startup.exists()
+
+
# ── Packaged defaults ────────────────────────────────────────────────────────
def test_frozen_build_finds_default_env_beside_the_executable(monkeypatch, tmp_path):