aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/main.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-27 22:20:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-27 22:20:53 +0200
commit8c7e39b6dca758badec6867ab6610fd5e8d93d1e (patch)
tree1d9eaa2e549484adc7b2343eedf1edd741226414 /packages/meshbay-client/src/main.js
parent7662484cae8e74b7d9aa383bd6cd0dad4690aadc (diff)
downloadmeshbay-8c7e39b6dca758badec6867ab6610fd5e8d93d1e.tar.gz
fix: Windows installer and desktop app start and stop the node one way
A 0.16 upgrade in service mode left the previous node running: setup's unelevated taskkill cannot reach session 0, and it ran in customInstall, which electron-builder inserts after the files are copied. The locked exe was not replaced, and the new app talked to the old node ("started but could not link", "No operator paired"). Installer (build/installer.nsh, build/stop-node.ps1): - customCheckAppRunning, which runs before uninstallOldVersion and extraction, stops the node with an embedded stop-node.ps1: control API, then schtasks /end, then Stop-Process, and refuses to half-upgrade if one survives. - An upgrade keeps the mode it finds (task, launcher, previous install), restores the sign-in launcher the old uninstaller deletes, and restarts the node the way that mode runs it. A silent upgrade of an "at sign-in" install used to end with no autostart and no node. - The uninstaller removes the task and firewall rules only on a real uninstall, not on an update. Desktop app (src/main.js): - Start, Stop, Restart and node:start go through the CLI's lifecycle verbs instead of a second implementation; a child spawned by Electron also held Electron's sockets after the app quit. - "Only while MeshBay is open" is a real mode: the app starts a provisioned node at launch and stops the one it started when it quits. - Switching modes stops the node first -- deleting a task does not end its instance, and a new service found the port taken -- keeps the firewall rules every mode needs, and starts the node again. A declined or unanswered UAC prompt restores the node instead of leaving it stopped, and says that nothing changed. - waiting_for_hub counts as a node that is up; linking waits for a node that answers, with a longer deadline, and reports a version mismatch. Packaging (packaging/win): - The service task gets no 72-hour limit, runs on battery and ignores a second start; service.ps1 status reports a stale registration so setup re-registers it; remove ends the running instance before deleting the task. - build-node-runtime.ps1 starts the frozen daemon in a throwaway profile (smoke-node-runtime.ps1) instead of only asking for --help. The mode that was "Off (start manually)" is labelled "Only while MeshBay is open" in all ten catalogues. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src/main.js')
-rw-r--r--packages/meshbay-client/src/main.js339
1 files changed, 198 insertions, 141 deletions
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();
});