diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-05 09:28:03 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-05 09:28:03 +0200 |
| commit | 7601991ccb1d75637c055062c38b1852eeef9700 (patch) | |
| tree | 022d6ce76e632d005417c82e92e0277614a92689 /packages | |
| parent | 301c8998bfdcac80ad3302e2d2ebe853e2ea6de1 (diff) | |
| download | meshbay-7601991ccb1d75637c055062c38b1852eeef9700.tar.gz | |
feat(client): a Node-page toggle to switch into/out of service mode
The installer's own mode question is effectively one-shot: customInstall
skips it entirely once the firewall rules already exist, for any reason --
and per-user mode sets those up on its own, with no Scheduled Task involved.
So declining once (or the rules existing from something unrelated, as
happened on a dev machine this session) was a dead end: no reinstall, repair,
or uninstall/reinstall cycle could ever bring the question back, since
uninstall defaults to leaving both alone.
Add the other door in (and out): a checkbox on the Node page, next to the
existing per-user autostart toggle, wired main.js -> preload.js -> platform.js
-> node-page.js. It runs packaging/win/service-mode.ps1 -- the exact script
installer.nsh already runs -- via one Start-Process -Verb RunAs elevation, so
the two paths can never disagree about what service mode means. The elevation
helper writes a tiny param()-based .ps1 to %TEMP% so the target script path
and its arguments bind through real PowerShell parameters instead of nested
string-quoting.
Also fixes a real pre-existing gap found while checking this: 8 of the 10
locale catalogues (all but en/fr) were missing the autostart/service-mode
keys added in an earlier commit this session (b782886) -- test_locales.py's
key-set-parity check uses a for-loop with an inline assert, so it stopped at
the first mismatch (fr) and never actually reached the other eight. Backfilled
all five keys (three pre-existing, two new) in de/es/it/ja/nl/pl/pt-BR/zh-CN.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
15 files changed, 200 insertions, 0 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 15c3cc0..dd7f38e 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -882,6 +882,54 @@ function registerBridge() { }); } + // ── Windows: switching INTO or OUT OF service mode after install ─────────── + // build/installer.nsh's mode question is effectively one-shot: it skips + // itself the moment the firewall rules already exist, and per-user mode + // sets those up on its own, with no Scheduled Task involved. So declining + // once (or the rules existing for any other reason) is a dead end through + // the installer alone — this is the other door in, driven from the Node + // page instead of setup. It runs the exact same packaging/win/service-mode.ps1 + // the installer does (task + firewall, one elevation), so the two paths + // can never disagree about what "service mode" means. + const MB_PWSH = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + + function winElevateServiceMode(action) { + return new Promise((resolve, reject) => { + const script = path.join(process.resourcesPath, 'service-mode.ps1'); + if (!fs.existsSync(script)) { + reject(new Error('service-mode.ps1 not found — only available in an installed build')); + return; + } + // Start-Process -Verb RunAs is the one UAC prompt; -Wait -PassThru hands + // its exit code back to this unelevated process, so a decline ("The + // operation was canceled by the user") surfaces as a rejection here + // instead of silently doing nothing. Written to a temp .ps1 and run via + // -File (not -Command) so the target path and its own arguments bind + // through real PowerShell parameters instead of nested string quoting. + const elevator = path.join(os.tmpdir(), 'meshbay-elevate-service-mode.ps1'); + const elevatorSrc = [ + 'param([string]$Target, [string]$TargetArgs)', + '$ErrorActionPreference = "Stop"', + '$p = Start-Process -FilePath $Target -ArgumentList $TargetArgs -Verb RunAs -Wait -PassThru', + 'exit $p.ExitCode', + '', + ].join('\r\n'); + fs.writeFileSync(elevator, elevatorSrc); + const targetArgs = + `-NoProfile -ExecutionPolicy Bypass -File "${script}" -Action ${action}`; + execFile(MB_PWSH, + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', elevator, + '-Target', MB_PWSH, '-TargetArgs', targetArgs], + (err) => { + if (err) { + reject(new Error('Elevation was declined, or the operation failed.')); + return; + } + resolve(); + }); + }); + } + async function spawnNodeDetached() { const bin = await findNodeBinary(); if (!bin) throw new Error('meshbay-node not found on PATH'); @@ -1050,6 +1098,19 @@ function registerBridge() { return { supported: true, installed: winAutostartInstalled() }; }); + // Turn service mode on or off after install — one elevation, task + firewall + // together, via the same service-mode.ps1 the installer runs. See + // winElevateServiceMode() above for why this is needed at all. + ipcMain.handle('node:service-mode', async (_e, action) => { + if (process.platform !== 'win32') return { supported: false }; + if (action !== 'install' && action !== 'remove') { + throw new Error(`unknown service-mode action: ${action}`); + } + await winElevateServiceMode(action); + const svc = await winServiceTaskStatus(); + return { supported: true, installed: svc.installed }; + }); + async function probeNode() { const nc = readNodeConfig(); const dataDir = nc ? nc.dataDir : meshbayDataDir(); diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 38c1a57..97a5063 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -110,6 +110,10 @@ contextBridge.exposeInMainWorld('meshbay', { // action: 'install' | 'remove' | 'status' (default). Elsewhere returns // { supported: false }. autostart: (action) => ipcRenderer.invoke('node:autostart', action), + // Windows only: switch into/out of the boot-time service (Scheduled Task + // + firewall, one elevation). action: 'install' | 'remove'. Elsewhere + // returns { supported: false }. + serviceMode: (action) => ipcRenderer.invoke('node:service-mode', action), }, // LAN cast relay. The main process runs a local HTTP server and the 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 b66c847..bb81fee 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -678,6 +678,11 @@ export default { 'node.service_stopping': 'Wird angehalten…', 'node.service_restart': 'Neu starten', 'node.service_restarting': 'Wird neu gestartet…', + 'node.autostart_label': 'Automatisch bei der Anmeldung starten', + 'node.autostart_updating': 'Wird aktualisiert…', + 'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.', + 'node.service_mode_label': 'Als Hintergrunddienst ausführen (startet beim Booten, vor der Anmeldung)', + 'node.service_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…', 'node.not_operator': 'Ihr Node konnte nicht erreicht werden. Stellen Sie sicher, dass er läuft.', 'node.offline': 'Node ist offline', 'node.retry': 'Erneut versuchen', 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 196b9cc..2817432 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -737,6 +737,8 @@ export default { 'node.autostart_label': 'Start automatically at sign-in', 'node.autostart_updating': 'Updating…', 'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.', + 'node.service_mode_label': 'Run as a background service (starts at boot, before sign-in)', + 'node.service_mode_updating': 'Switching mode — check for an administrator prompt…', 'node.offline': 'Node is offline', 'node.no_groups': 'No groups configured on this node.', 'node.retry': 'Retry', 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 8d06a2d..0f3ce21 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -674,6 +674,11 @@ export default { 'node.service_stopping': 'Deteniendo…', 'node.service_restart': 'Reiniciar', 'node.service_restarting': 'Reiniciando…', + 'node.autostart_label': 'Iniciar automáticamente al iniciar sesión', + 'node.autostart_updating': 'Actualizando…', + 'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.', + 'node.service_mode_label': 'Ejecutar como servicio en segundo plano (se inicia al arrancar, antes de iniciar sesión)', + 'node.service_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…', 'node.not_operator': 'No se pudo contactar con su node. Asegúrese de que esté en ejecución.', 'node.offline': 'Node sin conexión', 'node.retry': 'Reintentar', 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 fe38500..c3c4846 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -680,6 +680,8 @@ export default { 'node.autostart_label': 'Démarrer automatiquement à l\'ouverture de session', 'node.autostart_updating': 'Mise à jour…', 'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.', + 'node.service_mode_label': 'Exécuter comme service en arrière-plan (démarre au boot, avant l\'ouverture de session)', + 'node.service_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…', 'node.not_operator': 'Impossible de joindre votre node. Vérifiez qu\'il est en cours d\'exécution.', 'node.offline': 'Node hors ligne', 'node.retry': 'Réessayer', 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 52e18a8..f39ab3c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -676,6 +676,11 @@ export default { 'node.service_stopping': 'Arresto…', 'node.service_restart': 'Riavvia', 'node.service_restarting': 'Riavvio…', + 'node.autostart_label': 'Avvia automaticamente all\'accesso', + 'node.autostart_updating': 'Aggiornamento…', + 'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.', + 'node.service_mode_label': 'Esegui come servizio in background (si avvia all\'avvio del sistema, prima dell\'accesso)', + 'node.service_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…', 'node.not_operator': 'Impossibile raggiungere il suo node. Si assicuri che sia in esecuzione.', 'node.offline': 'Node non in linea', 'node.retry': 'Riprova', 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 c0560b3..1544940 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -664,6 +664,11 @@ export default { 'node.service_stopping': '停止中…', 'node.service_restart': '再起動', 'node.service_restarting': '再起動中…', + 'node.autostart_label': 'サインイン時に自動的に開始する', + 'node.autostart_updating': '更新中…', + 'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。', + 'node.service_mode_label': 'バックグラウンドサービスとして実行する(サインインより前、起動時に開始)', + 'node.service_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…', 'node.not_operator': 'node に接続できませんでした。node が実行中であることをご確認ください。', 'node.offline': 'Node はオフラインです', 'node.retry': '再試行', 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 da6dc04..19feec2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -678,6 +678,11 @@ export default { 'node.service_stopping': 'Stoppen…', 'node.service_restart': 'Herstarten', 'node.service_restarting': 'Herstarten…', + 'node.autostart_label': 'Automatisch starten bij aanmelden', + 'node.autostart_updating': 'Bijwerken…', + 'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.', + 'node.service_mode_label': 'Uitvoeren als achtergrondservice (start bij het opstarten, vóór het aanmelden)', + 'node.service_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…', 'node.not_operator': 'Uw node is niet bereikbaar. Controleer of hij draait.', 'node.offline': 'Node is offline', 'node.retry': 'Opnieuw proberen', 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 29feebe..512c4f3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -696,6 +696,11 @@ export default { 'node.service_stopping': 'Zatrzymywanie…', 'node.service_restart': 'Uruchom ponownie', 'node.service_restarting': 'Ponowne uruchamianie…', + 'node.autostart_label': 'Uruchamiaj automatycznie przy logowaniu', + 'node.autostart_updating': 'Aktualizowanie…', + 'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.', + 'node.service_mode_label': 'Uruchom jako usługę w tle (uruchamia się przy starcie systemu, przed zalogowaniem)', + 'node.service_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…', 'node.not_operator': 'Nie udało się połączyć z Pana/Pani node. Upewnij się, że działa.', 'node.offline': 'Node jest niedostępny', 'node.retry': 'Ponów', 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 d2fc355..8e33d74 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 @@ -675,6 +675,11 @@ export default { 'node.service_stopping': 'Parando…', 'node.service_restart': 'Reiniciar', 'node.service_restarting': 'Reiniciando…', + 'node.autostart_label': 'Iniciar automaticamente ao entrar na sessão', + 'node.autostart_updating': 'Atualizando…', + 'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.', + 'node.service_mode_label': 'Executar como serviço em segundo plano (inicia na inicialização, antes do login)', + 'node.service_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…', 'node.not_operator': 'Não foi possível alcançar seu node. Verifique se ele está em execução.', 'node.offline': 'Node está off-line', 'node.retry': 'Tentar novamente', 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 6840774..299dc4f 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 @@ -651,6 +651,11 @@ export default { 'node.service_stopping': '正在停止…', 'node.service_restart': '重启', 'node.service_restarting': '正在重启…', + 'node.autostart_label': '登录时自动启动', + 'node.autostart_updating': '正在更新…', + 'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。', + 'node.service_mode_label': '以后台服务方式运行(在开机时启动,早于登录)', + 'node.service_mode_updating': '正在切换模式 — 请留意管理员权限提示…', 'node.not_operator': '无法连接到您的 node。请确保它正在运行。', 'node.offline': 'Node 已离线', 'node.retry': '重试', 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 d242b75..2a0b0d7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -53,6 +53,17 @@ function NodeServicePanel({ onChanged }) { : platform.node.autostart.install())); }, [act, info]); + // Switching mode itself — the installer's own choice is effectively one-shot + // (it skips the question once the firewall rules exist for any reason, and + // per-user mode sets those up on its own with no Scheduled Task), so this is + // the only way back in if service mode was declined, or out if it is no + // longer wanted. One elevation, task + firewall together, same script. + const toggleServiceMode = useCallback(() => { + act('serviceMode', () => (info && info.mode === 'service' + ? platform.node.serviceMode.remove() + : platform.node.serviceMode.install())); + }, [act, info]); + if (!platform.node.service.available) return null; if (!info || info.supported === false) { return html`<div class="node-service"> @@ -99,6 +110,14 @@ function NodeServicePanel({ onChanged }) { ${' '}${busy === 'autostart' ? t('node.autostart_updating') : t('node.autostart_label')} </label> `} + ${platform.node.serviceMode.available && typeof info.mode === 'string' && html` + <label class="toggle-switch ${busy ? 'toggle-switch-disabled' : ''}"> + <input type="checkbox" checked=${info.mode === 'service'} disabled=${!!busy} + onChange=${toggleServiceMode} /> + <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> + ${' '}${busy === 'serviceMode' ? t('node.service_mode_updating') : t('node.service_mode_label')} + </label> + `} </div>`; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index b1025c5..8f5d484 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -282,6 +282,27 @@ export const node = { return bridge.node.autostart('remove'); }, }, + /** + * Windows only: switch INTO or OUT OF service mode after install — the + * installer's own choice is effectively one-shot (build/installer.nsh skips + * it once the firewall rules exist for any reason, and per-user mode sets + * those up on its own with no Scheduled Task), so this is the only way back + * in if it was declined, or out if it was chosen and no longer wanted. One + * elevation, task + firewall together — same script the installer runs. + */ + serviceMode: { + available: Boolean(bridge && bridge.node && bridge.node.serviceMode), + async install() { + if (!bridge || !bridge.node || !bridge.node.serviceMode) + throw new Error('Node bridge not available'); + return bridge.node.serviceMode('install'); + }, + async remove() { + if (!bridge || !bridge.node || !bridge.node.serviceMode) + throw new Error('Node bridge not available'); + return bridge.node.serviceMode('remove'); + }, + }, }; /** diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index 31816d2..21aaf29 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -316,6 +316,57 @@ def test_service_status_reports_state_without_admin(): "calls status/run/end directly, unelevated") +HUB_STATIC = ROOT / "packages" / "meshbay-hub" / "src" / "meshbay_hub" / "static" + + +def test_service_mode_toggle_elevates_the_same_script_the_installer_runs(): + """ + The installer's own mode question is effectively one-shot (it skips + itself the moment the firewall rules exist for any reason, and per-user + mode sets those up on its own with no Scheduled Task involved) -- so + declining once, or the rules existing for any other reason, is a dead + end through setup alone. The Node page's toggle is the other door in + (and out), and it must drive service-mode.ps1 -- the exact script + installer.nsh runs -- so the two paths can never disagree about what + "service mode" means. One elevation (-Verb RunAs), no stored password. + """ + main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") + assert "winElevateServiceMode" in main_js + fn = main_js.split("function winElevateServiceMode", 1)[1] + fn = fn[:fn.index("\n }\n")] + assert "service-mode.ps1" in fn + assert "-Verb RunAs" in fn or "'-Verb', 'RunAs'" in fn or "-Verb', 'RunAs'" in fn + assert "Get-Credential" not in fn + + handler = main_js.split("ipcMain.handle('node:service-mode'", 1)[1] + handler = handler[:handler.index("ipcMain.handle(")] + assert "winElevateServiceMode" in handler + assert "'install'" in handler or '"install"' in handler + assert "'remove'" in handler or '"remove"' in handler + + +def test_node_page_service_mode_toggle_is_wired_end_to_end(): + """preload.js -> platform.js -> node-page.js, the same three-layer shape + the existing autostart toggle uses. A break anywhere in this chain means + the checkbox renders but does nothing, or never renders at all.""" + preload = (CLIENT / "src" / "preload.js").read_text(encoding="utf-8") + assert "serviceMode:" in preload + assert "'node:service-mode'" in preload + + platform_js = (HUB_STATIC / "platform.js").read_text(encoding="utf-8") + assert "serviceMode:" in platform_js + assert "bridge.node.serviceMode('install')" in platform_js + assert "bridge.node.serviceMode('remove')" in platform_js + + node_page = (HUB_STATIC / "node-page.js").read_text(encoding="utf-8") + assert "platform.node.serviceMode.available" in node_page + assert "platform.node.serviceMode.install()" in node_page + assert "platform.node.serviceMode.remove()" in node_page + # Checked state must reflect the CURRENT mode, not a separate flag -- + # otherwise the toggle and the status panel above it could disagree. + assert "info.mode === 'service'" in node_page + + def test_the_help_smoke_test_joins_multiline_output_before_matching(): """ `& exe --help 2>&1` is an ARRAY once the output wraps past one line, which |