diff options
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" |