aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/PACKAGING-GUIDE.md60
-rw-r--r--packages/meshbay-client/src/main.js125
-rw-r--r--packages/meshbay-client/src/preload.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js23
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js10
-rw-r--r--packages/meshbay-hub/tests/test_node_page_pairing.py20
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/lifecycle.py57
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py6
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py47
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py29
-rw-r--r--packages/meshbay-node/tests/test_e2e_windows_app.py286
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py5
-rw-r--r--packages/meshbay-node/tests/test_windows_node_lifecycle.py235
24 files changed, 885 insertions, 59 deletions
diff --git a/docs/PACKAGING-GUIDE.md b/docs/PACKAGING-GUIDE.md
index 9649826..fdc0a57 100644
--- a/docs/PACKAGING-GUIDE.md
+++ b/docs/PACKAGING-GUIDE.md
@@ -71,20 +71,21 @@ Run the installer. It is **per-user** and lands in
node daemon ships beside the app at `resources\node-runtime\meshbay-node.exe`;
the client finds it automatically.
-It asks two things, both skippable:
+It asks one thing — **when the node runs**:
-- **"Run MeshBay Node as a background service?"** — Yes starts the node **at
- boot, before you even sign in**, and needs one administrator confirmation
- (which also sets up the firewall rules, in the same step — see below). No
- keeps the normal per-user mode: the node starts when you sign in, with no
- admin needed, and you can turn autostart on later from the Node page.
-- **(per-user mode only) "Allow MeshBay through Windows Firewall now?"** — one
- administrator confirmation adds the inbound rules the client and the node
- need for WebRTC and casting. Declining is fine — Windows shows its own
- "Allow access" dialog instead, the first time each is actually used.
+- **As a background service** (the default) — the node starts **at boot,
+ before you even sign in**.
+- **Automatically when I sign in to Windows** — a launcher in your Startup
+ folder, no service.
+- **Only while MeshBay is open** — the app starts the node and stops it when
+ you quit.
-Running setup again (an upgrade, a repair install) asks neither question if
-the firewall rules are already there.
+Every choice also adds the Windows Firewall rules the app and the node need
+(WebRTC, casting), so setup asks for **one administrator confirmation**: for the
+service and the rules together, or for the rules alone. Running setup again (an
+upgrade, a repair) keeps the choice it finds and asks nothing when the rules and
+the service are already in place. The choice can be changed later on the
+**Node** page (**Start automatically**).
**ffmpeg** — required for video streaming, **bundled in the installer by
default** (verified, checksummed, GPLv3-licensed; `LICENSE-ffmpeg.txt` ships
@@ -92,29 +93,38 @@ alongside it). Nothing to install separately.
### First run
-Open MeshBay and sign in. Use the **Node** page (or a terminal) to provision:
+Open MeshBay, choose the hub and sign in. **That is all**: the app sets the node
+up for the account you signed in with, starts it the way you chose at install,
+and links it to your account. **Node** and **Create group** appear in the
+sidebar; nothing has to be typed in a terminal and no key has to be copied.
-```
-meshbay-node init
-meshbay-node autostart install # per-user mode: run at every sign-in (no admin)
-meshbay-node service install # service mode: run at boot (needs an elevated prompt)
-```
+Two cases where the app does not do it on its own, because it would undo
+something:
+
+- the node was already set up for **another account or another hub** — the
+ **Start** button on the Node page asks before switching it;
+- your account is already linked to a node on **another machine** — linking
+ this one would disconnect that one. The Node page shows this node's key;
+ **Profile → Link Node** moves the link here if that is what you want.
+
+The Node page's **Start / Stop / Restart** work in every mode, and its status
+is the node's own: **Running** when it answers, **Stopped** only once no
+`meshbay-node.exe` is left. If a node will not stop, the page says so and why.
-The Node page's Start/Stop/Restart buttons work the same either way — they
-drive the Scheduled Task when service mode is active, or the daemon process
-directly otherwise.
+The node logs to `%LOCALAPPDATA%\meshbay\state\node.log` — in service mode
+that file is the only place it can tell you why it would not start.
Runtime data — `node.toml`, `keystore.enc`, `unlock.key`, `data\` — lives in
-`%LOCALAPPDATA%\meshbay\` and **survives uninstall/reinstall**, in either mode
+`%LOCALAPPDATA%\meshbay\` and **survives uninstall/reinstall**, in every mode
(service mode runs as your own account too — never LocalSystem — so nothing
about where your data lives changes).
### Uninstall
*Apps & features → MeshBay → Uninstall*, or the Start-menu *Uninstall MeshBay*
-entry. It stops a running daemon and removes the sign-in launcher; it offers
-(opt-in, one admin confirmation) to also remove the firewall rules and the
-boot-time service task, if you set one up. None of this touches
+entry. It stops a running node, removes the sign-in launcher, and removes the
+firewall rules and the boot-time service task with one administrator
+confirmation (none if neither is there). None of this touches
`%LOCALAPPDATA%\meshbay\` (the keystore).
### Build from source
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),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index c1913c0..b9466d0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -429,7 +429,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
// ── Sidebar ──────────────────────────────────────────────────────────────────
-function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey,
+function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, showNode,
allowPublicGroups = true }) {
const isStaff = role === 'moderator' || role === 'admin';
return html`
@@ -449,7 +449,7 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, ha
<a class="sidebar-item ${route === '/search' ? 'active' : ''}"
href="#/search"><${Icon} name="search" /> ${t('sidebar.search')}</a>
</div>
- ${platform.capabilities.nodeAdmin && hasNodeKey && html`
+ ${platform.capabilities.nodeAdmin && showNode && html`
<div class="sidebar-section">
<div class="sidebar-heading">${t('sidebar.node')}</div>
<a class="sidebar-item ${route === '/node' ? 'active' : ''}"
@@ -781,6 +781,9 @@ function App() {
const [notifDisabled, setNotifDisabled] = useState(false);
const [userPrefs, setUserPrefs] = useState({});
const [hasNodeKey, setHasNodeKey] = useState(false);
+ // A node on this machine, linked to the account or not: the Node section is
+ // where it is looked after, so it cannot wait for a link to show up.
+ const [localNode, setLocalNode] = useState(false);
// Instance policy, fetched once, unauthenticated. `null` until it answers;
// treat unknown as "allowed" so a slow hub never blocks a legitimate private
// group — the hub refuses a public one server-side regardless.
@@ -1041,6 +1044,25 @@ function App() {
fetchNotifications();
}, [user]);
+ // The node this application ships, set up, started and linked for whoever
+ // signs in (main.js `ensureNode`): installing it is all it should take.
+ // Keyed on the account, not the session object -- the token rotates, and a
+ // renewal is not a sign-in.
+ const signedInAs = user ? user.username : null;
+ useEffect(() => {
+ setLocalNode(false);
+ if (!signedInAs || !platform.capabilities.nodeAdmin || !platform.node.available) return;
+ let cancelled = false;
+ platform.node.installed()
+ .then((r) => { if (!cancelled) setLocalNode(Boolean(r && r.installed)); })
+ .catch(() => {});
+ const live = userRef.current;
+ platform.node.ensure({ username: live.username, token: live.token })
+ .then(() => { if (!cancelled) refreshNodeKey(); })
+ .catch(() => {});
+ return () => { cancelled = true; };
+ }, [signedInAs]);
+
// After an invitation is accepted: the group is ours now, and only the hub
// knows its row the way `/mine` answers it.
const reloadGroups = useCallback(() => {
@@ -1316,7 +1338,7 @@ function App() {
.catch(() => {});
refreshNodeKey();
}} />`;
- } else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) {
+ } else if (route === '/node' && platform.capabilities.nodeAdmin && (hasNodeKey || localNode)) {
page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`;
} else if (groupId) {
page = html`<${GroupPage}
@@ -1380,7 +1402,7 @@ function App() {
menuOpen=${menuOpen}
role=${user.role}
allowPublicGroups=${allowPublicGroups}
- hasNodeKey=${hasNodeKey} />`}
+ showNode=${hasNodeKey || localNode} />`}
${menuOpen && html`<div class="overlay visible"
onClick=${() => setMenuOpen(false)} />`}
<main class="main">
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 f4151f7..4dff605 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -939,6 +939,7 @@ export default {
'node.pair_code_placeholder': 'Kopplungscode',
'node.pair_button': 'Diesen Browser koppeln',
'node.pair_success': 'Erfolgreich gekoppelt.',
+ 'node.pair_pending': 'Kopplungscode bereit: Er wird verwendet, wenn Sie das nächste Mal eine Gruppe dieses Nodes öffnen.',
'node.reload': 'Konfiguration neu laden',
'node.reloaded': 'Konfiguration neu geladen. Verzeichnisänderungen an bestehenden Gruppen sind jetzt aktiv.',
'node.restart_needed': 'Nach dem Hinzufügen: Gruppenschlüssel initialisieren (meshbay-node gek-init --group <name>).',
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 84607e7..90c043e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1079,6 +1079,7 @@ export default {
'node.pair_code_placeholder': 'Pairing code',
'node.pair_button': 'Pair this browser',
'node.pair_success': 'Paired successfully.',
+ 'node.pair_pending': "Pairing code ready: it is used the next time you open one of this node's groups.",
'node.reload': 'Reload config',
'node.reloaded': 'Config reloaded. Root changes on existing groups are now active.',
'node.attach_group': 'Add group',
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 28ad85e..7b46b17 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -933,6 +933,7 @@ export default {
'node.pair_code_placeholder': 'Código de emparejamiento',
'node.pair_button': 'Emparejar este navegador',
'node.pair_success': 'Emparejamiento exitoso.',
+ 'node.pair_pending': 'Código de emparejamiento listo: se usará la próxima vez que abra uno de los grupos de este Node.',
'node.reload': 'Recargar configuración',
'node.reloaded': 'Configuración recargada. Los cambios de directorios en los grupos existentes ya están activos.',
'node.restart_needed': 'Después de añadir: inicialice la clave de grupo (meshbay-node gek-init --group <nombre>).',
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 9e8dbbe..d3f9200 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -936,6 +936,7 @@ export default {
'node.pair_code_placeholder': "Code d'appairage",
'node.pair_button': 'Appairer ce navigateur',
'node.pair_success': 'Appairage réussi.',
+ 'node.pair_pending': "Code d'appairage prêt : il sera utilisé à la prochaine ouverture d'un des groupes de ce node.",
'node.reload': 'Recharger la configuration',
'node.reloaded': 'Configuration rechargée. Les modifications de répertoires sur les groupes existants sont maintenant actives.',
'node.restart_needed': 'Après l\'ajout : initialisez la clé de groupe (meshbay-node gek-init --group <name>).',
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 493455d..715738e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -935,6 +935,7 @@ export default {
'node.pair_code_placeholder': 'Codice di associazione',
'node.pair_button': 'Associa questo browser',
'node.pair_success': 'Associazione riuscita.',
+ 'node.pair_pending': 'Codice di associazione pronto: verrà usato la prossima volta che apri uno dei gruppi di questo Node.',
'node.reload': 'Ricarica configurazione',
'node.reloaded': 'Configurazione ricaricata. Le modifiche alle directory dei gruppi esistenti sono ora attive.',
'node.restart_needed': "Dopo l'aggiunta: inizializzi la chiave di gruppo (meshbay-node gek-init --group <nome>).",
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 35a639a..23358c2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -923,6 +923,7 @@ export default {
'node.pair_code_placeholder': 'ペアリングコード',
'node.pair_button': 'このブラウザをペアリング',
'node.pair_success': 'ペアリングに成功しました。',
+ 'node.pair_pending': 'ペアリングコードの準備ができました。この Node のグループを次に開いたときに使用されます。',
'node.reload': '設定を再読み込み',
'node.reloaded': '設定を再読み込みしました。既存グループのルート変更が反映されました。',
'node.restart_needed': '追加後、グループ鍵を初期化してください(meshbay-node gek-init --group <name>)。',
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 73731cf..5e53084 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -937,6 +937,7 @@ export default {
'node.pair_code_placeholder': 'Koppelingscode',
'node.pair_button': 'Deze browser koppelen',
'node.pair_success': 'Succesvol gekoppeld.',
+ 'node.pair_pending': 'Koppelingscode klaar: die wordt gebruikt zodra u een van de groepen van deze Node opent.',
'node.reload': 'Configuratie herladen',
'node.reloaded': 'Configuratie herladen. Mapwijzigingen op bestaande groepen zijn nu actief.',
'node.restart_needed': 'Na het toevoegen: initialiseer de groepssleutel (meshbay-node gek-init --group <naam>).',
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 849c632..5d6d852 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -955,6 +955,7 @@ export default {
'node.pair_code_placeholder': 'Kod parowania',
'node.pair_button': 'Sparuj tę przeglądarkę',
'node.pair_success': 'Parowanie zakończone sukcesem.',
+ 'node.pair_pending': 'Kod parowania gotowy: zostanie użyty przy następnym otwarciu jednej z grup tego Node.',
'node.reload': 'Przeładuj konfigurację',
'node.reloaded': 'Konfiguracja przeładowana. Zmiany katalogów w istniejących grupach są teraz aktywne.',
'node.restart_needed': 'Po dodaniu: zainicjalizuj klucz grupy (meshbay-node gek-init --group <nazwa>).',
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 5672b60..6fe50f6 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
@@ -934,6 +934,7 @@ export default {
'node.pair_code_placeholder': 'Código de pareamento',
'node.pair_button': 'Parear este navegador',
'node.pair_success': 'Pareamento realizado com sucesso.',
+ 'node.pair_pending': 'Código de pareamento pronto: ele será usado na próxima vez que você abrir um dos grupos deste Node.',
'node.reload': 'Recarregar configuração',
'node.reloaded': 'Configuração recarregada. Alterações de diretórios em grupos existentes estão ativas.',
'node.restart_needed': 'Após adicionar: inicialize a chave do grupo (meshbay-node gek-init --group <nome>).',
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 26025ef..b3886ff 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
@@ -911,6 +911,7 @@ export default {
'node.pair_code_placeholder': '配对码',
'node.pair_button': '配对此浏览器',
'node.pair_success': '配对成功。',
+ 'node.pair_pending': '配对码已就绪:下次打开此 Node 的任一群组时将自动使用。',
'node.reload': '重新加载配置',
'node.reloaded': '配置已重新加载。对现有群组的目录更改现已生效。',
'node.restart_needed': '添加后请初始化群组密钥(meshbay-node gek-init --group <名称>)。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index 9d230c1..04f4abc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -6,7 +6,7 @@ import { t } from './i18n.js';
import { ask } from './ask.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
-import { HUB } from './hub-client.js';
+import { HUB, session } from './hub-client.js';
// ── Node management (D5) ────────────────────────────────────────────────────
//
@@ -108,6 +108,9 @@ function NodeServicePanel({ onChanged, token, username }) {
const KNOWN_STATES = ['active', 'inactive', 'failed', 'activating', 'deactivating'];
const stateKey = KNOWN_STATES.includes(info.activeState) ? info.activeState : 'unknown';
const running = info.activeState === 'active' || info.activeState === 'activating';
+ // A node process that does not answer ('unknown': on its way up or down, or
+ // stuck) is still one to stop -- only "nothing there" takes Stop away.
+ const stoppable = info.activeState !== 'inactive';
const dot = info.activeState === 'active' ? 'online'
: info.activeState === 'failed' ? 'offline' : 'unknown';
const label = info.installed ? t('node.service_state_' + stateKey)
@@ -132,7 +135,7 @@ function NodeServicePanel({ onChanged, token, username }) {
onClick=${() => act('start', () => platform.node.start({ hubUrl: HUB, username, token }))}>
${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button>
${info.installed && html`
- <button class="btn btn-small btn-secondary" disabled=${!!busy || !running}
+ <button class="btn btn-small btn-secondary" disabled=${!!busy || !stoppable}
onClick=${() => act('stop', () => platform.node.service.stop())}>
${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button>
<button class="btn btn-small btn-secondary" disabled=${!!busy}
@@ -520,13 +523,17 @@ export function NodePage({ groups, token, username }) {
setPairBusy(true);
setPairStatus('');
try {
+ // An operator code is redeemed over MNP, with the account's identity in
+ // a group -- this page has no transport. It used to store the code where
+ // nothing read it and say "Paired successfully", and the banner came back
+ // on the next refresh. Queued the way the Create Group wizard queues it:
+ // the next group of this node to open redeems it.
const result = await nodeOp('pairOperator');
if (result && result.code) {
await platform.node.setPairingCode(result.code);
+ session.pendingJoinCode = result.code;
}
- setPairStatus('paired');
- setOperatorPaired(true);
- await refresh();
+ setPairStatus('pending');
} catch (err) {
setPairStatus(platform.bridgeMessage(err));
} finally {
@@ -712,15 +719,15 @@ export function NodePage({ groups, token, username }) {
</div>
<${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} />
${actionMsg && html`<div class="node-message">${actionMsg}</div>`}
- ${operatorPaired === false && html`
+ ${operatorPaired === false && nodeGroups.length > 0 && html`
<div class="node-pair-banner">
<p>${t('node.pair_needed')}</p>
<button class="btn btn-primary btn-small"
disabled=${pairBusy}
onClick=${doPairOperator}>
${pairBusy ? t('node.settings_saving') : t('node.pair_button')}</button>
- ${pairStatus === 'paired'
- ? html`<p class="success-msg">${t('node.pair_success')}</p>`
+ ${pairStatus === 'pending'
+ ? html`<p class="success-msg">${t('node.pair_pending')}</p>`
: pairStatus ? html`<p class="error-msg">${pairStatus}</p>` : null}
</div>
`}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 28817c7..e3b3d2d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -253,6 +253,16 @@ export const node = {
return bridge.node.start(opts);
},
/**
+ * After sign-in: set up, start and link the node this application ships,
+ * for the signed-in account ({username, token}). Leaves alone a node set up
+ * for another account and an account linked to another node. Resolves to
+ * `{ state }`; a build or platform with nothing to do answers 'unsupported'.
+ */
+ async ensure(opts) {
+ if (!bridge || !bridge.node || !bridge.node.ensure) return { state: 'unsupported' };
+ return bridge.node.ensure(opts);
+ },
+ /**
* One of the local node's operations, by name (`NODE_OPS` in the desktop
* client's main.js). The page never names a route: the main process checks
* the arguments, builds the request, and asks the person itself before
diff --git a/packages/meshbay-hub/tests/test_node_page_pairing.py b/packages/meshbay-hub/tests/test_node_page_pairing.py
index 435488e..c716e0a 100644
--- a/packages/meshbay-hub/tests/test_node_page_pairing.py
+++ b/packages/meshbay-hub/tests/test_node_page_pairing.py
@@ -46,3 +46,23 @@ def test_the_banner_needs_an_explicit_false():
"the pairing banner must not show for a node that has not read its roster")
assert "useState(null)" in src.split("const [operatorPaired", 1)[1].split("\n", 1)[0]
assert "setOperatorPaired(!!" not in src
+
+
+def test_pair_this_browser_queues_the_code_instead_of_claiming_success():
+ """It stored the code where nothing read it, said "Paired successfully",
+ and the banner came back on the next refresh. The code is redeemed over
+ MNP by the next group of this node to open, as the wizard's is."""
+ src = _source()
+ pair = src.split("const doPairOperator = useCallback(", 1)[1].split("}, [refresh]);", 1)[0]
+ assert "session.pendingJoinCode = result.code;" in pair
+ assert "setOperatorPaired(true)" not in pair
+ assert "import { HUB, session } from './hub-client.js';" in src
+
+
+def test_no_pairing_banner_for_a_node_with_no_group_yet():
+ """A node set up at sign-in hosts nothing yet: there is nothing to pair
+ for until a group exists, and creating one pairs."""
+ src = _source()
+ banner = src.index('class="node-pair-banner"')
+ guard = src.rindex("${", 0, banner)
+ assert "nodeGroups.length > 0" in src[guard:banner]
diff --git a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
index 901c3d5..b461055 100644
--- a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
+++ b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py
@@ -55,23 +55,36 @@ def _running_status(cfg) -> dict | None:
return _await_daemon(cfg, since=0, timeout=0.1)
+class NodeStillRunning(RuntimeError):
+ pass
+
+
def _stop_node(cfg) -> str:
"""Stop the node, whichever session it runs in: through its own control API
- first, so it shuts down properly, then by force. Returns how it went."""
+ first, so it shuts down properly, then by force. Returns how it went, and
+ raises NodeStillRunning when a node process is still there at the end --
+ never "stopped" about a process nobody saw go."""
import time
from meshbay_node.platform import (
+ _pid_alive,
autostart_end,
+ kill_pid,
+ node_pids,
request_graceful_stop,
service_end,
service_state,
service_status,
)
+ answering = _running_status(cfg)
+ pid = answering.get("pid") if answering else None
if request_graceful_stop(cfg.data_dir, cfg.node.ui_port):
how = "stopped"
- elif _running_status(cfg) is None:
+ elif answering is None and not node_pids():
how = "not running"
else:
+ # Asked and not gone: its control API closes first, so it may answer
+ # nothing while its process is still there.
how = "stopped (forced)"
if service_status()["installed"]:
# Also leaves the task "Ready": a /run while it still reads "Running"
@@ -80,7 +93,17 @@ def _stop_node(cfg) -> str:
deadline = time.monotonic() + 15
while service_state().lower() == "running" and time.monotonic() < deadline:
time.sleep(0.25)
+ if pid is not None and _pid_alive(int(pid)):
+ kill_pid(int(pid)) # the one that answered, by pid, wherever it came from
autostart_end() # a node in this session that would not stop
+ deadline = time.monotonic() + 5
+ while (left := node_pids()) and time.monotonic() < deadline:
+ time.sleep(0.25)
+ if left:
+ raise NodeStillRunning(
+ f"meshbay-node is still running (pid {', '.join(map(str, left))}) and could "
+ "not be stopped from here -- it was probably started from an "
+ "administrator prompt. Stop it there, or end it in Task Manager.")
return how
@@ -94,7 +117,12 @@ def _start_and_confirm(args, action: str) -> None:
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
if action == "restart":
- _stop_node(cfg)
+ try:
+ _stop_node(cfg)
+ except NodeStillRunning as e:
+ # Starting another would find the port taken and quit at once.
+ print(f"Could not restart the node: {e}")
+ sys.exit(1)
else:
already = _running_status(cfg)
if already is not None:
@@ -122,6 +150,21 @@ def _start_and_confirm(args, action: str) -> None:
"its files were not replaced, or another copy was started.")
+def _print_stop(args) -> None:
+ try:
+ print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)))
+ except NodeStillRunning as e:
+ print(e)
+ sys.exit(1)
+
+
+def _stop_node_or_warn(args) -> None:
+ try:
+ _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))
+ except NodeStillRunning as e:
+ print(f"warning: {e}")
+
+
def restart_daemon(args) -> None:
if sys.platform == "win32":
_start_and_confirm(args, "restart")
@@ -161,7 +204,7 @@ def autostart(args) -> None:
elif sub == "start":
_start_and_confirm(args, "start")
elif sub == "stop":
- print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)))
+ _print_stop(args)
elif sub == "status":
st = _plat.autostart_status()
if st["installed"]:
@@ -186,7 +229,7 @@ def service(args) -> None:
if sub == "install":
# A node already running in this session holds the control API's port:
# the service's own would exit at once, leaving the old one in charge.
- _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))
+ _stop_node_or_warn(args)
try:
_plat.service_install()
except RuntimeError as e:
@@ -201,7 +244,7 @@ def service(args) -> None:
elif sub == "remove":
# Deleting a task does not end its running instance: stop the node
# first, or it runs on in session 0 with nothing left to stop it.
- _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))
+ _stop_node_or_warn(args)
_plat.service_remove()
print(f"Removed the {_plat.TASK_NAME!r} scheduled task.")
elif sub == "start":
@@ -211,7 +254,7 @@ def service(args) -> None:
sys.exit(1)
_start_and_confirm(args, "start")
elif sub == "stop":
- print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)))
+ _print_stop(args)
elif sub == "status":
st = _plat.service_status()
if st["installed"]:
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index 2cd8a8f..eb694ae 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -403,7 +403,11 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg = Config()
if path.exists():
- raw = tomllib.loads(path.read_text(encoding="utf-8"))
+ # utf-8-sig: Windows PowerShell 5.1's `Set-Content -Encoding utf8` and
+ # older Notepads write a BOM, which TOML refuses -- and a service node
+ # then stopped before it had said anything (found editing node.toml
+ # on a real install).
+ raw = tomllib.loads(path.read_text(encoding="utf-8-sig"))
hub = raw.get("hub", {})
cfg.hub.url = hub.get("url", cfg.hub.url)
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index e1aac41..6fba120 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -31,6 +31,7 @@ import os
import signal
import socket
import sys
+import threading
import time
from dataclasses import asdict
from pathlib import Path
@@ -81,6 +82,31 @@ if WEBRTC_AVAILABLE:
log = logging.getLogger(__name__)
+# How long a stopped node's process may outlive its _shutdown(), and the most
+# _shutdown() itself may take. Python's own exit waits for every worker thread,
+# and one still walking or hashing a large tree kept the process alive for as
+# long as that took, with its control API already closed: the desktop app saw
+# no node and said "Stopped" about a process that was still there.
+EXIT_GRACE_SECS = 3.0
+SHUTDOWN_DEADLINE_SECS = 30.0
+
+
+def exit_after(seconds: float, why: str, code: int = 0) -> threading.Timer:
+ """End this process in `seconds`, whatever is still running in it."""
+ def _exit() -> None:
+ busy = sorted(t.name for t in threading.enumerate()
+ if t is not threading.current_thread() and not t.daemon
+ and t is not threading.main_thread())
+ log.warning("%s -- ending the process now (still busy: %s)",
+ why, ", ".join(busy) or "nothing")
+ logging.shutdown()
+ os._exit(code)
+ timer = threading.Timer(seconds, _exit)
+ timer.daemon = True
+ timer.start()
+ return timer
+
+
# ── Hub WS sender bridge ─────────────────────────────────────────────────────
class _WsSender:
@@ -101,6 +127,9 @@ def _root_shape(roots) -> set[tuple]:
class NodeDaemon(EnrichmentMixin):
+ # Set by main(), never by a test that runs a daemon in its own process.
+ exit_process_when_stopped = False
+
def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH):
self._config = config
self._config_path = config_path
@@ -1564,6 +1593,9 @@ class NodeDaemon(EnrichmentMixin):
async def _shutdown(self) -> None:
log.info("Shutting down...")
self._state["status"] = "stopping"
+ if self.exit_process_when_stopped:
+ exit_after(SHUTDOWN_DEADLINE_SECS,
+ f"shutdown took more than {SHUTDOWN_DEADLINE_SECS:.0f}s", code=1)
for handle in self._pending_broadcasts.values():
handle.cancel()
@@ -1624,6 +1656,8 @@ class NodeDaemon(EnrichmentMixin):
token_file.unlink(missing_ok=True)
log.info("Node stopped")
+ if self.exit_process_when_stopped:
+ exit_after(EXIT_GRACE_SECS, "the node has stopped")
# ── Entry point ───────────────────────────────────────────────────────────────
@@ -1668,7 +1702,15 @@ def main() -> None:
# Printed for whoever ran it, and logged too: a daemon started by the
# service task has no console, and these are why it would refuse to start.
- cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ config_path = args.config or DEFAULT_CONFIG_PATH
+ try:
+ cfg = load_config(config_path)
+ except Exception as e:
+ # Uncaught, this went to a stderr that Task Scheduler discards: the
+ # node died with "Logging to ..." as its last word.
+ print(f"Error: cannot read {config_path}: {e}")
+ log.error("cannot read %s: %s", config_path, e)
+ sys.exit(1)
if not cfg.hub.username:
print("Error: hub.username not set in config. Run: meshbay-node init")
log.error("hub.username not set in config. Run: meshbay-node init")
@@ -1681,7 +1723,8 @@ def main() -> None:
log.error("%s", e)
sys.exit(1)
- daemon = NodeDaemon(cfg, Path(args.config or DEFAULT_CONFIG_PATH))
+ daemon = NodeDaemon(cfg, Path(config_path))
+ daemon.exit_process_when_stopped = True
try:
asyncio.run(daemon.run())
except ControlPortTaken as e:
diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py
index 88db83d..c0e4d00 100644
--- a/packages/meshbay-node/src/meshbay_node/platform.py
+++ b/packages/meshbay-node/src/meshbay_node/platform.py
@@ -397,6 +397,35 @@ def autostart_end() -> None:
subprocess.run(argv, capture_output=True)
+def node_pids() -> list[int]:
+ """Every meshbay-node.exe running, in any session, except this process and
+ its parent (the CLI is meshbay-node.exe too). Empty off Windows.
+
+ What says whether a node is still there after a stop: its control API
+ closes first, so a process that has not exited yet answers nothing and
+ used to count as gone.
+ """
+ if sys.platform != "win32":
+ return []
+ r = subprocess.run(["tasklist", "/FI", f"IMAGENAME eq {NODE_IMAGE}", "/NH", "/FO", "CSV"],
+ capture_output=True, text=True)
+ mine = {os.getpid(), os.getppid()}
+ pids = []
+ for line in r.stdout.splitlines():
+ cells = [c.strip('"') for c in line.split('","')]
+ if len(cells) > 1 and cells[1].isdigit() and int(cells[1]) not in mine:
+ pids.append(int(cells[1]))
+ return pids
+
+
+def kill_pid(pid: int) -> None:
+ """Force-stop one process and its children (its ffmpeg transcodes). Reaches
+ this session only, unless the caller is elevated. Windows only: systemd
+ stops the node everywhere else."""
+ if sys.platform == "win32":
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True)
+
+
# ── Service mode (Windows, opt-in at install time) ───────────────────────────
#
# The Startup-folder .vbs above only ever runs after *this* user signs in. A
diff --git a/packages/meshbay-node/tests/test_e2e_windows_app.py b/packages/meshbay-node/tests/test_e2e_windows_app.py
new file mode 100644
index 0000000..b407aa8
--- /dev/null
+++ b/packages/meshbay-node/tests/test_e2e_windows_app.py
@@ -0,0 +1,286 @@
+"""The installed Windows application, driven the way a person uses it.
+
+Opt-in (MESHBAY_WIN_E2E=1): it needs an installed MeshBay, launches it, and
+starts and stops the real node. Everything it asserts was broken at least once
+on a real install while every other test passed:
+
+- a fresh account signs in and, with nothing else done, has a node set up,
+ started, linked and listed in the sidebar (it used to take `meshbay-node
+ init` in a terminal and a key pasted on the profile page);
+- Stop leaves no meshbay-node.exe behind and only then says "Stopped";
+- Start and Restart answer with a running node, a new one for Restart.
+
+Before running it:
+- install MeshBay with the setup .exe ("background service" is the default;
+ MESHBAY_E2E_MODE=signin|open when another was chosen);
+- no %LOCALAPPDATA%\\meshbay and no %APPDATA%\\meshbay-client: it refuses to
+ touch an existing node or profile -- move them aside first;
+- run it from an ordinary terminal. Inside a packaged application (MSIX,
+ such as an IDE or agent installed from the Store) %LOCALAPPDATA% and
+ %APPDATA% are virtualised for every child process: what this test writes
+ there, a node started by Task Scheduler never sees. Start it outside the
+ package (Win32_Process.Create, a plain console window).
+
+ $env:MESHBAY_WIN_E2E = 1
+ pytest packages/meshbay-node/tests/test_e2e_windows_app.py -v -s
+
+The hub is a throwaway one on loopback (SQLite, a fresh key), never a real one.
+"""
+
+import json
+import os
+import secrets
+import socket
+import sqlite3
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+pytestmark = [
+ pytest.mark.skipif(sys.platform != "win32", reason="the Windows application"),
+ pytest.mark.skipif(os.environ.get("MESHBAY_WIN_E2E") != "1",
+ reason="opt-in: set MESHBAY_WIN_E2E=1 (see the module docstring)"),
+ pytest.mark.timeout(900),
+]
+
+LOCALAPPDATA = Path(os.environ.get("LOCALAPPDATA", ""))
+APPDATA = Path(os.environ.get("APPDATA", ""))
+INSTALL = Path(os.environ.get("MESHBAY_E2E_INSTALL", LOCALAPPDATA / "Programs" / "MeshBay"))
+NODE_HOME = LOCALAPPDATA / "meshbay"
+APP_PROFILE = APPDATA / "meshbay-client"
+MODE = os.environ.get("MESHBAY_E2E_MODE", "service")
+CDP_PORT = 9333
+
+
+def _free_port() -> int:
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def _launch(argv: list[str]) -> None:
+ """Start a process detached: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP."""
+ subprocess.Popen(argv, creationflags=0x00000008 | 0x00000200, close_fds=True)
+
+
+def _node_pids() -> list[int]:
+ r = subprocess.run(["tasklist", "/FI", "IMAGENAME eq meshbay-node.exe", "/NH", "/FO", "CSV"],
+ capture_output=True, text=True)
+ return [int(c[1]) for c in (line.split('","') for line in r.stdout.splitlines())
+ if len(c) > 1 and c[1].isdigit()]
+
+
+def _wait(what: str, cond, timeout: float, every: float = 1.0):
+ deadline = time.monotonic() + timeout
+ last = None
+ while time.monotonic() < deadline:
+ last = cond()
+ if last:
+ return last
+ time.sleep(every)
+ raise AssertionError(f"timed out after {timeout:.0f}s waiting for {what} (last: {last!r})")
+
+
+SUBMIT = "[...document.querySelectorAll('main form button[type=submit]')].pop().click()"
+
+
+class Page:
+ """The application's window over the DevTools protocol."""
+
+ HELPERS = """
+ var fill = (el, v) => {
+ const proto = el.tagName === 'SELECT' ? HTMLSelectElement.prototype
+ : HTMLInputElement.prototype;
+ Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, v);
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ };
+ var clickText = (t) => {
+ const els = [...document.querySelectorAll('button, a')].filter(e => !e.disabled);
+ const el = els.find(e => e.innerText.trim() === t)
+ || els.find(e => e.innerText.trim().startsWith(t));
+ if (!el) throw new Error('nothing to click named ' + t);
+ el.click();
+ };
+ """
+
+ def __init__(self):
+ from websockets.sync.client import connect
+ targets = json.loads(_http_get(f"http://127.0.0.1:{CDP_PORT}/json/list"))
+ page = next(t for t in targets if t["type"] == "page" and t["url"].startswith("app://"))
+ self._conn = connect(page["webSocketDebuggerUrl"], max_size=None)
+ self._ws = self._conn.__enter__()
+ self._id = 0
+
+ def eval(self, expr: str):
+ self._id += 1
+ self._ws.send(json.dumps({"id": self._id, "method": "Runtime.evaluate", "params": {
+ "expression": f"{self.HELPERS}\n(async () => ({expr}))()",
+ "awaitPromise": True, "returnByValue": True, "userGesture": True}}))
+ while True:
+ msg = json.loads(self._ws.recv(timeout=120))
+ if msg.get("id") == self._id:
+ break
+ res = msg["result"]
+ if "exceptionDetails" in res:
+ raise AssertionError(res["exceptionDetails"].get("exception", {}).get("description"))
+ return res["result"].get("value")
+
+ def text(self) -> str:
+ return self.eval("document.body.innerText")
+
+ def sidebar(self) -> str:
+ return self.eval("(document.querySelector('aside') || {}).innerText || ''")
+
+ def click(self, label: str, timeout: float = 30) -> None:
+ """Click a button or link by its label once it is there and enabled."""
+ time.sleep(0.3) # after a fill, let the page render what it was given
+ _wait(f"a control named {label!r}",
+ lambda: _try(lambda: self.eval(f"(clickText({json.dumps(label)}), 1)")), timeout)
+
+ def close(self):
+ self._conn.__exit__(None, None, None)
+
+
+def _http_get(url: str, token: str | None = None) -> str:
+ import urllib.request
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"} if token else {})
+ with urllib.request.urlopen(req, timeout=5) as r:
+ return r.read().decode()
+
+
+def _node_status() -> dict | None:
+ import urllib.request
+ try:
+ token = (NODE_HOME / "data" / "ui-token").read_text(encoding="utf-8").strip()
+ with urllib.request.urlopen(f"http://127.0.0.1:18000/api/status?t={token}",
+ timeout=3) as r:
+ return json.loads(r.read())
+ except (OSError, ValueError):
+ return None
+
+
+@pytest.fixture(scope="module")
+def hub(tmp_path_factory):
+ pytest.importorskip("meshbay_hub")
+ pytest.importorskip("websockets.sync.client")
+ work = tmp_path_factory.mktemp("hub")
+ port = _free_port()
+ env = {**os.environ,
+ "MESHBAY_DATABASE_URL": f"sqlite+aiosqlite:///{(work / 'hub.db').as_posix()}",
+ "MESHBAY_HUB_ID": "127.0.0.1", "MESHBAY_HUB_KEY": str(work / "hub_private.pem"),
+ "MESHBAY_HUB_HOST": "127.0.0.1", "MESHBAY_HUB_PORT": str(port)}
+ proc = subprocess.Popen([sys.executable, "-m", "uvicorn", "meshbay_hub.app:create_app",
+ "--factory", "--host", "127.0.0.1", "--port", str(port)],
+ env=env, cwd=work, stdout=(work / "hub.log").open("w"),
+ stderr=subprocess.STDOUT)
+ url = f"http://127.0.0.1:{port}"
+ try:
+ _wait("the local hub", lambda: _try(lambda: _http_get(f"{url}/v1/hub/version")), 30)
+ yield {"url": url, "db": work / "hub.db"}
+ finally:
+ proc.kill()
+
+
+def _try(fn):
+ try:
+ return fn()
+ except Exception:
+ return None
+
+
+@pytest.fixture(scope="module")
+def app(hub):
+ exe = INSTALL / "MeshBay.exe"
+ if not exe.exists():
+ pytest.skip(f"no installed MeshBay at {INSTALL}")
+ for there in (NODE_HOME / "node.toml", NODE_HOME / "keystore.enc", APP_PROFILE):
+ if there.exists():
+ pytest.fail(f"{there} exists: this test only runs on a machine with no node "
+ "and no app profile -- move them aside, it never deletes them")
+ if MODE == "service":
+ r = subprocess.run(["schtasks", "/query", "/tn", "MeshBay Node"], capture_output=True)
+ assert r.returncode == 0, "service mode chosen at install, but there is no boot task"
+ APP_PROFILE.mkdir(parents=True)
+ (APP_PROFILE / "config.json").write_text(json.dumps({"hubBase": hub["url"]}),
+ encoding="utf-8")
+ _launch([str(exe), f"--remote-debugging-port={CDP_PORT}"])
+ page = _wait("the application window", lambda: _try(Page), 60)
+ try:
+ yield page
+ finally:
+ # What the window showed last: the first thing to read when it failed.
+ last = _try(lambda: page.eval("location.href + ' ' + document.body.innerText"))
+ print(f"\n--- the application's page at the end ---\n{last}")
+ page.close()
+ subprocess.run(["taskkill", "/F", "/IM", "MeshBay.exe"], capture_output=True)
+ subprocess.run([str(INSTALL / "resources" / "node-runtime" / "meshbay-node.exe"),
+ "autostart", "stop"], capture_output=True, timeout=120)
+
+
+def test_a_fresh_account_gets_its_node_set_up_and_can_stop_it(app, hub):
+ page = app
+ username = "e2e" + secrets.token_hex(3)
+ passphrase = "e2e-" + secrets.token_urlsafe(18)
+
+ # Register.
+ _wait("the sign-in page", lambda: "Register" in page.text(), 30)
+ page.eval("(location.hash = '#/register', 1)")
+ _wait("the registration form", lambda: page.eval(
+ "document.querySelectorAll('main input').length >= 4"), 15)
+ page.eval(f"""(() => {{ const i = document.querySelectorAll('main input');
+ fill(i[0], {json.dumps(username)}); fill(i[1], {json.dumps(username + '@example.invalid')});
+ fill(i[2], {json.dumps(passphrase)}); fill(i[3], {json.dumps(passphrase)});
+ return 1; }})()""")
+ time.sleep(0.5) # the form's state follows the input events a render later
+ page.eval(f"({SUBMIT}, 1)")
+ # Not "recovery key" alone: the registration form says it too.
+ _wait("the recovery key", lambda: "I have saved my recovery key" in page.text(), 90)
+ page.eval("(document.querySelector('input[type=checkbox]').click(), 1)")
+ page.click("Continue")
+ _wait("the verification form", lambda: "Verify" in page.text(), 30)
+ code = sqlite3.connect(hub["db"]).execute(
+ "select code from email_verifications order by rowid desc limit 1").fetchone()[0]
+ page.eval(f"(fill(document.querySelector('main input'), {json.dumps(code)}), 1)")
+ page.click("Verify")
+ _wait("the verified page", lambda: "Go to login" in page.text(), 30)
+ page.click("Go to login")
+ _wait("the login form", lambda: page.eval(
+ "document.querySelectorAll('main input').length >= 2"), 15)
+ page.eval(f"""(() => {{ const i = document.querySelectorAll('main input');
+ fill(i[0], {json.dumps(username)}); fill(i[1], {json.dumps(passphrase)});
+ return 1; }})()""")
+ time.sleep(0.5) # the form's state follows the input events a render later
+ page.eval(f"({SUBMIT}, 1)")
+
+ # Nothing else done: the node is set up, running, linked, and in the sidebar.
+ _wait("the Node section in the sidebar", lambda: "Create group" in page.sidebar(), 120)
+ status = _wait("a running node", lambda: (s := _node_status()) and
+ s.get("status") == "running" and s, 120)
+ toml = (NODE_HOME / "node.toml").read_text(encoding="utf-8")
+ assert f'username = "{username}"' in toml and hub["url"] in toml
+ token = page.eval("JSON.parse(localStorage.getItem('mb_auth')).token")
+ keys = json.loads(_http_get(f"{hub['url']}/v1/users/{username}/pubkeys", token))
+ assert keys.get("pk_node_ed25519") == status["pk_node_ed25519"]
+
+ # Stop: no process left, and only then "Stopped".
+ page.eval("(location.hash = '#/node', 1)")
+ _wait("the Node page", lambda: "Restart" in page.text(), 30)
+ page.click("Stop")
+ _wait("no meshbay-node.exe", lambda: not _node_pids(), 60)
+ _wait("the page saying Stopped", lambda: "Stopped" in page.text(), 30)
+ assert _node_status() is None
+
+ # Start, then Restart: a running node each time, a new one for Restart.
+ page.click("Start")
+ first = _wait("a node after Start", lambda: (s := _node_status()) and
+ s.get("status") == "running" and s, 120)
+ _wait("the page saying Running", lambda: "Running" in page.text(), 30)
+ page.click("Restart")
+ second = _wait("a new node after Restart", lambda: (s := _node_status()) and
+ s.get("status") == "running" and s.get("pid") != first.get("pid") and s, 120)
+ assert len(_node_pids()) == 1, _node_pids()
+ assert second["pk_node_ed25519"] == first["pk_node_ed25519"]
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
index ad366d0..1fe6be8 100644
--- a/packages/meshbay-node/tests/test_packaging_win.py
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -824,7 +824,10 @@ def test_nothing_treats_find_node_binary_as_always_a_promise():
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
+ # Nothing answering is not enough: the control API closes before the
+ # process exits, and the page said "Stopped" about a node still running.
+ assert "if (await probeNode() || (await winNodePids()).length)" in stop
+ assert "if (!r.ok)" in stop and "throw new Error" in stop
def test_node_start_provisions_before_it_starts_anything():
diff --git a/packages/meshbay-node/tests/test_windows_node_lifecycle.py b/packages/meshbay-node/tests/test_windows_node_lifecycle.py
new file mode 100644
index 0000000..241f583
--- /dev/null
+++ b/packages/meshbay-node/tests/test_windows_node_lifecycle.py
@@ -0,0 +1,235 @@
+"""The node's life on a Windows desktop: set up at sign-in, stopped for real.
+
+Two defects from the first Windows beta tester, each found again live on a
+clean install (2026-10-07):
+
+- After a service-mode install the node was never set up for the account that
+ signed in, the Node section stayed out of the sidebar, and the way out was
+ `meshbay-node init` in a terminal plus the key pasted on the profile page.
+- A Stop left meshbay-node.exe running while the Node page said "Stopped": the
+ control API closes before the process exits, and the page asked nothing else.
+
+What can run for real here does (processes, the CLI's stop, the exit timer);
+the desktop application's side is pinned by reading main.js and app.js, the only
+evidence available without launching it -- tests/e2e_windows_app.py launches it.
+"""
+
+import inspect
+import os
+import subprocess
+import sys
+import textwrap
+import time
+from pathlib import Path
+
+import pytest
+from meshbay_node import platform as plat
+from meshbay_node.cli import lifecycle
+
+ROOT = Path(__file__).resolve().parents[3]
+MAIN_JS = ROOT / "packages" / "meshbay-client" / "src" / "main.js"
+STATIC = ROOT / "packages" / "meshbay-hub" / "src" / "meshbay_hub" / "static"
+
+
+def _js(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def _block(src: str, start: str, end: str = "\n });\n") -> str:
+ return src.split(start, 1)[1].split(end, 1)[0]
+
+
+# ── node.toml ────────────────────────────────────────────────────────────────
+
+def test_a_byte_order_mark_in_node_toml_is_read(tmp_path):
+ from meshbay_node.config import load_config
+ cfg_file = tmp_path / "node.toml"
+ cfg_file.write_bytes(b"\xef\xbb\xbf"
+ + b'[hub]\nurl = "http://127.0.0.1:1"\nusername = "someone"\n')
+ assert load_config(cfg_file).hub.username == "someone"
+
+
+def test_a_node_toml_it_cannot_read_is_said_not_swallowed(tmp_path):
+ """A service node has no console: the reason it will not start goes to
+ the log, where it used to die after "Logging to ..." in silence."""
+ cfg_file = tmp_path / "node.toml"
+ cfg_file.write_text("[hub\nusername = ", encoding="utf-8")
+ env = {**os.environ, "LOCALAPPDATA": str(tmp_path), "APPDATA": str(tmp_path),
+ "HOME": str(tmp_path), "USERPROFILE": str(tmp_path)}
+ r = subprocess.run([sys.executable, "-c", "from meshbay_node.daemon import main; main()",
+ "--config", str(cfg_file)],
+ capture_output=True, text=True, timeout=60, env=env)
+ assert r.returncode == 1, r.stdout + r.stderr
+ assert "cannot read" in r.stdout
+ if sys.platform == "win32":
+ log = tmp_path / "meshbay" / "state" / "node.log"
+ assert "cannot read" in log.read_text(encoding="utf-8")
+
+
+# ── the daemon leaves when it has stopped ────────────────────────────────────
+
+def test_a_stopped_node_does_not_wait_for_a_busy_thread():
+ """A worker still walking a large tree kept the process alive after
+ _shutdown(), with its control API already closed."""
+ script = textwrap.dedent("""
+ import threading, time
+ from meshbay_node.daemon import exit_after
+ threading.Thread(target=time.sleep, args=(120,), name="walker").start()
+ exit_after(0.5, "the node has stopped")
+ """)
+ t0 = time.monotonic()
+ r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True,
+ timeout=60)
+ assert r.returncode == 0, r.stderr
+ assert time.monotonic() - t0 < 30
+ assert "walker" in r.stderr, "it names what it did not wait for"
+
+
+def test_only_the_real_daemon_ends_its_own_process():
+ """Tests run daemons inside pytest: the timer must be armed by main()
+ alone, both before _shutdown() (a hung shutdown) and after it."""
+ from meshbay_node import daemon
+ assert daemon.NodeDaemon.exit_process_when_stopped is False
+ assert "daemon.exit_process_when_stopped = True" in inspect.getsource(daemon.main)
+ shutdown = inspect.getsource(daemon.NodeDaemon._shutdown)
+ assert shutdown.count("if self.exit_process_when_stopped:") == 2
+ assert shutdown.index("SHUTDOWN_DEADLINE_SECS") < shutdown.index('log.info("Node stopped")') \
+ < shutdown.index("EXIT_GRACE_SECS")
+
+
+# ── the CLI's stop: by pid, then checked ─────────────────────────────────────
+
+class _Stub:
+ def __init__(self, monkeypatch, *, answering=None, graceful=False, pids=(),
+ kill_works=True):
+ self.killed: list[int] = []
+ self.pids = list(pids)
+ monkeypatch.setattr(lifecycle, "_running_status", lambda cfg: answering)
+ monkeypatch.setattr(plat, "request_graceful_stop", lambda *a, **k: graceful)
+ monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""})
+ monkeypatch.setattr(plat, "_pid_alive", lambda pid: pid in self.pids)
+ monkeypatch.setattr(plat, "autostart_end", lambda: None)
+ monkeypatch.setattr(plat, "node_pids", lambda: list(self.pids))
+
+ def kill(pid):
+ self.killed.append(pid)
+ if kill_works:
+ self.pids.remove(pid)
+ monkeypatch.setattr(plat, "kill_pid", kill)
+ clock = iter(range(0, 10_000))
+ monkeypatch.setattr(time, "monotonic", lambda: next(clock))
+ monkeypatch.setattr(time, "sleep", lambda s: None)
+
+
+class _Cfg:
+ data_dir = Path(".")
+
+ class node:
+ ui_port = 1
+
+
+def test_a_stop_the_node_did_not_finish_kills_it_by_pid(monkeypatch):
+ stub = _Stub(monkeypatch, answering={"pid": 4242}, graceful=False, pids=[4242])
+ assert lifecycle._stop_node(_Cfg) == "stopped (forced)"
+ assert stub.killed == [4242]
+
+
+def test_a_node_process_left_behind_is_never_called_stopped(monkeypatch):
+ _Stub(monkeypatch, answering=None, graceful=False, pids=[5151], kill_works=False)
+ with pytest.raises(lifecycle.NodeStillRunning, match="5151"):
+ lifecycle._stop_node(_Cfg)
+
+
+def test_a_node_answering_nothing_is_still_a_node(monkeypatch):
+ """Its API closed, its process there: not "not running"."""
+ stub = _Stub(monkeypatch, answering=None, graceful=False, pids=[6161])
+ monkeypatch.setattr(plat, "autostart_end", lambda: stub.pids.clear())
+ assert lifecycle._stop_node(_Cfg) == "stopped (forced)"
+
+
+def test_a_stop_that_fails_exits_non_zero_with_the_reason(monkeypatch, capsys):
+ def refuse(cfg):
+ raise lifecycle.NodeStillRunning("meshbay-node is still running (pid 7)")
+ monkeypatch.setattr(lifecycle, "_stop_node", refuse)
+ monkeypatch.setattr(lifecycle, "load_config", lambda p: _Cfg)
+
+ class Args:
+ config = None
+ subcommand = "stop"
+ monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""})
+ monkeypatch.setattr(sys, "platform", "win32")
+ with pytest.raises(SystemExit) as e:
+ lifecycle.autostart(Args)
+ assert e.value.code == 1
+ assert "pid 7" in capsys.readouterr().out
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="lists and kills real processes")
+def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch):
+ """Under an image name of this test's own: the real one would find, and
+ kill, the developer's node."""
+ import shutil
+ exe = tmp_path / "mbpidtest.exe"
+ shutil.copy(r"C:\Windows\System32\PING.EXE", exe)
+ proc = subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ monkeypatch.setattr(plat, "NODE_IMAGE", "mbpidtest.exe")
+ try:
+ assert plat.node_pids() == [proc.pid]
+ plat.kill_pid(proc.pid)
+ assert proc.wait(timeout=10) is not None
+ assert plat.node_pids() == []
+ finally:
+ if proc.poll() is None:
+ proc.kill()
+
+
+# ── the desktop application: what it says, and when it sets the node up ──────
+
+def test_the_node_page_asks_the_node_not_only_the_task():
+ """A node started from a terminal runs while the service task reads
+ "Ready"; one on its way out answers nothing for a while. The page said
+ "Stopped" about both."""
+ main_js = _js(MAIN_JS)
+ status = main_js.split("async function nodeServiceStatus()", 1)[1].split("\n }\n", 1)[0]
+ assert "/running/i.test(svc.state)" not in status
+ assert status.count("winNodeActivity()") == 2
+ activity = main_js.split("async function winNodeActivity()", 1)[1].split("\n }\n", 1)[0]
+ assert "probeNode()" in activity and "winNodePids()" in activity
+ assert "'unknown'" in activity and "'inactive'" in activity
+ page = _js(STATIC / "node-page.js")
+ assert "const stoppable = info.activeState !== 'inactive';" in page
+ assert "disabled=${!!busy || !stoppable}" in page
+
+
+def test_signing_in_sets_up_this_machines_node():
+ main_js = _js(MAIN_JS)
+ assert "handle('node:ensure'" in main_js
+ ensure = main_js.split("async function ensureNode(opts)", 1)[1].split("\n }\n", 1)[0]
+ # Only the node this build ships, on Windows: Linux is unchanged.
+ assert "process.platform !== 'win32' || !hasBundledNode()" in ensure
+ # A node set up for someone else is left as it is...
+ assert ensure.index("return { state: 'other_account' }") < ensure.index("provisionNode(")
+ # ...and so is an account already linked to another node.
+ assert "if (linked && linked !== p.pk_node_ed25519) return { state: 'other_node' };" in ensure
+ assert ensure.index("state: 'other_node'") < ensure.index("linkNodeKeyAndAwaitRunning(")
+ # Started however this machine is set up, through the CLI like every start.
+ assert "winNodeStartVia(['autostart', 'start'])" in ensure
+ # One start at a time.
+ start = main_js.split("handle('node:start'", 1)[1][:400]
+ assert "if (nodeEnsuring) await nodeEnsuring" in start
+ preload = _js(ROOT / "packages" / "meshbay-client" / "src" / "preload.js")
+ assert "ensure: (opts) => ipcRenderer.invoke('node:ensure', opts)" in preload
+ assert "bridge.node.ensure(opts)" in _js(STATIC / "platform.js")
+
+
+def test_the_node_section_shows_for_a_node_not_yet_linked():
+ """It waited for the hub to hold a node key, which nothing gave it."""
+ app = _js(STATIC / "app.js")
+ assert "showNode=${hasNodeKey || localNode}" in app
+ assert "platform.capabilities.nodeAdmin && (hasNodeKey || localNode)" in app
+ effect = app.split("const signedInAs = user ? user.username : null;", 1)[1]
+ effect = effect.split("}, [signedInAs]);", 1)[0]
+ assert "platform.node.ensure(" in effect and "platform.node.installed()" in effect
+ # The token rotates; a renewal is not a sign-in.
+ assert "}, [signedInAs]);" in app