aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src')
-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();
});