diff options
18 files changed, 927 insertions, 250 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_ diff --git a/packaging/win/build-node-runtime.ps1 b/packaging/win/build-node-runtime.ps1 index 28c4061..371311b 100644 --- a/packaging/win/build-node-runtime.ps1 +++ b/packaging/win/build-node-runtime.ps1 @@ -198,6 +198,13 @@ if ($LASTEXITCODE -ne 0 -or $help -notmatch "meshbay-node") { if ($LASTEXITCODE -ne 0) { throw "frozen meshbay-node --help exited $LASTEXITCODE`n$help" } if ($help -notmatch "meshbay-node") { throw "frozen --help output looks wrong:`n$help" } +# --help imports the parser and nothing else; start the daemon itself. +Step "smoke test: the frozen daemon starts and answers" +$initPy = Join-Path $Repo "packages\meshbay-node\src\meshbay_node\__init__.py" +$expect = [regex]::Match((Get-Content -LiteralPath $initPy -Raw), '__version__\s*=\s*"([^"]+)"').Groups[1].Value +if (-not $expect) { throw "cannot read __version__ from $initPy" } +& (Join-Path $WinDir "smoke-node-runtime.ps1") -Exe $exe -ExpectVersion $expect + $mb = (Get-ChildItem $OutDir -Recurse | Measure-Object Length -Sum).Sum / 1MB Write-Host "" Write-Host ("OK node-runtime ready at {0} ({1:N0} MB)" -f $OutDir, $mb) -ForegroundColor Green diff --git a/packaging/win/service-mode.ps1 b/packaging/win/service-mode.ps1 index 2cb61b4..21794da 100644 --- a/packaging/win/service-mode.ps1 +++ b/packaging/win/service-mode.ps1 @@ -24,31 +24,48 @@ #> [CmdletBinding()] param( - [ValidateSet("install", "remove")] + # install: the boot task + the firewall rules, then start the node. + # remove: the boot task only -- switching to "at sign-in" or "only while + # MeshBay is open" from the Node page. The firewall rules serve + # every mode; removing them here left a node that silently + # accepted no connections (found by switching modes on a real + # install). + # uninstall: the boot task and the firewall rules -- the uninstaller. + [ValidateSet("install", "remove", "uninstall")] [string]$Action = "install" ) $here = $PSScriptRoot $log = Join-Path $env:TEMP "meshbay-firewall.log" -$firewallAction = if ($Action -eq "install") { "add" } else { "remove" } $failed = $false "[{0}] service-mode {1}" -f (Get-Date -Format s), $Action | Add-Content $log +# Elevated, so this reaches a node in any session. The desktop app has already +# asked it to stop properly; this only catches one that did not. Before +# install, a node still running would hold the control API's port and the +# service's own node would quit at once; before remove, deleting the task does +# not end its running instance, which would run on with nothing to stop it. +Get-Process -Name meshbay-node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + +$serviceAction = if ($Action -eq "install") { "install" } else { "remove" } try { - & (Join-Path $here "service.ps1") $Action + & (Join-Path $here "service.ps1") $serviceAction } catch { - " service $Action failed: $_" | Add-Content $log + " service $serviceAction failed: $_" | Add-Content $log $failed = $true } -try { - & (Join-Path $here "firewall.ps1") $firewallAction -} -catch { - " firewall $firewallAction failed: $_" | Add-Content $log - $failed = $true +if ($Action -ne "remove") { + $firewallAction = if ($Action -eq "install") { "add" } else { "remove" } + try { + & (Join-Path $here "firewall.ps1") $firewallAction + } + catch { + " firewall $firewallAction failed: $_" | Add-Content $log + $failed = $true + } } # Register-ScheduledTask with -Trigger AtStartup does exactly that -- it does diff --git a/packaging/win/service.ps1 b/packaging/win/service.ps1 index 1f84a29..a46bb19 100644 --- a/packaging/win/service.ps1 +++ b/packaging/win/service.ps1 @@ -78,15 +78,28 @@ switch ($Action) { $taskAction = New-ScheduledTaskAction -Execute $node $bootTrigger = New-ScheduledTaskTrigger -AtStartup $taskPrincipal = New-ScheduledTaskPrincipal -UserId $user -LogonType S4U -RunLevel Limited + # Task Scheduler's defaults end a task after 72 hours, never start it on + # battery and stop it when the cable comes out. Kept identical to + # meshbay_node.platform.SERVICE_TASK_SETTINGS. + $taskSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -MultipleInstances IgnoreNew -StartWhenAvailable Register-ScheduledTask -TaskName $TASK_NAME -Action $taskAction -Trigger $bootTrigger ` - -Principal $taskPrincipal -Force -ErrorAction Stop | Out-Null + -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null Write-Host "service: installed ($user, runs at boot)" } "remove" { + # Deleting a task does not end its running instance. + & schtasks /end /tn $TASK_NAME 2>$null | Out-Null & schtasks /delete /tn $TASK_NAME /f 2>$null | Out-Null Write-Host "service: removed" } "status" { + # Exit 0: installed and current. 1: not installed. 2: installed but + # stale -- it runs another executable than this install's (an older + # install dir, a dev venv), or it still has the 72-hour / battery + # defaults. The installer re-registers a stale task; reading all of this + # needs no admin. $out = & schtasks /query /tn $TASK_NAME /fo list 2>$null if ($LASTEXITCODE -ne 0) { Write-Output "NOT_INSTALLED" @@ -94,6 +107,18 @@ switch ($Action) { } $line = $out | Select-String "^Status:" $state = if ($line) { ($line -replace "^Status:\s*", "").Trim() } else { "unknown" } + $task = Get-ScheduledTask -TaskName $TASK_NAME -ErrorAction SilentlyContinue + $stale = $true + if ($task) { + $exe = ([string]$task.Actions[0].Execute).Trim('"') + $s = $task.Settings + $stale = ($exe -ne $node) -or ($s.ExecutionTimeLimit -ne "PT0S") ` + -or $s.DisallowStartIfOnBatteries -or $s.StopIfGoingOnBatteries + } + if ($stale) { + Write-Output "INSTALLED_STALE:$state" + exit 2 + } Write-Output "INSTALLED:$state" exit 0 } diff --git a/packaging/win/smoke-node-runtime.ps1 b/packaging/win/smoke-node-runtime.ps1 new file mode 100644 index 0000000..bfcabac --- /dev/null +++ b/packaging/win/smoke-node-runtime.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS + Start a frozen meshbay-node as a daemon and check it answers as the version + it claims to be. + +.DESCRIPTION + `meshbay-node --help` imports the parser and nothing else, so it passes for + a bundle that cannot start the daemon at all. This starts the real daemon, + in a throwaway profile (its own LOCALAPPDATA, an unused port, a hub URL + nothing listens on -- it never touches a real hub or the developer's own + node), waits for its control API, and checks: + - /api/status answers, with the expected version; + - the Windows log file was written (a service-mode daemon has no console, + and this file is the only place its errors go). + + Also useful against an installed build: + smoke-node-runtime.ps1 -Exe "$env:LOCALAPPDATA\Programs\MeshBay\resources\node-runtime\meshbay-node.exe" -ExpectVersion 0.16.0 + +.PARAMETER Exe + The meshbay-node.exe to start. + +.PARAMETER ExpectVersion + The version /api/status must report. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Exe, + [Parameter(Mandatory = $true)][string]$ExpectVersion, + [int]$TimeoutSeconds = 45 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$profileDir = Join-Path ([IO.Path]::GetTempPath()) ("meshbay-smoke-" + [guid]::NewGuid().ToString("N")) +$meshbay = Join-Path $profileDir "meshbay" +New-Item -ItemType Directory -Force $meshbay | Out-Null + +# A free loopback port, so a node already running here on 18000 is untouched. +$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) +$listener.Start() +$port = $listener.LocalEndpoint.Port +$listener.Stop() + +$bytes = New-Object byte[] 32 +[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) +$unlock = Join-Path $meshbay "unlock.key" +Set-Content -Encoding ascii -LiteralPath $unlock ([Convert]::ToBase64String($bytes)) +$unlockToml = $unlock.Replace([char]92, [char]47) +Set-Content -Encoding ascii -LiteralPath (Join-Path $meshbay "node.toml") @" +[hub] +url = "http://127.0.0.1:1" +username = "smoke" + +[node] +quic_enabled = false +ui_port = $port + +[keystore] +unlock_file = "$unlockToml" +"@ + +$oldLocal = $env:LOCALAPPDATA +$env:LOCALAPPDATA = $profileDir +$stderr = Join-Path $profileDir "stderr.txt" +$proc = $null +try { + $proc = Start-Process -FilePath $Exe -PassThru -WindowStyle Hidden ` + -RedirectStandardError $stderr -RedirectStandardOutput (Join-Path $profileDir "stdout.txt") + $env:LOCALAPPDATA = $oldLocal + + $tokenFile = Join-Path $meshbay "data\ui-token" + $status = $null + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline -and -not $proc.HasExited) { + if (Test-Path -LiteralPath $tokenFile) { + try { + $token = (Get-Content -LiteralPath $tokenFile -Raw).Trim() + $status = Invoke-RestMethod "http://127.0.0.1:$port/api/status?t=$token" -TimeoutSec 3 + break + } catch { } + } + Start-Sleep -Milliseconds 500 + } + + $tail = if (Test-Path -LiteralPath $stderr) { (Get-Content -LiteralPath $stderr -Tail 30) -join "`n" } else { "" } + if ($proc.HasExited) { + throw "the frozen daemon exited (code $($proc.ExitCode)) before its control API answered:`n$tail" + } + if ($null -eq $status) { + throw "the frozen daemon's control API did not answer within ${TimeoutSeconds}s:`n$tail" + } + if ($status.version -ne $ExpectVersion) { + throw "the frozen daemon reports version '$($status.version)', expected '$ExpectVersion'" + } + $log = Join-Path $meshbay "state\node.log" + if (-not (Test-Path -LiteralPath $log) -or (Get-Item -LiteralPath $log).Length -eq 0) { + throw "the frozen daemon wrote no log file at $log" + } + Write-Host "OK daemon answered: version $($status.version), status $($status.status), log $log" -ForegroundColor Green +} +finally { + $env:LOCALAPPDATA = $oldLocal + if ($proc -and -not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } + if ($proc) { $proc.WaitForExit(10000) | Out-Null } + Remove-Item -LiteralPath $profileDir -Recurse -Force -ErrorAction SilentlyContinue +} |