diff options
Diffstat (limited to 'packages/meshbay-client')
| -rw-r--r-- | packages/meshbay-client/build/installer.nsh | 123 | ||||
| -rw-r--r-- | packages/meshbay-client/build/stop-node.ps1 | 45 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 339 |
3 files changed, 347 insertions, 160 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(); }); |