aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-07 21:25:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-07 22:22:51 +0200
commite833fe1bfc8eb6f66cc5dc53997cc4158bab583f (patch)
tree6874dfd09cb210eaf61e4f5761d0bad23c9cd0a3 /packages/meshbay-client
parent92e25ffcc5edf5d1a9996bfb921b5a95b826134b (diff)
downloadmeshbay-e833fe1bfc8eb6f66cc5dc53997cc4158bab583f.tar.gz
fix: set the Windows node up at sign-in, and stop it for real
Found by the first Windows beta tester, then reproduced on a clean install. After a service-mode install nothing set the node up for the account that signed in: the boot task started a node that quit ("hub.username not set"), and the sidebar showed Node / Create group only once the hub held a node key. The only way to the wizard that provisions was the home page's welcome card, which an account already in a group never sees. The way out was `meshbay-node init` and the key pasted on the profile page -- which is also what PACKAGING-GUIDE.md told people to do. - main.js `node:ensure`, called by app.js at sign-in: provisions, starts and links the node this build ships (Windows, bundled node only). A node set up for another account, or an account linked to another node, is left alone. node:start waits for it, so the two never race. - The sidebar shows the Node section when a node exists on this machine. - The Node page's status is the node's: its control API and the process list, not the service task's state (a node started from a terminal ran while the page said Stopped). Stop says Stopped only once no meshbay-node.exe is left, and stays offered for a process that answers nothing. - CLI stop kills the pid that answered when a graceful stop does not finish, and fails with the reason when a node process is still there. - The daemon ends its process 3s after _shutdown(): Python's exit waited for a busy indexer thread, with the control API already closed. Armed by main() only, never by a daemon run inside a test. - node.toml is read as utf-8-sig (PowerShell 5.1 writes a BOM), and a config that cannot be read is logged instead of dying silently in service mode. - "Pair this browser" queues the code for the next group of this node to open instead of saying "Paired successfully"; no banner before a group. - test_e2e_windows_app.py (opt-in, MESHBAY_WIN_E2E=1) drives the installed app against a throwaway hub: fresh account to linked node, Stop, Start, Restart, checked against the real processes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client')
-rw-r--r--packages/meshbay-client/src/main.js125
-rw-r--r--packages/meshbay-client/src/preload.js1
2 files changed, 115 insertions, 11 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index f582b57..8e987d3 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -1459,6 +1459,34 @@ function registerBridge() {
return r;
}
+ // Every meshbay-node.exe running, in any session (tasklist lists session 0,
+ // where a service node runs). Whether a node is there is a question for the
+ // process list as well as its control API: the API closes first on the way
+ // down, and a node started some other way than the service task -- from a
+ // terminal, after `meshbay-node init` -- runs while the task reads "Ready".
+ // Both made the Node page say "Stopped" about a node that was running.
+ function winNodePids() {
+ return new Promise((resolve) => {
+ execFile('tasklist', ['/FI', 'IMAGENAME eq meshbay-node.exe', '/NH', '/FO', 'CSV'],
+ { windowsHide: true }, (err, stdout) => {
+ if (err) return resolve([]);
+ resolve(String(stdout || '').split(/\r?\n/)
+ .map((l) => l.split('","'))
+ .filter((c) => c.length > 1 && /^\d+$/.test(c[1]))
+ .map((c) => Number(c[1])));
+ });
+ });
+ }
+
+ // 'active' when a node answers, 'unknown' when a node process is there but
+ // does not (starting, stopping, or stuck: Stop must still be offered),
+ // 'inactive' when there is none.
+ async function winNodeActivity() {
+ const [p, pids] = await Promise.all([probeNode(), winNodePids()]);
+ if (p) return { activeState: 'active', node: p };
+ return { activeState: pids.length ? 'unknown' : 'inactive', node: null };
+ }
+
// 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) {
@@ -1691,14 +1719,15 @@ function registerBridge() {
if (process.platform === 'win32') {
const svc = await winServiceTaskStatus();
if (svc.installed) {
- // Service mode: Task Scheduler already tracks running/not, directly —
- // no need to probe the daemon's own API for this panel.
- const running = /running/i.test(svc.state);
+ // Service mode. Not the task's state alone: a node started from a
+ // terminal runs while the task reads "Ready", and one still on its
+ // way out after a /end reads "Ready" too.
+ const { activeState } = await winNodeActivity();
return {
supported: true,
mode: 'service',
installed: true,
- activeState: running ? 'active' : 'inactive',
+ activeState,
subState: svc.state,
// Whether switching startup mode can actually elevate right now —
// service-mode.ps1 is an extraResource, present in a packaged Full
@@ -1715,7 +1744,7 @@ function registerBridge() {
// gated on `installed`, so with no autostart configured they silently
// vanished — the daemon was perfectly manageable, just not launchable
// at sign-in. `autostart` carries that state as its own field instead.
- const [p, bin] = await Promise.all([probeNode(), findNodeBinary()]);
+ const [{ activeState }, bin] = await Promise.all([winNodeActivity(), findNodeBinary()]);
return {
supported: true,
// Only claim "startup mode" once a node was actually found (bundled
@@ -1726,8 +1755,8 @@ function registerBridge() {
mode: bin ? 'startup' : null,
installed: Boolean(bin),
autostart: winAutostartInstalled(),
- activeState: p ? 'active' : 'inactive',
- subState: p ? 'running' : '',
+ activeState,
+ subState: activeState === 'active' ? 'running' : '',
canElevate: winCanElevateServiceMode(),
};
}
@@ -1759,11 +1788,16 @@ function registerBridge() {
async function nodeServiceStop() {
if (process.platform === 'win32') {
- await killNodeProcesses();
+ const r = 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()) {
+ // Said only once nothing answers and no node process is left: this used
+ // to report success about a service node it could not reach from this
+ // session, then about a process still running with its API closed.
+ if (!r.ok) {
+ throw new Error(`${r.out || 'the node could not be stopped'}
+Its log: ${nodeLogHint()}`);
+ }
+ if (await probeNode() || (await winNodePids()).length) {
throw new Error(`the node could not be stopped. Its log: ${nodeLogHint()}`);
}
return { stopped: true };
@@ -1980,7 +2014,76 @@ function registerBridge() {
}
}
+ // ── Windows: the node this application ships, set up for whoever signs in ──
+ // Installing MeshBay installs a node, and signing in is all it should take
+ // to have one running for that account. Nothing did it: node.toml was
+ // written by the Create Group wizard alone, which the home page offers only
+ // to an account with no groups -- a member of somebody else's group, or
+ // anyone who clicked "Skip setup", never saw it. A fresh install then left
+ // the service task starting a node that quit at once ("hub.username not
+ // set"), the account with no node key, and the sidebar without its Node
+ // section; the way out was `meshbay-node init` in a terminal and the key
+ // pasted on the profile page (the first Windows beta tester, 2026-10).
+ //
+ // It never overrides a choice: a node set up for another account or hub is
+ // left alone (node:start asks before changing that), and an account already
+ // linked to another node keeps it -- `PUT /me/node_key` replaces, and the
+ // node it replaced would stop working with nothing said.
+ let nodeEnsuring = null;
+
+ async function ensureNode(opts) {
+ if (process.platform !== 'win32' || !hasBundledNode()) return { state: 'unsupported' };
+ const hubUrl = String(config.hubBase || '').replace(/\/+$/, '');
+ const username = String((opts && opts.username) || '');
+ const token = opts && opts.token;
+ if (!hubUrl || !token || !USERNAME_RE.test(username)) return { state: 'unsupported' };
+ const current = provisionedAs();
+ if (current && (!sameHub(current.hubUrl, hubUrl) || current.username !== username)) {
+ return { state: 'other_account' };
+ }
+ if (!current) provisionNode(hubUrl, username);
+
+ let p = await probeNode();
+ if (!p) {
+ try {
+ // However this machine is set up: the service task, or a process of
+ // its own -- and it reports what answered, not what it launched.
+ await winNodeStartVia(['autostart', 'start']);
+ } catch (err) {
+ console.error('[node] start after sign-in:', err.message);
+ return { state: 'not_started' };
+ }
+ if ((await winStartupMode()) !== 'service') nodeStartedByApp = true;
+ p = await waitForNode(Date.now() + 15000);
+ if (!p) return { state: 'not_started' };
+ }
+ if (p.status === 'running') return { state: 'running' };
+
+ let linked = '';
+ try {
+ const r = await fetch(`${hubUrl}/v1/users/${encodeURIComponent(username)}/pubkeys`, {
+ headers: { Authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!r.ok) return { state: 'not_linked' };
+ linked = (await r.json()).pk_node_ed25519 || '';
+ } catch { return { state: 'not_linked' }; }
+ if (linked && linked !== p.pk_node_ed25519) return { state: 'other_node' };
+ const ready = await linkNodeKeyAndAwaitRunning({ token, hubUrl }, Date.now() + 90000);
+ return { state: ready && ready.status === 'running' ? 'running' : 'not_linked' };
+ }
+
+ handle('node:ensure', async (_e, opts) => {
+ if (!nodeEnsuring) {
+ nodeEnsuring = ensureNode(opts).finally(() => { nodeEnsuring = null; });
+ }
+ return nodeEnsuring;
+ });
+
handle('node:start', async (_e, opts) => {
+ // One at a time: a start racing the sign-in's own would stop the node the
+ // other had just started.
+ if (nodeEnsuring) await nodeEnsuring.catch(() => {});
const already = await probeNode();
if (already && already.status === 'running') {
return { started: true, ...already };
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index 0de76db..9b466f4 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -143,6 +143,7 @@ contextBridge.exposeInMainWorld('meshbay', {
// PATH. Windows only; other platforms always resolve true.
bundled: () => ipcRenderer.invoke('node:bundled'),
start: (opts) => ipcRenderer.invoke('node:start', opts),
+ ensure: (opts) => ipcRenderer.invoke('node:ensure', opts),
op: (name, args) => ipcRenderer.invoke('node:op', name, args),
pairingCode: () => ipcRenderer.invoke('node:pairing-code'),
setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code),