aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/build/installer.nsh123
-rw-r--r--packages/meshbay-client/build/stop-node.ps145
-rw-r--r--packages/meshbay-client/src/main.js339
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py472
14 files changed, 760 insertions, 239 deletions
diff --git a/packages/meshbay-client/build/installer.nsh b/packages/meshbay-client/build/installer.nsh
index 3157f22..ef9c82a 100644
--- a/packages/meshbay-client/build/installer.nsh
+++ b/packages/meshbay-client/build/installer.nsh
@@ -55,7 +55,55 @@
!macroend
!macro customInit
+ ; What this machine already runs, before the previous version's uninstaller
+ ; deletes the sign-in launcher: an upgrade keeps the mode it finds, instead
+ ; of defaulting to "background service" -- which a silent upgrade cannot even
+ ; set up (no elevation), so an "at sign-in" install came out of one with no
+ ; autostart at all and its node stopped (found upgrading a real install).
+ ; A fresh install still defaults to the service.
StrCpy $MB_AutoMode "2"
+ nsExec::Exec 'schtasks /query /tn "MeshBay Node"'
+ Pop $0
+ ${If} $0 == 0
+ StrCpy $MB_AutoMode "2"
+ ${ElseIf} ${FileExists} "$APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\MeshBay Node.vbs"
+ StrCpy $MB_AutoMode "1"
+ ${ElseIf} ${FileExists} "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
+ StrCpy $MB_AutoMode "0"
+ ${EndIf}
+!macroend
+
+; ── stop the node before a single file is touched ─────────────────────────
+; electron-builder inserts customCheckAppRunning in place of its own
+; app-running check, which it runs before uninstallOldVersion and before the
+; files are extracted (installSection.nsh); customInstall only runs after both.
+; A service-mode daemon lives in the task's S4U logon session, where an
+; unelevated taskkill gets "Access is denied". Left running, it keeps
+; meshbay-node.exe and its DLLs locked, their copy fails, and electron-builder's
+; last-resort extract ignores the failure. build/stop-node.ps1 asks the node to
+; stop through its own control API first -- any session, no elevation, a proper
+; shutdown -- then Task Scheduler, then taskkill. It is embedded and run from
+; the plugins directory: the installed copy of anything may be what is being
+; replaced.
+
+; Defining customCheckAppRunning makes allowOnlyOneInstallerInstance.nsh skip
+; these two, which its own _CHECK_APP_RUNNING (inserted below) still needs.
+!include "getProcessInfo.nsh"
+Var pid
+
+!macro customCheckAppRunning
+ InitPluginsDir
+ File "/oname=$PLUGINSDIR\mb-stop-node.ps1" "${BUILD_RESOURCES_DIR}\stop-node.ps1"
+ mb_stop_node:
+ DetailPrint "Stopping the MeshBay node..."
+ nsExec::Exec `"${MB_PWSH}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\mb-stop-node.ps1"`
+ Pop $0
+ ${If} $0 != 0
+ MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "The MeshBay node is still running and holds files that setup must replace. Stop it (meshbay-node service stop, or end meshbay-node.exe in Task Manager), then click Retry." /SD IDCANCEL IDRETRY mb_stop_node
+ Quit
+ ${EndIf}
+ !insertmacro IS_POWERSHELL_AVAILABLE
+ !insertmacro _CHECK_APP_RUNNING
!macroend
; ── the autostart choice, as a radio page ─────────────────────────────────
@@ -123,9 +171,8 @@
!macroend
!macro customInstall
- ; resources\node-runtime\meshbay-node.exe is about to be overwritten; a
- ; daemon still running from a previous version holds the file open.
- nsExec::Exec 'taskkill /IM meshbay-node.exe /F'
+ ; The node was stopped by customCheckAppRunning, before the files were
+ ; copied -- by the time this runs they already have been.
; Add the daemon dir to the per-user PATH (HKCU\Environment). WordAdd is a
; stock NSIS macro over a ';'-delimited list -- it is a no-op if the entry is
@@ -146,9 +193,12 @@
${If} $MB_AutoMode == "2"
; Background service: the boot-time Scheduled Task AND the firewall
; rules, in ONE elevation (service-mode.ps1 does both). Skip it only
- ; when the task already exists and the rules are already there.
+ ; when the task is already there and current and so are the rules. A
+ ; stale task (2: another executable, or the 72-hour / battery defaults
+ ; of an older setup) is registered again -- the owner cannot change it
+ ; without elevation.
nsExec::Exec '"${MB_PWSH}" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service.ps1" status'
- Pop $R1 ; 0 = task installed
+ Pop $R1 ; 0 = installed and current, 1 = absent, 2 = stale
${If} $R1 == 0
${AndIf} $R0 == 0
Goto mb_auto_done
@@ -159,28 +209,58 @@
Goto mb_auto_done
${EndIf}
- ; Modes 0 and 1: no scheduled task. One elevation for the firewall rules,
- ; and only if one is actually missing.
+ ; Modes 0 and 1. A boot task left from an earlier "background service"
+ ; choice would start the node a second time, at boot and at sign-in: take
+ ; it out (service-mode.ps1 remove keeps the firewall rules every mode needs).
+ nsExec::Exec 'schtasks /query /tn "MeshBay Node"'
+ Pop $R1
+ ${If} $R1 == 0
+ ExecShellWait "runas" "${MB_PWSH}" \
+ '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service-mode.ps1" -Action remove' \
+ SW_HIDE
+ ${EndIf}
+ ; One elevation for the firewall rules, and only if one is actually missing.
${If} $R0 != 0
ExecShellWait "runas" "${MB_PWSH}" \
'-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\firewall.ps1" add' \
SW_HIDE
${EndIf}
- ; Mode 1 also drops the per-user sign-in launcher (no admin -- it is just
- ; a .vbs in this account's Startup folder). Idempotent, so a repeat run is
- ; harmless. meshbay_node.platform.service_install() removes this itself if
- ; the user later switches to service mode from the Node page.
- ${If} $MB_AutoMode == "1"
- nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart install'
- ${EndIf}
-
mb_auto_done:
${EndIf}
+
+ ; The sign-in launcher as the mode wants it -- silent installs too: during an
+ ; upgrade the previous version's uninstaller has just deleted it. No admin,
+ ; idempotent; `autostart install` refuses while a boot task exists.
+ ${If} $MB_AutoMode == "1"
+ nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart install'
+ ${Else}
+ nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart remove'
+ ${EndIf}
+ Pop $R2
+
+ ; Start the node customCheckAppRunning stopped, the way this mode runs it,
+ ; rather than leave it down until the next boot or sign-in. Only a node that
+ ; has been set up: a fresh install's has no account yet, and node:start
+ ; provisions and starts it. Mode 0 is the app's to start (runAfterFinish).
+ ${If} ${FileExists} "$LOCALAPPDATA\meshbay\node.toml"
+ ${If} $MB_AutoMode == "2"
+ nsExec::Exec 'schtasks /query /tn "MeshBay Node"'
+ Pop $R2
+ ${If} $R2 == 0
+ nsExec::Exec 'schtasks /run /tn "MeshBay Node"'
+ Pop $R2
+ ${EndIf}
+ ${ElseIf} $MB_AutoMode == "1"
+ nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart start'
+ Pop $R2
+ ${EndIf}
+ ${EndIf}
!macroend
!macro customUnInstall
- nsExec::Exec 'taskkill /IM meshbay-node.exe /F'
+ ; The node is already stopped: the uninstaller runs customCheckAppRunning
+ ; (un.checkAppRunning) before this.
; Take our entry back out of PATH, leaving the rest of it alone.
ReadRegStr $0 HKCU "Environment" "Path"
@@ -196,17 +276,22 @@
; default-No; a silent uninstall skips it entirely. customUnInstall runs
; before the files are removed, so service-mode.ps1 is still there.
${IfNot} ${Silent}
+ ${AndIfNot} ${isUpdated}
MessageBox MB_YESNO|MB_ICONQUESTION \
"Remove MeshBay's Windows Firewall rules and its boot-time service task, if you set one up? This needs one administrator confirmation. Both are harmless if left." \
/SD IDNO IDNO mb_keep_privileged
ExecShellWait "runas" "${MB_PWSH}" \
- '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service-mode.ps1" -Action remove' \
+ '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service-mode.ps1" -Action uninstall' \
SW_HIDE
mb_keep_privileged:
${EndIf}
; meshbay_node.platform.autostart_install() -- if the user picked "at sign-in"
; (here, or later in the client), this points wscript at the binary we are
- ; about to delete, and would error at every sign-in.
- Delete "$APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\MeshBay Node.vbs"
+ ; about to delete, and would error at every sign-in. Not in an upgrade: the
+ ; new version is about to take this path's place, and deleting the launcher
+ ; here is what left upgraded "at sign-in" installs with no autostart at all.
+ ${IfNot} ${isUpdated}
+ Delete "$APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\MeshBay Node.vbs"
+ ${EndIf}
!macroend
diff --git a/packages/meshbay-client/build/stop-node.ps1 b/packages/meshbay-client/build/stop-node.ps1
new file mode 100644
index 0000000..cfaf2f8
--- /dev/null
+++ b/packages/meshbay-client/build/stop-node.ps1
@@ -0,0 +1,45 @@
+# Stop every MeshBay node on this machine before setup touches its files, or
+# before the uninstaller removes them. Embedded in the installer and run from
+# its plugins directory: the installed copy of anything may be the one being
+# replaced.
+#
+# 1. Ask the node through its own control API (/api/shutdown, loopback, per-run
+# token): the only stop that reaches a node in any session with no
+# elevation, and the one that lets it shut down properly -- WebRTC sessions
+# closed, transcodes stopped.
+# 2. Task Scheduler for a background-service node (the owner may end the task;
+# taskkill from here gets "Access is denied" in its session).
+# 3. taskkill for anything left in this session.
+# Exit 0 once no meshbay-node.exe is left, 1 otherwise.
+$ErrorActionPreference = "SilentlyContinue"
+
+function NodeLeft { [bool](Get-Process -Name meshbay-node -ErrorAction SilentlyContinue) }
+function WaitGone([int]$Seconds) {
+ $deadline = (Get-Date).AddSeconds($Seconds)
+ while ((NodeLeft) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 }
+ -not (NodeLeft)
+}
+
+if (-not (NodeLeft)) { exit 0 }
+
+$cfg = Join-Path $env:LOCALAPPDATA "meshbay"
+$port = 18000
+$toml = Join-Path $cfg "node.toml"
+if (Test-Path $toml) {
+ $m = Select-String -Path $toml -Pattern '^\s*ui_port\s*=\s*(\d+)' | Select-Object -First 1
+ if ($m) { $port = [int]$m.Matches[0].Groups[1].Value }
+}
+$tokenFile = Join-Path $cfg "data\ui-token"
+if (Test-Path $tokenFile) {
+ $token = (Get-Content $tokenFile -Raw).Trim()
+ try {
+ Invoke-WebRequest -UseBasicParsing -Method Post -TimeoutSec 5 `
+ -Uri "http://127.0.0.1:$port/api/shutdown?t=$token" | Out-Null
+ if (WaitGone 20) { exit 0 }
+ } catch { }
+}
+
+& schtasks /end /tn "MeshBay Node" 2>&1 | Out-Null
+Get-Process -Name meshbay-node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
+if (WaitGone 20) { exit 0 }
+exit 1
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index c263d2c..9116d1a 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -88,6 +88,15 @@ function meshbayDataDir() {
return path.join(os.homedir(), '.local', 'share', 'meshbay');
}
+// Kept in step with meshbay_node.platform.log_file(). Windows only: elsewhere
+// the daemon logs to journald.
+function nodeLogHint() {
+ if (process.platform === 'win32') {
+ return path.join(process.env.LOCALAPPDATA || os.homedir(), 'meshbay', 'state', 'node.log');
+ }
+ return 'journalctl --user -u meshbay-node';
+}
+
// The policy, sent as a header on every response.
//
// Not a <meta> tag: `frame-ancestors` is ignored there — Chromium says so in
@@ -542,6 +551,11 @@ let trayTimer = null;
// there already do what hiding to an indicator does elsewhere.
const trayOS = () => process.platform === 'linux' || process.platform === 'win32';
let nodeService = null; // assigned by registerBridge()
+// Whether this app started the node that runs now, outside service mode: in
+// the installer's "only while MeshBay is open" mode that is the node it stops
+// when it quits.
+let nodeStartedByApp = false;
+let nodeWithApp = null; // assigned by registerBridge()
const TRAY_FALLBACK = {
show: 'Show MeshBay', quit: 'Quit',
@@ -1251,27 +1265,47 @@ function registerBridge() {
try { fs.rmSync(WIN_STARTUP_VBS, { force: true }); } catch { /* not there */ }
}
- // 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() {
+ // Windows: starting, stopping and restarting the node is the CLI's, and only
+ // the CLI's (meshbay_node/cli/lifecycle.py) -- one implementation behind
+ // every front door, the Node page, the tray, node:start and a terminal
+ // alike. It stops through the node's own control API first (graceful, and
+ // the only thing that reaches a service node in session 0 without
+ // elevation), then Task Scheduler, then taskkill; it starts the node with
+ // nothing of this process inherited (a child of Electron held Electron's
+ // sockets after the app quit); and it reports what actually answered.
+ // Two implementations had drifted: this file ended the service with
+ // schtasks first -- a TerminateProcess -- and "stopped" a node it could not
+ // reach while saying it had.
+ async function winNodeCli(args, timeoutMs = 120000) {
+ // await, not .then: findNodeBinary() returns the bundled path as a plain
+ // string in a packaged build and a promise otherwise -- `.then` on it made
+ // every start and stop fail in the installed app, and only there.
const bin = await findNodeBinary();
+ if (!bin) return { ok: false, out: 'meshbay-node not found' };
return new Promise((resolve) => {
- if (bin) {
- execFile(bin, ['autostart', 'stop'], () => resolve());
- } else {
- execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve());
- }
+ execFile(bin, args, { windowsHide: true, timeout: timeoutMs },
+ (err, stdout, stderr) => resolve({
+ ok: !err, out: `${stdout || ''}${stderr || ''}`.trim(),
+ }));
});
}
+ async function killNodeProcesses() {
+ const r = await winNodeCli(['autostart', 'stop']);
+ if (!r.ok) console.error('[node] stop:', r.out);
+ return r;
+ }
+
+ // Start (or restart) through the CLI and return what answered, or throw
+ // with the CLI's own words and where the log is.
+ async function winNodeStartVia(args) {
+ const r = await winNodeCli(args);
+ if (!r.ok) {
+ throw new Error(`${r.out || 'the node did not start'}\nIts log: ${nodeLogHint()}`);
+ }
+ return r.out;
+ }
+
// ── Windows: the opt-in Scheduled Task "service mode" ──────────────────────
// Set up once, elevated, at install time (build/installer.nsh + packaging/win
// /service.ps1 + /service-mode.ps1) or via `meshbay-node service install`
@@ -1292,17 +1326,47 @@ function registerBridge() {
});
}
- function winServiceTaskRun() {
- return new Promise((resolve) => {
- execFile('schtasks', ['/run', '/tn', WIN_SERVICE_TASK], () => resolve());
- });
+ // The installer's three choices, read back from what they leave behind: the
+ // boot task, the sign-in launcher, or neither -- "only while MeshBay is open".
+ async function winStartupMode() {
+ if ((await winServiceTaskStatus()).installed) return 'service';
+ if (winAutostartInstalled()) return 'signin';
+ return 'open';
}
- function winServiceTaskEnd() {
- return new Promise((resolve) => {
- execFile('schtasks', ['/end', '/tn', WIN_SERVICE_TASK], () => resolve());
- });
+ function nodeProvisioned() {
+ try {
+ return /^\s*username\s*=\s*"[^"]+"/m.test(fs.readFileSync(nodeConfigPath(), 'utf8'));
+ } catch { return false; }
+ }
+
+ // "Only while MeshBay is open" meant nothing: nothing started the node with
+ // the app, so after a reboot a group stayed offline with MeshBay open until
+ // someone pressed Start; and nothing stopped it at Quit. Found by testing
+ // each mode of a real install. A node not yet set up (no account in
+ // node.toml) is left alone -- node:start provisions and starts it.
+ async function winStartNodeWithApp() {
+ if (process.platform !== 'win32' || !hasBundledNode() || !nodeProvisioned()) return;
+ if ((await winStartupMode()) !== 'open' || await probeNode()) return;
+ try {
+ await winNodeStartVia(['autostart', 'start']);
+ nodeStartedByApp = true;
+ } catch (err) {
+ console.error('[node] start with the app:', err.message);
+ }
}
+ nodeWithApp = { start: winStartNodeWithApp };
+
+ let nodeStopAtQuitDone = false;
+ app.on('before-quit', (event) => {
+ if (process.platform !== 'win32' || nodeStopAtQuitDone || !nodeStartedByApp) return;
+ event.preventDefault(); // before-quit waits for no promise
+ nodeStopAtQuitDone = true;
+ winStartupMode()
+ .then((mode) => (mode === 'open' ? killNodeProcesses() : null))
+ .catch((err) => console.error('[node] stop at quit:', err.message))
+ .finally(() => app.quit());
+ });
// ── Windows: switching INTO or OUT OF service mode after install ───────────
// build/installer.nsh's mode question is effectively one-shot: it skips
@@ -1351,79 +1415,20 @@ function registerBridge() {
execFile(MB_PWSH,
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', elevator,
'-Target', MB_PWSH, '-TargetArgs', targetArgs],
- (err) => {
- if (err) {
- reject(new Error('Elevation was declined, or the operation failed.'));
- return;
+ (err, _stdout, stderr) => {
+ if (!err) {
+ resolve();
+ } else if (/cancel/i.test(String(stderr))) {
+ // Also what an unanswered prompt becomes after two minutes.
+ reject(new Error('The administrator prompt was declined — nothing was changed.'));
+ } else {
+ reject(new Error('Switching the startup mode failed. Details: '
+ + path.join(os.tmpdir(), 'meshbay-firewall.log')));
}
- resolve();
});
});
}
- // A daemon that crashes immediately (a port already in use -- reproduced
- // live: a second node instance found 18000 taken by the first -- a corrupt
- // config, antivirus interference) used to fail silently: stdio was
- // 'ignore', so its stderr was thrown away, and the only failure path left
- // was the caller's waitForNode() timing out after a generic 60s ("did not
- // start within 60s"). The real reason was sitting on stderr the whole time,
- // just never read. This watches for a few seconds -- long enough for any
- // startup crash, reproduced consistently well under one second -- and
- // rejects with the daemon's own tail of stderr if it exits in that window.
- // If it survives the window, stdio is released and it is left fully
- // detached, same as before this existed.
- const NODE_CRASH_WATCH_MS = 2500;
-
- function spawnNodeDetachedWatched(bin, args = []) {
- return new Promise((resolve, reject) => {
- const child = spawn(bin, args, {
- detached: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
- });
- let stderr = '';
- let settled = false;
- child.stderr.on('data', (d) => { stderr += d.toString(); });
- // spawn() failures (bad path, a stale PATH entry, antivirus
- // interference) land on the ChildProcess as an 'error' event,
- // asynchronously -- with no listener, Node rethrows it as an uncaught
- // exception and takes the whole main process down with it.
- child.on('error', (err) => {
- if (settled) return;
- settled = true;
- reject(err);
- });
- child.on('exit', (code, signal) => {
- if (settled) return;
- settled = true;
- // By lines (last 8) at first cut the actual OSError -- a real crash
- // captured live logged the bind failure, then two separate uvicorn/
- // asyncio tracebacks *after* it, which pushed it out of a short tail.
- // Character-bounded instead: Python's own daemon rarely writes more
- // than a couple of screens on a startup crash, so keeping the last
- // stretch of raw text is far more likely to still include the one
- // line that actually says what went wrong than guessing a line count.
- let tail = stderr.trim();
- if (tail.length > 4000) tail = `…${tail.slice(-4000)}`;
- reject(new Error(
- `meshbay-node exited immediately (code ${code}${signal ? `, signal ${signal}` : ''})`
- + (tail ? `:\n${tail}` : '')));
- });
- setTimeout(() => {
- if (settled) return;
- settled = true;
- child.stdout.destroy();
- child.stderr.destroy();
- child.unref();
- resolve();
- }, NODE_CRASH_WATCH_MS);
- });
- }
-
- async function spawnNodeDetached() {
- const bin = await findNodeBinary();
- if (!bin) throw new Error('meshbay-node not found on PATH');
- await spawnNodeDetachedWatched(bin);
- }
-
async function waitForNode(deadline) {
while (Date.now() < deadline) {
const p = await probeNode();
@@ -1450,10 +1455,10 @@ function registerBridge() {
while (Date.now() < deadline) {
last = await probeNode();
if (last && last.status === 'running') return last;
+ // Whatever it is waiting for: a node backing off a 429 still needs its
+ // key linked before its next attempt can succeed.
if (last && !linked && opts && opts.token && opts.hubUrl
- && last.pk_node_ed25519
- && (last.status === 'waiting_for_node_key'
- || last.status === 'waiting_for_account')) {
+ && last.pk_node_ed25519 && last.status !== 'starting') {
try {
const r = await fetch(`${opts.hubUrl}/v1/users/me/node_key`, {
method: 'PUT',
@@ -1591,10 +1596,13 @@ function registerBridge() {
async function nodeServiceStop() {
if (process.platform === 'win32') {
- const svc = await winServiceTaskStatus();
- if (svc.installed) await winServiceTaskEnd();
- await killNodeProcesses(); // graceful-then-forceful; also the
- // belt-and-suspenders in case /end left the process running
+ await killNodeProcesses();
+ nodeStartedByApp = false;
+ // Said only once nothing answers: this used to report success about a
+ // service node it could not reach from this session.
+ if (await probeNode()) {
+ throw new Error(`the node could not be stopped. Its log: ${nodeLogHint()}`);
+ }
return { stopped: true };
}
if (process.platform !== 'linux') {
@@ -1614,16 +1622,12 @@ function registerBridge() {
async function nodeServiceRestart() {
if (process.platform === 'win32') {
- const svc = await winServiceTaskStatus();
- if (svc.installed) await winServiceTaskEnd();
- await killNodeProcesses();
- if (svc.installed) {
- await winServiceTaskRun();
- } else {
- await spawnNodeDetached();
- }
- const p = await waitForNode(Date.now() + 30000);
- if (!p) throw new Error('node did not come back up within 30s');
+ // The CLI waits for the *new* instance: this used to report the one
+ // that was still shutting down, answering on the port for a moment.
+ await winNodeStartVia(['restart-daemon']);
+ if ((await winStartupMode()) !== 'service') nodeStartedByApp = true;
+ const p = await probeNode();
+ if (!p) throw new Error(`the node did not come back up. Its log: ${nodeLogHint()}`);
return { restarted: true, ...p };
}
if (process.platform !== 'linux') {
@@ -1643,6 +1647,10 @@ function registerBridge() {
ipcMain.handle('node:autostart', async (_e, action) => {
if (process.platform !== 'win32') return { supported: false };
if (action === 'install') {
+ // Both would start the node: at boot, then again at sign-in.
+ if ((await winServiceTaskStatus()).installed) {
+ throw new Error('the node already runs as a background service');
+ }
const bin = await findNodeBinary();
if (!bin) throw new Error('meshbay-node not found on PATH');
winAutostartInstall(bin);
@@ -1663,8 +1671,43 @@ function registerBridge() {
if (action !== 'install' && action !== 'remove') {
throw new Error(`unknown service-mode action: ${action}`);
}
- await winElevateServiceMode(action);
+ // Stop the running node first, from here, unelevated: its own control API
+ // reaches it in any session. Otherwise removing the service left its node
+ // running in session 0 with nothing left that could stop it, and
+ // installing it started a second node that found the port taken and quit,
+ // leaving the old one in charge -- both found by switching modes on a real
+ // install.
+ const wasRunning = Boolean(await probeNode());
+ await killNodeProcesses();
+ try {
+ await winElevateServiceMode(action);
+ } catch (err) {
+ // Declined, timed out or failed: the mode is what it was, and so must
+ // the node be. It used to stay stopped -- a "No" to the prompt took the
+ // groups offline. restart-daemon starts it however this machine is now
+ // set up, which after a failure is how it was.
+ if (wasRunning) {
+ try {
+ await winNodeStartVia(['restart-daemon']);
+ } catch (e) {
+ console.error('[node] restart after a refused mode switch:', e.message);
+ }
+ }
+ throw err;
+ }
const svc = await winServiceTaskStatus();
+ if (action === 'install') {
+ nodeStartedByApp = false;
+ } else if (wasRunning) {
+ // Up again in this session: in the mode being switched to, the node
+ // runs while the app is open, or from the next sign-in on.
+ try {
+ await winNodeStartVia(['autostart', 'start']);
+ nodeStartedByApp = true;
+ } catch (err) {
+ console.error('[node] restart after leaving service mode:', err.message);
+ }
+ }
return { supported: true, installed: svc.installed };
});
@@ -1680,11 +1723,17 @@ function registerBridge() {
{ signal: AbortSignal.timeout(3000) });
if (!r.ok) return null;
const status = await r.json();
- const READY = ['running', 'waiting_for_node_key', 'waiting_for_account', 'starting'];
+ // Every state of a daemon that is up. 'waiting_for_hub' is a node backing
+ // off a 429 or a hub restart; leaving it out made that node count as no
+ // node at all, so node:start never linked it and reported "started but
+ // could not link" (reproduced against a local hub, 2026-09-26).
+ const READY = ['running', 'waiting_for_node_key', 'waiting_for_account',
+ 'waiting_for_hub', 'starting'];
if (!READY.includes(status.status)) return null;
_nodeToken = token;
_nodePort = port;
- return { pk_node_ed25519: status.pk_node_ed25519 || '', status: status.status };
+ return { pk_node_ed25519: status.pk_node_ed25519 || '', status: status.status,
+ version: status.version || '' };
} catch { return null; }
}
@@ -1739,37 +1788,43 @@ function registerBridge() {
if (process.platform === 'win32') {
if (opts && opts.hubUrl && opts.username) provisionNode(opts.hubUrl, opts.username);
- const svc = await winServiceTaskStatus();
- if (svc.installed) {
- // A service-mode daemon runs under the task's own S4U logon session,
- // not this (interactive) one -- killNodeProcesses()'s taskkill and
- // CTRL_BREAK both target it by image name/pid from here, and both
- // fail with "Access is denied" across that session boundary
- // (confirmed live 2026-09-14: an already-elevated `schtasks /end`
- // succeeds against the exact same pid taskkill just refused).
- // Silently, too -- killNodeProcesses() never surfaces the failure,
- // so a stuck instance was never actually replaced: re-running the
- // task below is then a no-op too, since Windows still considers it
- // Running (default "do not start a new instance" policy). Task Scheduler can
- // stop what it started; go through it, the way nodeServiceStop/
- // nodeServiceRestart already correctly do, instead of reaching past it.
- await winServiceTaskEnd();
- await winServiceTaskRun();
- } else {
- await killNodeProcesses(); // clear a crash-looping one (same session)
- await spawnNodeDetached();
+ // Restarted, not merely started: provisionNode() may just have pointed
+ // the node at another hub or account, which it only reads at start.
+ // The CLI stops whatever runs (in any session, gracefully first), starts
+ // it the way this machine is set up -- the service task, or a process of
+ // its own with nothing of the app's inherited -- and waits for the new
+ // instance to answer.
+ await winNodeStartVia(['restart-daemon']);
+ if ((await winStartupMode()) !== 'service') nodeStartedByApp = true;
+ const p = await waitForNode(Date.now() + 15000);
+ if (!p) throw new Error('the node did not start. Its log: '
+ + `${nodeLogHint()}`);
+ // An upgrade that could not replace a running node's files leaves the
+ // previous version's node behind, and it cannot speak this app's
+ // protocol; everything after this point would fail for no stated reason.
+ if (app.isPackaged && p.version && p.version !== app.getVersion()) {
+ throw new Error(
+ `the node that answered is version ${p.version}, but this app is `
+ + `${app.getVersion()}. Its files were not replaced, or the `
+ + 'background service runs another copy: stop the node '
+ + '(meshbay-node service stop) and run the installer again.');
}
- const p = await waitForNode(Date.now() + 60000);
- if (!p) throw new Error('the node did not start within 60s — run it from a '
- + 'terminal (`meshbay-node`) to see why');
// Up, but almost never 'running' on a first launch: link the node key to
// the hub account and wait for the daemon to authenticate. Without this
// it stays at 'waiting_for_account' and nothing here ever tells the hub
// about the node.
const ready = p.status === 'running'
? p
- : await linkNodeKeyAndAwaitRunning(opts, Date.now() + 45000);
- if (!ready || ready.status !== 'running') {
+ // 90s: a node caught in the hub's per-minute sign-in limit waits out
+ // the rest of that minute plus its 10s back-off before trying again.
+ : await linkNodeKeyAndAwaitRunning(opts, Date.now() + 90000);
+ if (!ready) {
+ // It answered once and then stopped answering: it is not running, and
+ // saying "started but could not link" sent the reader after the link.
+ throw new Error('the node started, then stopped responding. Its log: '
+ + `${nodeLogHint()}`);
+ }
+ if (ready.status !== 'running') {
// "Link Node" is on the Settings page, not this one -- pointing here
// at the Node page sent whoever read this hunting for a control that
// is not on it (reproduced live 2026-09-14).
@@ -1857,9 +1912,9 @@ function registerBridge() {
detached: true,
stdio: 'ignore',
});
- // Same reason as spawnNodeDetached(): an unhandled 'error' event here
- // would crash the whole main process instead of letting the polling
- // loop below report "never came up".
+ // spawn() failures arrive as an 'error' event: unhandled, it would crash
+ // the whole main process instead of letting the polling loop below
+ // report "never came up".
child.on('error', (err) => console.error('[node] failed to start:', err.message));
child.unref();
}
@@ -1878,9 +1933,9 @@ function registerBridge() {
}
// Daemon is up but stuck on hub auth — link the key so it can proceed.
+ // Whatever it is waiting for, 'waiting_for_hub' included (see probeNode).
if (!keyLinked && opts && opts.token && result.pk_node_ed25519 &&
- (result.status === 'waiting_for_node_key' ||
- result.status === 'waiting_for_account')) {
+ result.status !== 'starting') {
try {
const lr = await fetch(
`${opts.hubUrl}/v1/users/me/node_key`, {
@@ -2115,6 +2170,8 @@ if (!app.requestSingleInstanceLock()) {
registerBridge();
if (trayOS()) ensureTray();
createWindow();
+ // Not awaited: the window does not wait for the node.
+ if (nodeWithApp) nodeWithApp.start();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
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 7bd5169..41a56be 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -910,7 +910,7 @@ export default {
'node.service_restarting': 'Wird neu gestartet…',
'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.',
'node.startup_mode_label': 'Automatisch starten:',
- 'node.startup_mode_off': 'Aus (manuell starten)',
+ 'node.startup_mode_off': 'Nur solange MeshBay geöffnet ist',
'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…',
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 0c9cb19..7c06815 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1006,7 +1006,7 @@ export default {
'node.service_restarting': 'Restarting…',
'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.',
'node.startup_mode_label': 'Start automatically:',
- 'node.startup_mode_off': 'Off (start manually)',
+ 'node.startup_mode_off': 'Only while MeshBay is open',
'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…',
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 1399a83..939b96c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -904,7 +904,7 @@ export default {
'node.service_restarting': 'Reiniciando…',
'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.',
'node.startup_mode_label': 'Iniciar automáticamente:',
- 'node.startup_mode_off': 'Desactivado (iniciar manualmente)',
+ 'node.startup_mode_off': 'Solo mientras MeshBay está abierto',
'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…',
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 ae278d9..c546d4e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -907,7 +907,7 @@ export default {
'node.service_restarting': 'Redémarrage…',
'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.',
'node.startup_mode_label': 'Démarrer automatiquement :',
- 'node.startup_mode_off': 'Désactivé (démarrage manuel)',
+ 'node.startup_mode_off': 'Uniquement quand MeshBay est ouvert',
'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…',
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 f0fb0d1..893a563 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -906,7 +906,7 @@ export default {
'node.service_restarting': 'Riavvio…',
'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.',
'node.startup_mode_label': 'Avvia automaticamente:',
- 'node.startup_mode_off': 'Disattivato (avvio manuale)',
+ 'node.startup_mode_off': 'Solo mentre MeshBay è aperto',
'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…',
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 560332d..1a03c52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -894,7 +894,7 @@ export default {
'node.service_restarting': '再起動中…',
'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。',
'node.startup_mode_label': '自動的に開始:',
- 'node.startup_mode_off': 'オフ(手動で開始)',
+ 'node.startup_mode_off': 'MeshBay が開いている間のみ',
'node.startup_mode_signin': 'サインイン時',
'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)',
'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…',
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 adb94c6..8ae0ab3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -908,7 +908,7 @@ export default {
'node.service_restarting': 'Herstarten…',
'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.',
'node.startup_mode_label': 'Automatisch starten:',
- 'node.startup_mode_off': 'Uit (handmatig starten)',
+ 'node.startup_mode_off': 'Alleen zolang MeshBay open is',
'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…',
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 fb5a4ed..a2cc5bb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -926,7 +926,7 @@ export default {
'node.service_restarting': 'Ponowne uruchamianie…',
'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.',
'node.startup_mode_label': 'Uruchamiaj automatycznie:',
- 'node.startup_mode_off': 'Wyłączone (uruchamianie ręczne)',
+ 'node.startup_mode_off': 'Tylko gdy MeshBay jest otwarty',
'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…',
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 7bd11f6..2b98298 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
@@ -905,7 +905,7 @@ export default {
'node.service_restarting': 'Reiniciando…',
'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.',
'node.startup_mode_label': 'Iniciar automaticamente:',
- 'node.startup_mode_off': 'Desativado (iniciar manualmente)',
+ 'node.startup_mode_off': 'Somente enquanto o MeshBay estiver aberto',
'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…',
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 5509332..4ac583a 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
@@ -882,7 +882,7 @@ export default {
'node.service_restarting': '正在重启…',
'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。',
'node.startup_mode_label': '自动启动:',
- 'node.startup_mode_off': '关闭(手动启动)',
+ 'node.startup_mode_off': '仅在 MeshBay 打开时',
'node.startup_mode_signin': '登录时',
'node.startup_mode_service': '作为后台服务(开机时启动)',
'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…',
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
index aa77c29..cc9f8cc 100644
--- a/packages/meshbay-node/tests/test_packaging_win.py
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -10,8 +10,11 @@ node-runtime artifact slipping into git, the autostart seam between the NSIS
uninstaller and meshbay_node.platform drifting apart.
"""
+import inspect
import json
+import os
import re
+import sys
from pathlib import Path
import pytest
@@ -196,7 +199,6 @@ def test_the_uninstaller_clears_the_autostart_launcher():
nsh = NSH.read_text(encoding="utf-8")
assert "!macro customUnInstall" in nsh
- assert "taskkill /IM meshbay-node.exe /F" in nsh
# The tail platform.py builds, made NSIS-relative ($APPDATA == %APPDATA%).
tail = plat._startup_vbs()
@@ -208,10 +210,244 @@ def test_the_uninstaller_clears_the_autostart_launcher():
"changed and installer.nsh was not updated")
-def test_customInstall_stops_a_running_daemon_before_overwriting_it():
+# ── an upgrade must replace the node it is upgrading ───────────────────────
+#
+# The test this replaced asserted a `taskkill` in customInstall, under the name
+# "stops a running daemon before overwriting it". Both halves were false:
+# electron-builder runs customInstall AFTER it has copied the files, and an
+# unelevated taskkill cannot reach a service-mode daemon (S4U session, "Access
+# is denied"). The copy of the locked meshbay-node.exe failed, electron-builder's
+# last-resort extract ignored the failure, and an upgraded install went on
+# running the previous version's node -- whose code is embedded in that exe --
+# against the new client and hub. These read electron-builder's own template
+# rather than restating what it was assumed to do.
+
+EB_NSIS = CLIENT / "node_modules" / "app-builder-lib" / "templates" / "nsis"
+
+
+def _eb_template(rel: str) -> str:
+ path = EB_NSIS / rel
+ if not path.exists():
+ pytest.skip("electron-builder is not installed (npm ci in packages/meshbay-client)")
+ return path.read_text(encoding="utf-8")
+
+
+def test_electron_builder_checks_for_running_apps_before_it_copies_anything():
+ section = _eb_template("installSection.nsh")
+ i_check = section.index("!insertmacro CHECK_APP_RUNNING")
+ i_uninstall_old = section.index("!insertmacro uninstallOldVersion")
+ i_copy = section.index("!insertmacro installApplicationFiles")
+ i_custom = section.index("!insertmacro customInstall")
+ assert i_check < i_uninstall_old < i_copy < i_custom, (
+ "electron-builder's install order changed: the node must be stopped "
+ "before uninstallOldVersion and installApplicationFiles, and "
+ "customInstall is only reached after both")
+
+ uninstaller = _eb_template("uninstaller.nsh")
+ section_body = uninstaller.split('Section "un.', 1)[1]
+ assert section_body.index("call un.checkAppRunning") < \
+ section_body.index("!insertmacro customUnInstall")
+
+
+def test_electron_builder_hands_the_running_app_check_to_customCheckAppRunning():
+ helper = _eb_template("include/allowOnlyOneInstallerInstance.nsh")
+ check = helper.split("!macro CHECK_APP_RUNNING", 1)[1].split("!macroend", 1)[0]
+ assert "!insertmacro customCheckAppRunning" in check
+ # ...and defining it drops what its own check needs; installer.nsh supplies them.
+ guarded = helper.split("!ifmacrondef customCheckAppRunning", 1)[1].split("!endif", 1)[0]
+ assert '!include "getProcessInfo.nsh"' in guarded
+ assert "Var pid" in guarded
+
+
+STOP_NODE_PS1 = CLIENT / "build" / "stop-node.ps1"
+
+
+def test_the_node_is_stopped_before_any_file_is_copied():
nsh = NSH.read_text(encoding="utf-8")
- body = _macro_body(nsh, "customInstall")
- assert "taskkill /IM meshbay-node.exe /F" in body
+ check = _macro_body(nsh, "customCheckAppRunning")
+ # Embedded and run from the plugins dir: the installed copy may be the one
+ # being replaced.
+ assert r'"${BUILD_RESOURCES_DIR}\stop-node.ps1"' in check
+ assert r'-File "$PLUGINSDIR\mb-stop-node.ps1"' in check
+ assert check.index("InitPluginsDir") < check.index("File ")
+ # A node that will not stop fails the install loudly, never half-upgrades it.
+ assert "Quit" in check and "/SD IDCANCEL" in check
+ # electron-builder's own "close MeshBay" check still runs, with its prerequisites.
+ assert check.index("IS_POWERSHELL_AVAILABLE") < check.index("_CHECK_APP_RUNNING")
+ assert '!include "getProcessInfo.nsh"' in nsh
+ assert re.search(r"^Var pid\s*$", nsh, re.M)
+
+
+def test_the_installer_asks_the_node_to_stop_before_forcing_it():
+ src = STOP_NODE_PS1.read_text(encoding="utf-8")
+ i_api = src.index("/api/shutdown")
+ i_task = src.index("schtasks /end /tn \"MeshBay Node\"")
+ i_kill = src.index("Stop-Process -Force")
+ assert i_api < i_task < i_kill, (
+ "graceful through the control API, then Task Scheduler, then taskkill")
+ assert "ui_port" in src and r"data\ui-token" in src
+
+
+def test_no_taskkill_is_left_to_pretend_it_stops_the_node():
+ nsh = NSH.read_text(encoding="utf-8")
+ for macro in ("customInstall", "customUnInstall"):
+ assert "taskkill" not in _macro_body(nsh, macro), (
+ f"{macro} runs after the files are touched, and taskkill cannot reach "
+ "a service-mode daemon anyway")
+
+
+def test_setup_starts_the_service_node_it_just_installed():
+ nsh = NSH.read_text(encoding="utf-8")
+ install = _macro_body(nsh, "customInstall")
+ tail = install.split("mb_auto_done:", 1)[1]
+ run = 'schtasks /run /tn "MeshBay Node"'
+ assert run in tail, "the node stopped before the copy must be started again"
+ # Outside the ${IfNot} ${Silent} block: a silent upgrade needs its node too.
+ assert tail.index("${EndIf}") < tail.index(run)
+ last_if = tail.rindex("${If} $MB_AutoMode", 0, tail.index(run))
+ assert tail[last_if:].startswith('${If} $MB_AutoMode == "2"')
+
+
+def test_a_stale_service_task_is_registered_again():
+ src = (WIN / "service.ps1").read_text(encoding="utf-8")
+ status = src.split('"status" {', 1)[1].split('"run" {', 1)[0]
+ assert "exit 2" in status and "INSTALLED_STALE" in status
+ for probe in ("$exe -ne $node", '"PT0S"', "DisallowStartIfOnBatteries",
+ "StopIfGoingOnBatteries"):
+ assert probe in status, f"service.ps1 status does not check {probe}"
+ install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall")
+ assert "${If} $R1 == 0" in install, "only a current task (0) may skip the elevation"
+
+
+def test_the_service_task_is_not_ended_after_72_hours_or_on_battery():
+ from meshbay_node import platform as plat
+
+ flags = ("-ExecutionTimeLimit ([TimeSpan]::Zero)", "-AllowStartIfOnBatteries",
+ "-DontStopIfGoingOnBatteries", "-MultipleInstances IgnoreNew")
+ ps1 = (WIN / "service.ps1").read_text(encoding="utf-8")
+ install = ps1.split('"install" {', 1)[1].split('"remove" {', 1)[0]
+ assert "-Settings $taskSettings" in install
+ for flag in flags:
+ assert flag in install, f"service.ps1 install lacks {flag}"
+ assert flag in plat.SERVICE_TASK_SETTINGS, f"platform.py lacks {flag}"
+ assert "-Settings $s" in inspect.getsource(plat.service_install)
+
+
+def test_a_service_restart_waits_for_the_old_instance_before_starting_one():
+ """/end returns before the task leaves Running; a /run in that window is
+ dropped (MultipleInstances IgnoreNew) and leaves no node at all. And the
+ restart reports the new instance, not the one still shutting down."""
+ from meshbay_node.cli import lifecycle
+ stop = inspect.getsource(lifecycle._stop_node)
+ i_end = stop.index("service_end()")
+ assert 'service_state().lower() == "running"' in stop[i_end:]
+ start = inspect.getsource(lifecycle._start_and_confirm)
+ assert start.index("_stop_node(cfg)") < start.index("since = time.time()") \
+ < start.index("service_run()")
+ assert "_await_daemon(cfg, since)" in start
+
+
+def test_node_start_refuses_a_node_of_another_version():
+ main_js = MAIN_JS.read_text(encoding="utf-8")
+ body = main_js.split("ipcMain.handle('node:start'", 1)[1]
+ body = body[:body.index("process.platform !== 'linux'")]
+ assert "p.version !== app.getVersion()" in body
+ assert body.index("waitForNode(") < body.index("p.version !== app.getVersion()") \
+ < body.index("linkNodeKeyAndAwaitRunning(")
+ # A node that stopped answering is not reported as "started but not linked".
+ assert "if (!ready)" in body and "nodeLogHint()" in body
+
+
+def test_the_build_starts_the_frozen_daemon_not_only_its_help():
+ build = (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8")
+ assert "smoke-node-runtime.ps1" in build
+ smoke = (WIN / "smoke-node-runtime.ps1").read_text(encoding="utf-8")
+ assert "/api/status" in smoke and "$status.version -ne $ExpectVersion" in smoke
+ assert "state\\node.log" in smoke
+ # Never the developer's node or a real hub.
+ assert "$env:LOCALAPPDATA = $profileDir" in smoke
+ assert 'url = "http://127.0.0.1:1"' in smoke
+
+
+def _run_stop_node(tmp_path, dummy_name, localappdata):
+ """stop-node.ps1 as setup runs it, with the process and task names swapped
+ for ones that belong to this test -- the real ones would stop the
+ developer's own node."""
+ import shutil
+ import subprocess
+
+ script = (STOP_NODE_PS1.read_text(encoding="utf-8")
+ .replace('"MeshBay Node"', '"MeshBay Node stop-test"')
+ .replace("meshbay-node", dummy_name))
+ assert "meshbay-node" not in script
+ copy = tmp_path / "stop-node-test.ps1"
+ copy.write_text(script, encoding="utf-8")
+ pwsh = (shutil.which("powershell")
+ or r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe")
+ env = {**os.environ, "LOCALAPPDATA": str(localappdata)}
+ return subprocess.run([pwsh, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(copy)],
+ capture_output=True, text=True, timeout=90, env=env)
+
+
+def _dummy_node(tmp_path, name):
+ import shutil
+ import subprocess
+ exe = tmp_path / f"{name}.exe"
+ shutil.copy(r"C:\Windows\System32\PING.EXE", exe)
+ return subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="runs the installer's PowerShell for real")
+def test_setup_stops_a_node_that_answers_through_its_control_api(tmp_path):
+ """The graceful path: the script finds port and token where the node keeps
+ them, POSTs /api/shutdown, and waits for the process to go -- here a stand-in
+ that exits when asked, as the daemon does."""
+ import threading
+ from http.server import BaseHTTPRequestHandler, HTTPServer
+
+ proc = _dummy_node(tmp_path, "mbstopgraceful")
+ asked = []
+
+ class Api(BaseHTTPRequestHandler):
+ def do_POST(self): # noqa: N802
+ asked.append(self.path)
+ self.send_response(200)
+ self.send_header("Content-Length", "2")
+ self.end_headers()
+ self.wfile.write(b"{}")
+ proc.kill() # "shuts down"
+
+ def log_message(self, *a):
+ pass
+
+ server = HTTPServer(("127.0.0.1", 0), Api)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ home = tmp_path / "home" / "meshbay"
+ (home / "data").mkdir(parents=True)
+ (home / "node.toml").write_text(f"[node]\nui_port = {server.server_address[1]}\n",
+ encoding="utf-8")
+ (home / "data" / "ui-token").write_text("tok", encoding="utf-8")
+ try:
+ r = _run_stop_node(tmp_path, "mbstopgraceful", tmp_path / "home")
+ assert r.returncode == 0, r.stdout + r.stderr
+ assert asked == ["/api/shutdown?t=tok"]
+ finally:
+ server.shutdown()
+ if proc.poll() is None:
+ proc.kill()
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="runs the installer's PowerShell for real")
+def test_setup_forces_a_node_that_does_not_answer(tmp_path):
+ proc = _dummy_node(tmp_path, "mbstopforced")
+ try:
+ r = _run_stop_node(tmp_path, "mbstopforced", tmp_path / "nothing-here")
+ assert r.returncode == 0, r.stdout + r.stderr
+ assert proc.wait(timeout=10) is not None
+ finally:
+ if proc.poll() is None:
+ proc.kill()
# ── the autostart choice + the one-time elevated firewall step ──────────────
@@ -330,11 +566,59 @@ def test_the_uninstaller_offers_to_remove_everything_privileged_default_no():
uninstall = _macro_body(nsh, "customUnInstall")
assert "${IfNot} ${Silent}" in uninstall
+ assert "${AndIfNot} ${isUpdated}" in uninstall, "not while an upgrade replaces it"
assert "/SD IDNO" in uninstall, "the uninstall prompt should default to No"
- assert 'service-mode.ps1" -Action remove' in uninstall
+ # uninstall, not remove: remove keeps the firewall rules (a mode switch).
+ assert 'service-mode.ps1" -Action uninstall' in uninstall
assert 'ExecShellWait "runas"' in uninstall
+def test_an_upgrade_keeps_the_sign_in_launcher():
+ """The previous version's uninstaller runs during an upgrade; deleting the
+ launcher there left upgraded "at sign-in" installs with no autostart at all."""
+ uninstall = _macro_body(NSH.read_text(encoding="utf-8"), "customUnInstall")
+ i_guard = uninstall.index("${IfNot} ${isUpdated}")
+ assert i_guard < uninstall.index("MeshBay Node.vbs")
+
+
+def test_setup_preselects_the_mode_this_machine_already_runs():
+ init = _macro_body(NSH.read_text(encoding="utf-8"), "customInit")
+ i_task = init.index('schtasks /query /tn "MeshBay Node"')
+ i_vbs = init.index("MeshBay Node.vbs")
+ i_prev = init.index("$INSTDIR\\${APP_EXECUTABLE_FILENAME}")
+ assert i_task < i_vbs < i_prev
+ assert 'StrCpy $MB_AutoMode "1"' in init[i_vbs:i_prev]
+ assert 'StrCpy $MB_AutoMode "0"' in init[i_prev:]
+
+
+def test_setup_leaves_the_launcher_and_the_node_as_the_mode_wants_them():
+ install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall")
+ tail = install.split("mb_auto_done:", 1)[1]
+ silent_end = tail.index("${EndIf}")
+ # Outside ${IfNot} ${Silent}: a silent upgrade needs them as much.
+ assert silent_end < tail.index("autostart install") < tail.index("autostart remove")
+ start = tail.split('${FileExists} "$LOCALAPPDATA\\meshbay\\node.toml"', 1)[1]
+ assert 'schtasks /run /tn "MeshBay Node"' in start
+ assert 'meshbay-node.exe" autostart start' in start
+
+
+def test_choosing_another_mode_takes_out_a_leftover_boot_task():
+ install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall")
+ modes01 = install.split("; Modes 0 and 1.", 1)[1].split("mb_auto_done:", 1)[0]
+ assert 'service-mode.ps1" -Action remove' in modes01
+
+
+def test_leaving_service_mode_keeps_the_firewall_rules():
+ """Switching from the Node page removed them too, and the node silently
+ stopped accepting connections."""
+ src = (WIN / "service-mode.ps1").read_text(encoding="utf-8")
+ assert '[ValidateSet("install", "remove", "uninstall")]' in src
+ fw = src.split('if ($Action -ne "remove")', 1)
+ assert len(fw) == 2 and "firewall.ps1" in fw[1], "remove must skip the firewall step"
+ # Before the task goes, or a service node runs on with nothing to stop it.
+ assert src.index("Stop-Process -Force") < src.index('"service.ps1") $serviceAction')
+
+
# ── service mode itself (packaging/win/service.ps1, service-mode.ps1) ──────
def test_service_ps1_and_service_mode_ps1_are_extraresources():
@@ -494,18 +778,47 @@ def test_the_help_smoke_test_joins_multiline_output_before_matching():
assert '(& $exe --help 2>&1) -join' in body
-def test_main_js_drives_the_service_task_for_all_three_actions():
- """The hard requirement: Start/Stop/Restart from the Node page must
- control the Scheduled Task when service mode is active, not just spawn a
- detached process that has nothing to do with it."""
- main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8")
- for handler in ("node:service-stop", "node:service-restart", "node:start"):
- body = main_js.split(f"ipcMain.handle('{handler}'", 1)[1]
- body = body[:body.index("ipcMain.handle(")]
- assert "winServiceTaskStatus" in body, f"{handler} never checks for the service task"
+def _fn_body(src: str, signature: str) -> str:
+ return src.split(signature, 1)[1].split("\n }\n", 1)[0]
+
+
+def test_every_start_stop_and_restart_goes_through_the_cli():
+ """One implementation behind every front door. main.js kept its own copy:
+ it ended the service with schtasks first (a TerminateProcess), started nodes
+ as children of Electron (which inherited Electron's sockets), and reported a
+ node it could not reach from its session as stopped."""
+ main_js = MAIN_JS.read_text(encoding="utf-8")
+ assert "winNodeCli(['autostart', 'stop'])" in _fn_body(
+ main_js, "async function killNodeProcesses()")
+ assert "killNodeProcesses()" in _fn_body(main_js, "async function nodeServiceStop()")
+ assert "winNodeStartVia(['restart-daemon'])" in _fn_body(
+ main_js, "async function nodeServiceRestart()")
+ start = main_js.split("ipcMain.handle('node:start'", 1)[1]
+ start = start.split("process.platform !== 'linux'", 1)[0]
+ assert "winNodeStartVia(['restart-daemon'])" in start
+ for gone in ("spawnNodeDetached", "winServiceTaskEnd", "winServiceTaskRun"):
+ assert gone not in main_js, gone
+ # The CLI stops gracefully first, whatever the session, then forces.
+ from meshbay_node.cli import lifecycle
+ stop = inspect.getsource(lifecycle._stop_node)
+ assert stop.index("request_graceful_stop") < stop.index("service_end()") \
+ < stop.index("autostart_end()")
-def test_node_start_provisions_before_it_ever_touches_the_service_task():
+def test_nothing_treats_find_node_binary_as_always_a_promise():
+ """It returns the bundled path as a plain string in a packaged build: a
+ `.then` on it failed every start and stop in the installed app only --
+ found by launching the installed app, invisible from a dev run."""
+ main_js = MAIN_JS.read_text(encoding="utf-8")
+ assert not re.search(r"findNodeBinary\(\)\s*\.\s*then", main_js)
+
+
+def test_a_stop_that_leaves_the_node_answering_says_so():
+ stop = _fn_body(MAIN_JS.read_text(encoding="utf-8"), "async function nodeServiceStop()")
+ assert "if (await probeNode())" in stop and "throw new Error" in stop
+
+
+def test_node_start_provisions_before_it_starts_anything():
"""
On a fresh install with service mode chosen, the Scheduled Task exists
before anything is provisioned (node.toml is written by the wizard, not
@@ -525,11 +838,10 @@ def test_node_start_provisions_before_it_ever_touches_the_service_task():
body = body[:body.index("ipcMain.handle(")]
provision_at = body.index("provisionNode(")
- service_check_at = body.index("winServiceTaskStatus")
- assert provision_at < service_check_at, (
- "node:start checks the service task before provisioning — a fresh "
- "install's first Start would run/query the daemon before node.toml "
- "exists for it to read")
+ start_at = body.index("winNodeStartVia(")
+ assert provision_at < start_at, (
+ "node:start starts the node before provisioning — a fresh install's "
+ "first Start would run the daemon before node.toml exists for it to read")
def test_node_start_links_the_node_key_on_windows_not_only_linux():
@@ -556,7 +868,29 @@ def test_node_start_links_the_node_key_on_windows_not_only_linux():
helper = main_js.split("async function linkNodeKeyAndAwaitRunning", 1)[1]
helper = helper[:2000]
assert "/v1/users/me/node_key" in helper
- assert "waiting_for_account" in helper and "waiting_for_node_key" in helper
+ # Linked whatever the node is waiting for -- a node backing off a 429
+ # ('waiting_for_hub') needs its key as much as one at 'waiting_for_account'.
+ assert "last.status !== 'starting'" in helper
+
+
+def test_a_node_backing_off_the_hub_is_still_a_node():
+ """'waiting_for_hub' (429 or hub restart) was missing from probeNode's list,
+ so node:start treated a live node as absent, never linked it, and said
+ "started but could not link" -- reproduced against a local hub."""
+ main_js = MAIN_JS.read_text(encoding="utf-8")
+ probe = main_js.split("async function probeNode()", 1)[1][:1500]
+ ready = probe.split("const READY = [", 1)[1].split("];", 1)[0]
+ for status in ("running", "waiting_for_node_key", "waiting_for_account",
+ "waiting_for_hub", "starting"):
+ assert f"'{status}'" in ready, status
+ daemon_py = (ROOT / "packages" / "meshbay-node" / "src" / "meshbay_node"
+ / "daemon.py").read_text(encoding="utf-8")
+ daemon_states = set(re.findall(r'self\._state\["status"\] = "(\w+)"', daemon_py))
+ not_a_node = {"stopping"} # shutting down: nothing to link or wait for
+ assert daemon_states - not_a_node <= {s.strip(" '\n") for s in ready.split(",")}, (
+ "the daemon has a status probeNode does not recognise")
+ wizard = (HUB_STATIC / "create-group-page.js").read_text(encoding="utf-8")
+ assert "'waiting_for_hub'" in wizard
def test_firewall_ps1_targets_both_executables_and_is_idempotent():
@@ -1159,36 +1493,13 @@ def test_main_js_calls_ensure_node_path_on_every_launch():
"must be both defined and called")
-def test_node_start_surfaces_an_immediate_daemon_crash_instead_of_a_60s_timeout():
- """
- Reproduced live: a daemon that exits within ~1s (a port already bound,
- reproduced with a second instance colliding on 127.0.0.1:18000) used to
- be indistinguishable from one that simply never started -- spawn()'s
- stdio was 'ignore', discarding the exact stderr line that named the real
- problem, and waitForNode()'s 60s generic timeout was the only failure
- path left. spawnNodeDetachedWatched watches for an early exit and
- rejects with the daemon's own tail of stderr instead.
- """
+def test_a_node_that_fails_to_start_is_reported_with_its_own_words_and_log():
+ """A daemon that exits at once used to look like one that never started.
+ The CLI reports what happened; the app passes that on with where the log
+ is -- the only place a node with no console writes why."""
src = MAIN_JS.read_text(encoding="utf-8")
- assert "function spawnNodeDetachedWatched(" in src
- body = src.split("function spawnNodeDetachedWatched(", 1)[1].split("\n }", 1)[0]
- assert "stdio: ['ignore', 'pipe', 'pipe']" in body
- assert "exited immediately" in body
- assert "NODE_CRASH_WATCH_MS" in body
- # The tail must be bounded by length, not by a line count -- a real
- # capture had the actual OSError line pushed out by two uvicorn/asyncio
- # tracebacks that followed it, which a short "last N lines" cut before
- # this was fixed to bound by characters instead.
- assert "split(/\\r?\\n/).slice(" not in body, (
- "a line-count tail can cut the one line that names the real error "
- "-- bound by characters instead (reproduced live, see the comment "
- "above this constant)")
- assert "4000" in body
-
- async_fn = src.split("async function spawnNodeDetached()", 1)[1].split("\n }", 1)[0]
- assert "spawnNodeDetachedWatched" in async_fn, (
- "spawnNodeDetached must actually use the watched spawn, not the old "
- "fire-and-forget one")
+ body = _fn_body(src, "async function winNodeStartVia(args)")
+ assert "r.out" in body and "nodeLogHint()" in body and "throw new Error" in body
def test_setup_welcome_hints_at_the_node_startup_choice():
@@ -1265,25 +1576,48 @@ def test_node_start_ends_a_service_mode_daemon_via_task_scheduler_not_taskkill()
until a reboot. nodeServiceStop/nodeServiceRestart already route through
winServiceTaskEnd() first for exactly this reason -- node:start must too.
"""
+ # Now the CLI's (test_every_start_stop_and_restart_goes_through_the_cli):
+ # its stop reaches a session-0 node through the node's own control API, and
+ # still ends the task when that does not answer.
+ from meshbay_node.cli import lifecycle
+ stop = inspect.getsource(lifecycle._stop_node)
+ assert "request_graceful_stop" in stop and "service_end()" in stop
+
+
+def test_switching_modes_stops_the_node_first_and_brings_it_back():
+ """Removing the service left its node running in session 0, unstoppable;
+ installing it started a second node that found the port taken and quit."""
main_js = MAIN_JS.read_text(encoding="utf-8")
- body = main_js.split("ipcMain.handle('node:start'", 1)[1]
- body = body[:body.index("ipcMain.handle(")]
- win_branch = body.split("process.platform === 'win32'", 1)[1]
- win_branch = win_branch[:win_branch.index("process.platform !== 'linux'")]
+ body = main_js.split("ipcMain.handle('node:service-mode'", 1)[1].split("ipcMain.handle(", 1)[0]
+ assert body.index("killNodeProcesses()") < body.index("winElevateServiceMode(action)")
+ after = body.split("winElevateServiceMode(action)", 1)[1]
+ assert "wasRunning" in after and "winNodeStartVia(['autostart', 'start'])" in after
+ autostart = main_js.split("ipcMain.handle('node:autostart'", 1)[1]
+ autostart = autostart.split("ipcMain.handle(", 1)[0]
+ assert "winServiceTaskStatus()).installed" in autostart, (
+ "the sign-in launcher must refuse while the boot task exists")
+
- svc_installed = win_branch.split("if (svc.installed) {", 1)[1]
- svc_installed = svc_installed[:svc_installed.index("} else {")]
- assert "winServiceTaskEnd" in svc_installed, (
- "the service-mode branch of node:start never calls winServiceTaskEnd() "
- "-- a stuck S4U-session daemon can't be reached by killNodeProcesses() "
- "(Access is denied, confirmed live) so it never actually gets replaced")
- assert svc_installed.index("winServiceTaskEnd") < svc_installed.index("winServiceTaskRun"), (
- "winServiceTaskEnd() must run before winServiceTaskRun() -- ending "
- "second would stop the fresh instance right after starting it")
+def test_a_declined_prompt_leaves_the_node_as_it_was():
+ """The node is stopped before the prompt; a "No" -- or a prompt nobody
+ answered for two minutes -- used to leave it stopped, groups offline, with
+ the mode unchanged. Found by letting the prompt time out on a real install."""
+ main_js = MAIN_JS.read_text(encoding="utf-8")
+ body = main_js.split("ipcMain.handle('node:service-mode'", 1)[1].split("ipcMain.handle(", 1)[0]
+ guarded = body.split("await winElevateServiceMode(action);", 1)[1]
+ catch = guarded.split("} catch (err) {", 1)[1].split("throw err;", 1)[0]
+ assert "wasRunning" in catch and "winNodeStartVia(['restart-daemon'])" in catch
+ elevate = _fn_body(main_js, "function winElevateServiceMode(action)")
+ assert "/cancel/i.test(" in elevate, "a decline is not reported as a failure"
- svc_else = win_branch.split("} else {", 1)[1]
- svc_else = svc_else[:svc_else.index("const p = await waitForNode")]
- assert "killNodeProcesses" in svc_else, (
- "the non-service branch (Startup mode / only-while-open) should still "
- "use killNodeProcesses() -- that daemon runs in this same session, "
- "where taskkill/CTRL_BREAK actually work")
+
+def test_only_while_open_starts_with_the_app_and_stops_at_quit():
+ main_js = MAIN_JS.read_text(encoding="utf-8")
+ start = _fn_body(main_js, "async function winStartNodeWithApp()")
+ assert "winStartupMode()) !== 'open'" in start and "nodeProvisioned()" in start
+ assert "winNodeStartVia(['autostart', 'start'])" in start
+ ready = main_js.split("app.whenReady().then(", 1)[1]
+ assert ready.index("createWindow();") < ready.index("nodeWithApp.start()")
+ quit_ = main_js.split("app.on('before-quit', (event) => {", 1)[1].split("\n });", 1)[0]
+ assert "!nodeStartedByApp" in quit_ and "event.preventDefault()" in quit_
+ assert "mode === 'open' ? killNodeProcesses()" in quit_