aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--packages/meshbay-client/package.json4
-rw-r--r--packages/meshbay-client/src/main.js92
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js28
-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-node/tests/test_packaging_win.py114
-rw-r--r--packaging/win/electron-builder.msix.yml9
-rw-r--r--packaging/win/ensure-node-path.ps160
16 files changed, 308 insertions, 9 deletions
diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json
index 6d8e711..28e0f3c 100644
--- a/packages/meshbay-client/package.json
+++ b/packages/meshbay-client/package.json
@@ -49,6 +49,10 @@
{
"from": "../../packaging/win/service-mode.ps1",
"to": "service-mode.ps1"
+ },
+ {
+ "from": "../../packaging/win/ensure-node-path.ps1",
+ "to": "ensure-node-path.ps1"
}
]
},
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();
}
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index ba24822..188b389 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -569,10 +569,38 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup
// ── First-run welcome (Electron-only, shown once on empty home) ─────────────
function SetupWelcome({ onDismiss }) {
+ // An install-time NSIS page used to be the only place this choice was
+ // ever offered, and an AppX/MSIX install has no install-time page at all
+ // (no custom actions, full stop, not just no elevation) -- so a build
+ // running its own bundled node needs to say so somewhere the user will
+ // actually see it, not just leave the choice sitting unfound on the Node
+ // page. Shown only while neither startup mode is configured yet; it
+ // disappears on its own once one is (or stays hidden forever if the
+ // platform has no node.service at all, e.g. Light, non-Windows, browser).
+ const [startupHint, setStartupHint] = useState(false);
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ if (!platform.node.available || !(await platform.node.bundled())) return;
+ if (!platform.node.service.available) return;
+ const status = await platform.node.service.status();
+ const configured = status.mode === 'service' || Boolean(status.autostart);
+ if (!cancelled && status.supported !== false && !configured) setStartupHint(true);
+ } catch { /* best effort -- the Node page itself is the source of truth */ }
+ })();
+ return () => { cancelled = true; };
+ }, []);
+
return html`<div class="page-content">
<h2>${t('setup.welcome_title')}</h2>
<p class="page-message" style="margin-bottom:24px">
${t('setup.welcome_message')}</p>
+ ${startupHint && html`
+ <p class="page-message" style="margin-bottom:24px">
+ ${t('setup.node_startup_hint')}
+ </p>
+ `}
<div style="display:flex;gap:8px;flex-wrap:wrap">
<a class="btn btn-primary" href="#/create-group">
${t('setup.create_group')}</a>
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 a41eeee..2d67bb2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -730,6 +730,7 @@ export default {
'setup.node_started': 'Node gestartet.',
'setup.node_not_installed': 'meshbay-node ist auf diesem Rechner nicht installiert.',
'setup.node_install_hint': 'Installieren Sie es mit Ihrem Paketmanager und kommen Sie hierher zurück.',
+ 'setup.node_startup_hint': 'Sobald Ihr Node läuft, besuchen Sie jederzeit „Node“ in der Seitenleiste, um es automatisch bei der Anmeldung zu starten oder als Hintergrunddienst auszuführen.',
'setup.skip': 'Überspringen — Gruppe ohne lokalen Node erstellen',
'setup.create_group': 'Erste Gruppe erstellen',
'setup.dismiss': 'Einrichtung überspringen',
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 4561663..88ee3da 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -593,6 +593,7 @@ export default {
'setup.node_started': 'Node started.',
'setup.node_not_installed': 'meshbay-node is not installed on this machine.',
'setup.node_install_hint': 'Install it with your package manager, then come back here.',
+ 'setup.node_startup_hint': 'Once your node is running, visit “Node” in the sidebar any time to have it start automatically at sign-in, or run as a background service.',
'setup.skip': 'Skip — create a group without a local node',
'setup.create_group': 'Create your first group',
'setup.dismiss': 'Skip setup',
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 bbfcfa5..8dabc10 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -725,6 +725,7 @@ export default {
'setup.node_started': 'Node iniciado.',
'setup.node_not_installed': 'meshbay-node no está instalado en esta máquina.',
'setup.node_install_hint': 'Instálelo con su gestor de paquetes y vuelva aquí.',
+ 'setup.node_startup_hint': 'Una vez que su node esté en funcionamiento, visite «Node» en la barra lateral en cualquier momento para que se inicie automáticamente al iniciar sesión, o se ejecute como un servicio en segundo plano.',
'setup.skip': 'Omitir — crear un grupo sin node local',
'setup.create_group': 'Crear su primer grupo',
'setup.dismiss': 'Omitir configuración',
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 b5560fa..3cc1348 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -728,6 +728,7 @@ export default {
'setup.node_started': 'Node démarré.',
'setup.node_not_installed': 'meshbay-node n\'est pas installé sur cette machine.',
'setup.node_install_hint': 'Installez-le avec votre gestionnaire de paquets, puis revenez ici.',
+ 'setup.node_startup_hint': 'Une fois votre node en fonctionnement, consultez « Node » dans la barre latérale à tout moment pour le faire démarrer automatiquement à la connexion, ou fonctionner comme un service en arrière-plan.',
'setup.skip': 'Passer — créer un groupe sans node local',
'setup.create_group': 'Créer votre premier groupe',
'setup.dismiss': 'Passer la configuration',
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 358e824..83fe150 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -727,6 +727,7 @@ export default {
'setup.node_started': 'Node avviato.',
'setup.node_not_installed': 'meshbay-node non è installato su questa macchina.',
'setup.node_install_hint': 'Lo installi con il suo gestore di pacchetti, poi torni qui.',
+ 'setup.node_startup_hint': 'Una volta che il node è in esecuzione, visiti «Node» nella barra laterale in qualsiasi momento per farlo avviare automaticamente all\'accesso, o eseguirlo come servizio in background.',
'setup.skip': 'Salta — crea un gruppo senza node locale',
'setup.create_group': 'Crea il suo primo gruppo',
'setup.dismiss': 'Salta la configurazione',
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 e28635a..75728f8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -715,6 +715,7 @@ export default {
'setup.node_started': 'Node が起動しました。',
'setup.node_not_installed': 'この端末に meshbay-node がインストールされていません。',
'setup.node_install_hint': 'パッケージマネージャーでインストールし、こちらにお戻りください。',
+ 'setup.node_startup_hint': 'ノードが起動したら、いつでもサイドバーの「Node」にアクセスして、サインイン時に自動的に起動するか、バックグラウンドサービスとして実行するように設定できます。',
'setup.skip': 'スキップ — ローカル node なしでグループを作成',
'setup.create_group': '最初のグループを作成',
'setup.dismiss': 'セットアップをスキップ',
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 4d35bdc..ae1c168 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -729,6 +729,7 @@ export default {
'setup.node_started': 'Node gestart.',
'setup.node_not_installed': 'meshbay-node is niet geïnstalleerd op deze machine.',
'setup.node_install_hint': 'Installeer het met uw pakketbeheerder en kom hier terug.',
+ 'setup.node_startup_hint': 'Zodra uw node actief is, bezoek dan op elk moment \'Node\' in de zijbalk om het automatisch te laten starten bij aanmelding, of als achtergrondservice te laten draaien.',
'setup.skip': 'Overslaan — groep aanmaken zonder lokale node',
'setup.create_group': 'Eerste groep aanmaken',
'setup.dismiss': 'Installatie overslaan',
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 85015b0..b3d1a1d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -747,6 +747,7 @@ export default {
'setup.node_started': 'Node uruchomiony.',
'setup.node_not_installed': 'meshbay-node nie jest zainstalowany na tej maszynie.',
'setup.node_install_hint': 'Zainstaluj go za pomocą menedżera pakietów, a potem wróć tutaj.',
+ 'setup.node_startup_hint': 'Gdy twój node już działa, odwiedź w dowolnym momencie „Node” na pasku bocznym, aby uruchamiał się automatycznie przy logowaniu lub działał jako usługa w tle.',
'setup.skip': 'Pomiń — utwórz grupę bez lokalnego node',
'setup.create_group': 'Utwórz pierwszą grupę',
'setup.dismiss': 'Pomiń konfigurację',
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 9b9e80c..e0ec3fe 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
@@ -726,6 +726,7 @@ export default {
'setup.node_started': 'Node iniciado.',
'setup.node_not_installed': 'meshbay-node não está instalado nesta máquina.',
'setup.node_install_hint': 'Instale-o com seu gerenciador de pacotes e volte aqui.',
+ 'setup.node_startup_hint': 'Assim que seu node estiver em execução, acesse "Node" na barra lateral a qualquer momento para fazê-lo iniciar automaticamente ao entrar, ou rodar como um serviço em segundo plano.',
'setup.skip': 'Pular — criar um grupo sem node local',
'setup.create_group': 'Criar seu primeiro grupo',
'setup.dismiss': 'Pular configuração',
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 99872f2..01b54c3 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
@@ -702,6 +702,7 @@ export default {
'setup.node_started': 'Node 已启动。',
'setup.node_not_installed': '此机器上未安装 meshbay-node。',
'setup.node_install_hint': '请使用包管理器安装,然后回到此处。',
+ 'setup.node_startup_hint': '节点运行后,随时可在侧边栏访问"Node",将其设置为登录时自动启动,或作为后台服务运行。',
'setup.skip': '跳过 — 不使用本地 node 创建群组',
'setup.create_group': '创建第一个群组',
'setup.dismiss': '跳过设置',
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py
index 2cc8f12..6f7573b 100644
--- a/packages/meshbay-node/tests/test_packaging_win.py
+++ b/packages/meshbay-node/tests/test_packaging_win.py
@@ -1102,3 +1102,117 @@ def test_build_win_msix_points_electron_builder_at_the_system_sdk():
# already pin that winCanElevateServiceMode() checks for service-mode.ps1's
# presence generically, which is exactly what makes it work for a third
# packaged target without being told about it.
+
+
+# ------------------------------------------------------------------------
+# Three gaps a real sideload install found (2026-09-12) that reading the
+# manifest and launching the app once had missed: no install-time hook means
+# no equivalent of installer.nsh's PATH write either, an immediate daemon
+# crash was silently discarded instead of surfacing to the user, and nobody
+# told a first-time MSIX user the startup-mode choice existed at all.
+# Confirmed live: process.resourcesPath/spawn work correctly under the
+# installed AppX path, PATH actually gained the entry on next launch, and a
+# real port-18000 collision now rejects in ~2s with the daemon's own stderr
+# instead of a 60s generic timeout.
+# ------------------------------------------------------------------------
+
+ENSURE_NODE_PATH_PS1 = WIN / "ensure-node-path.ps1"
+
+
+def test_ensure_node_path_script_is_idempotent_and_unelevated():
+ """
+ No admin verb, no elevation helper -- a per-user HKCU write never needed
+ elevation in the first place (installer.nsh's customInstall already did
+ this one unelevated); what MSIX lacks is an install-time hook to run
+ anything from, not the right to make this specific change.
+ """
+ assert ENSURE_NODE_PATH_PS1.exists(), f"{ENSURE_NODE_PATH_PS1} is missing"
+ src = ENSURE_NODE_PATH_PS1.read_text(encoding="utf-8")
+ assert "HKEY_CURRENT_USER\\Environment" in src
+ assert "already present" in src, "must be a no-op when the entry already exists"
+ assert "RunAs" not in src and "Verb" not in src
+ assert "WM_SETTINGCHANGE" in src or "SendMessageTimeout" in src, (
+ "must broadcast the change so already-open shells notice, same as "
+ "installer.nsh's own SendMessage")
+
+
+def test_ensure_node_path_shipped_to_full_and_msix_not_light():
+ pkg = _pkg()
+ full_yml = json.dumps(pkg["build"])
+ assert "ensure-node-path.ps1" in full_yml
+
+ msix_yml = MSIX_YML.read_text(encoding="utf-8")
+ assert "ensure-node-path.ps1" in msix_yml
+
+ light_yml = LIGHT_YML.read_text(encoding="utf-8")
+ assert "ensure-node-path.ps1" not in light_yml, (
+ "Light has no bundled node-runtime to add to PATH")
+
+
+def test_main_js_calls_ensure_node_path_on_every_launch():
+ src = MAIN_JS.read_text(encoding="utf-8")
+ assert "function winEnsureNodeOnPath()" in src
+ body = src.split("function winEnsureNodeOnPath()", 1)[1].split("\n }", 1)[0]
+ assert "hasBundledNode()" in body, "must not run at all for a Light install"
+ assert "ensure-node-path.ps1" in body
+ # Actually invoked, not just defined -- registerBridge() calls it once,
+ # unconditionally, on every launch (idempotent, so Full's already-set
+ # PATH is just a fast no-op query each time).
+ assert src.count("winEnsureNodeOnPath()") >= 2, (
+ "must be both defined and called")
+
+
+def test_node_start_surfaces_an_immediate_daemon_crash_instead_of_a_60s_timeout():
+ """
+ Reproduced live: a daemon that exits within ~1s (a port already bound,
+ reproduced with a second instance colliding on 127.0.0.1:18000) used to
+ be indistinguishable from one that simply never started -- spawn()'s
+ stdio was 'ignore', discarding the exact stderr line that named the real
+ problem, and waitForNode()'s 60s generic timeout was the only failure
+ path left. spawnNodeDetachedWatched watches for an early exit and
+ rejects with the daemon's own tail of stderr instead.
+ """
+ src = MAIN_JS.read_text(encoding="utf-8")
+ assert "function spawnNodeDetachedWatched(" in src
+ body = src.split("function spawnNodeDetachedWatched(", 1)[1].split("\n }", 1)[0]
+ assert "stdio: ['ignore', 'pipe', 'pipe']" in body
+ assert "exited immediately" in body
+ assert "NODE_CRASH_WATCH_MS" in body
+ # The tail must be bounded by length, not by a line count -- a real
+ # capture had the actual OSError line pushed out by two uvicorn/asyncio
+ # tracebacks that followed it, which a short "last N lines" cut before
+ # this was fixed to bound by characters instead.
+ assert "split(/\\r?\\n/).slice(" not in body, (
+ "a line-count tail can cut the one line that names the real error "
+ "-- bound by characters instead (reproduced live, see the comment "
+ "above this constant)")
+ assert "4000" in body
+
+ async_fn = src.split("async function spawnNodeDetached()", 1)[1].split("\n }", 1)[0]
+ assert "spawnNodeDetachedWatched" in async_fn, (
+ "spawnNodeDetached must actually use the watched spawn, not the old "
+ "fire-and-forget one")
+
+
+def test_setup_welcome_hints_at_the_node_startup_choice():
+ """
+ build/installer.nsh's radio page was the only place this choice was ever
+ offered, and an AppX/MSIX install has no install-time page at all to
+ replace it with -- a first-time user of a build with a bundled node
+ otherwise has no reason to ever find the Node page's startup-mode
+ control. Shown only while neither mode is configured yet (so it
+ disappears on its own once one is, or never appears for Light/non-
+ Windows/browser, where platform.node.service.available is false).
+ """
+ src = (HUB_STATIC / "app.js").read_text(encoding="utf-8")
+ fn = src.split("function SetupWelcome(", 1)[1].split("\nfunction ", 1)[0]
+ assert "platform.node.bundled()" in fn
+ assert "platform.node.service.status()" in fn
+ assert "mode === 'service'" in fn and "autostart" in fn
+ assert "setup.node_startup_hint" in fn
+
+
+def test_node_startup_hint_key_exists_in_all_ten_locales():
+ for name in ("en", "fr", "es", "pt-BR", "zh-CN", "ja", "de", "it", "nl", "pl"):
+ cat = (HUB_STATIC / "locales" / f"{name}.js").read_text(encoding="utf-8")
+ assert "'setup.node_startup_hint':" in cat, f"{name}.js is missing the key"
diff --git a/packaging/win/electron-builder.msix.yml b/packaging/win/electron-builder.msix.yml
index 4026d46..5e45598 100644
--- a/packaging/win/electron-builder.msix.yml
+++ b/packaging/win/electron-builder.msix.yml
@@ -61,6 +61,15 @@ win:
to: service.ps1
- from: ../../packaging/win/service-mode.ps1
to: service-mode.ps1
+ # Full's own installer adds node-runtime\ to the per-user PATH at install
+ # time (build/installer.nsh's customInstall) -- an unelevated HKCU write,
+ # never blocked by the no-elevation rule this target is built around, but
+ # MSIX has no install-time hook at all to run it from. main.js's
+ # winEnsureNodeOnPath() calls this itself on first launch instead
+ # (found missing by actually sideloading a build and checking, not
+ # anticipated in the original plan).
+ - from: ../../packaging/win/ensure-node-path.ps1
+ to: ensure-node-path.ps1
appx:
# --- Real values, from Partner Center's "App identity" page (App
diff --git a/packaging/win/ensure-node-path.ps1 b/packaging/win/ensure-node-path.ps1
new file mode 100644
index 0000000..f9b6f68
--- /dev/null
+++ b/packaging/win/ensure-node-path.ps1
@@ -0,0 +1,60 @@
+<#
+.SYNOPSIS
+ Add the bundled node-runtime directory to the per-user PATH, if it is not
+ there already.
+
+.DESCRIPTION
+ build/installer.nsh's customInstall does this at install time for the NSIS
+ Full target (HKCU\Environment, no elevation -- a per-user registry write
+ needs none). An AppX/MSIX install has no install-time hook at all, elevated
+ or not -- not a signing/elevation gap like the firewall rule or
+ service-mode, but the more basic fact that MSIX runs no custom code
+ whatsoever during setup. So the MSIX target calls this itself, once, on
+ first launch (main.js's winEnsureNodeOnPath()) instead.
+
+ Idempotent and safe to run on every launch of any packaged Windows build,
+ NSIS Full included: if the installer already added the entry, this is a
+ fast no-op query. Never touches PATH for a Light install (no bundled
+ node-runtime to add) -- the caller only invokes this when
+ hasBundledNode() is true.
+
+ Broadcasts WM_SETTINGCHANGE afterward so already-open shells notice --
+ same as the installer's own SendMessage call. New shells pick it up
+ either way.
+
+.PARAMETER NodeDir
+ Full path to the node-runtime directory to add.
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)][string]$NodeDir
+)
+
+$ErrorActionPreference = "Stop"
+
+$key = "Registry::HKEY_CURRENT_USER\Environment"
+$current = (Get-ItemProperty -Path $key -Name "Path" -ErrorAction SilentlyContinue).Path
+if ($null -eq $current) { $current = "" }
+
+$parts = $current -split ";" | Where-Object { $_ -ne "" }
+$already = $parts | Where-Object { $_.TrimEnd('\') -ieq $NodeDir.TrimEnd('\') }
+if ($already) {
+ Write-Output "already present"
+ exit 0
+}
+
+$next = if ($parts.Count -gt 0) { ($parts + $NodeDir) -join ";" } else { $NodeDir }
+# ExpandString, not String -- matches installer.nsh's WriteRegExpandStr, so any
+# %VAR% another entry already carries on this machine keeps expanding.
+New-ItemProperty -Path $key -Name "Path" -Value $next -PropertyType ExpandString -Force | Out-Null
+
+Add-Type -Namespace MeshBay -Name NativeMethods -MemberDefinition @"
+ [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)]
+ public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);
+"@
+$HWND_BROADCAST = [IntPtr]0xffff
+$WM_SETTINGCHANGE = 0x1a
+$result = [UIntPtr]::Zero
+[MeshBay.NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", 2, 5000, [ref]$result) | Out-Null
+
+Write-Output "added"