aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-12 15:48:03 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-12 15:48:22 +0200
commit9cc2909cb4a360c81b471ceab1d9578a7655a88e (patch)
treeda6da7ff43eed10382e72247ef7c84b142a42ca5 /packages/meshbay-client/src
parentd8885c8df17c60927cb8d1f77ce1745814c6d3b4 (diff)
downloadmeshbay-9cc2909cb4a360c81b471ceab1d9578a7655a88e.tar.gz
fix(packaging): three MSIX first-run regressions found by a real sideload
A second-machine sideload of the MSIX target surfaced three things the earlier verification round (which only proved the package installs and runs) had missed: 1. meshbay-node missing from PATH. installer.nsh's customInstall adds node-runtime\ to HKCU\Environment at install time -- an unelevated per-user write, never blocked by MSIX's no-elevation rule, only by the more basic fact that an AppX/MSIX install runs no custom code at all. packaging/win/ensure-node-path.ps1 (idempotent, no admin verb) plus main.js's winEnsureNodeOnPath() do it from the app itself instead, once per launch, shipped to Full and MSIX (not Light, nothing to add there). Verified live via the Node inspector protocol: the entry was in HKCU\Environment\Path after a launch, absent before. 2. A daemon that crashes on startup failed silently. spawnNodeDetached() used stdio: 'ignore', so a real crash reproduced live (a second instance colliding with the first on 127.0.0.1:18000) left waitForNode()'s generic 60s timeout as the only failure ever shown. spawnNodeDetachedWatched() pipes stdio and watches ~2.5s, rejecting immediately with the daemon's own stderr on an early exit; a survivor has its streams released and runs fully detached exactly as before. First version bounded the captured text by line count and a live test showed that cut the actual OSError line -- two uvicorn/asyncio tracebacks followed it in the real capture -- so it is bounded by characters instead. 3. No hint that a startup-mode choice exists. The install-time radio page was the only place this was ever offered, and nothing replaces it now that no install-time page can exist at all. SetupWelcome (the existing first-run banner) grew a conditional hint, shown only while a bundled node is present and neither autostart nor service mode is configured yet. Considered and rejected: linking straight to the Node page -- its route is gated on a linked hub node key, false on the exact fresh-install screen this hint targets, so the link would have been dead on arrival. New key setup.node_startup_hint, added to all ten locale catalogues. test_packaging_win.py gained six tests pinning all three (69 total). Full plan and verification detail: C:\Users\admin\devel\msix-installer.md section 13 (out of repo). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src')
-rw-r--r--packages/meshbay-client/src/main.js92
1 files changed, 83 insertions, 9 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index d7e3e7b..bebe144 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -1000,6 +1000,29 @@ function registerBridge() {
return true;
}
+ // build/installer.nsh's customInstall adds node-runtime\ to the per-user
+ // PATH at install time (HKCU\Environment, no elevation needed for that --
+ // it never was the elevation that blocked it here). An AppX/MSIX install
+ // has no install-time hook at all, so `meshbay-node` in a terminal simply
+ // never got added for that target -- a real regression found by actually
+ // running a sideloaded build, not a theoretical gap. Idempotent (the script
+ // itself checks first) and harmless to call on every launch, NSIS Full
+ // included, where it is normally already a no-op. Fire-and-forget: a
+ // terminal convenience is not worth blocking startup or surfacing an error
+ // dialog over.
+ function winEnsureNodeOnPath() {
+ if (process.platform !== 'win32' || !hasBundledNode()) return;
+ const script = path.join(process.resourcesPath, 'ensure-node-path.ps1');
+ if (!fs.existsSync(script)) return; // dev run, or an older build without it
+ const nodeDir = path.join(process.resourcesPath, 'node-runtime');
+ execFile(MB_PWSH,
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-NodeDir', nodeDir],
+ (err, stdout) => {
+ if (err) { console.error('[path] ensure-node-path.ps1 failed:', err.message); return; }
+ console.log('[path] node-runtime on PATH:', (stdout || '').trim());
+ });
+ }
+
function findNodeBinary() {
if (process.platform === 'win32') {
// A packaged Windows build carries the frozen daemon as an
@@ -1166,18 +1189,67 @@ function registerBridge() {
});
}
+ // 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');
- const child = spawn(bin, [], { detached: true, stdio: 'ignore', windowsHide: true });
- // 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. The caller's waitForNode() timeout already
- // turns "never came up" into a clean message; this only has to keep that
- // path reachable instead of crashing first.
- child.on('error', (err) => console.error('[node] failed to start:', err.message));
- child.unref();
+ await spawnNodeDetachedWatched(bin);
}
async function waitForNode(deadline) {
@@ -1724,6 +1796,8 @@ function registerBridge() {
await castChromecast.disconnect();
return true;
});
+
+ winEnsureNodeOnPath();
}
/**