diff options
| -rw-r--r-- | packages/meshbay-client/build/installer.nsh | 174 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 122 |
2 files changed, 215 insertions, 81 deletions
diff --git a/packages/meshbay-client/build/installer.nsh b/packages/meshbay-client/build/installer.nsh index 70858c6..3157f22 100644 --- a/packages/meshbay-client/build/installer.nsh +++ b/packages/meshbay-client/build/installer.nsh @@ -1,21 +1,31 @@ ; electron-builder NSIS customisation (auto-included: build/installer.nsh). ; -; Per-user install, no elevation (package.json build.nsis) -- that part never -; changes. What this adds, all conditional on interactive setup (never -; ${Silent}): +; Per-user install, no elevation at install time (package.json build.nsis) -- +; that part never changes. What this adds, all conditional on interactive setup +; (never ${Silent}): +; - the per-machine ("anyone who uses this computer") option removed from +; electron-builder's install-mode page: MeshBay is per-user only -- the +; keystore and the DPAPI-protected secrets are bound to the signed-in +; account (MESHBAY_DESIGN.md §11.2 / §7.5) -- so that option was only ever +; shown disabled here. customInstallMode forces current-user and the page +; is skipped entirely; ; - the bundled daemon dir on the user's PATH, so `meshbay-node` works in a ; terminal; -; - a choice of autostart: the normal per-user Startup-folder launcher (no -; admin, starts at sign-in -- see meshbay_node.platform._startup_vbs), -; or a background-service mode (one admin confirmation, starts at boot, -; no sign-in required -- see meshbay_node.platform.service_install and -; packaging/win/service.ps1); -; - the inbound firewall rules, folded into that SAME elevation when service -; mode is chosen, or offered on their own otherwise -- never two UAC -; prompts for one install; +; - a custom page (radio buttons, like the rest of setup) choosing when the +; node runs: only while the MeshBay app is open, at each sign-in (a per-user +; Startup-folder launcher -- no admin -- see +; meshbay_node.platform.autostart_install), or as a background service +; (a boot-time S4U scheduled task -- one admin confirmation -- see +; meshbay_node.platform.service_install and packaging/win/service.ps1). +; Default: background service; +; - the inbound firewall rules, set up in EVERY mode (a node that silently +; accepts no connections is the failure mode called out in +; MESHBAY_DESIGN.md §7.5). Folded into the SAME elevation as the boot +; task when service mode is chosen; their own single elevation otherwise. +; One UAC prompt for an install, never two, never zero; ; - cleanup of whichever of those is outside $INSTDIR on the way out (the ; Startup .vbs; the scheduled task and firewall rules, together, if the -; user opts in). +; user opts in at uninstall time). ; ; Deliberately NOT touched: ; - %LOCALAPPDATA%\meshbay\ (node.toml, keystore.enc, unlock.key, data/) -- @@ -27,6 +37,7 @@ !include "WinMessages.nsh" !include "WordFunc.nsh" !include "LogicLib.nsh" +!include "nsDialogs.nsh" !insertmacro WordAdd !insertmacro un.WordAdd @@ -38,6 +49,79 @@ ; the exact same string. !define MB_NODE_BIN "$INSTDIR\resources\node-runtime" +; ── force per-user, skip the all-users / current-user page ────────────────── +!macro customInstallMode + StrCpy $isForceCurrentInstall "1" +!macroend + +!macro customInit + StrCpy $MB_AutoMode "2" +!macroend + +; ── the autostart choice, as a radio page ───────────────────────────────── +; MB_AutoMode: "0" only-while-open, "1" at sign-in, "2" background service. +; Declared here (not at file scope) so the uninstaller pass -- which inserts +; none of the macros that touch it -- does not warn about an unused Var, which +; electron-builder's makensis promotes to a hard error. customInit defaults it +; for silent installs, where this page never runs. +!macro customPageAfterChangeDir + Var MB_AutoMode + Var MB_Dlg + Var MB_RbOpen + Var MB_RbSignin + Var MB_RbService + + Page custom mbAutostartPageCreate mbAutostartPageLeave + + Function mbAutostartPageCreate + !insertmacro MUI_HEADER_TEXT "MeshBay Node" "Choose when the node runs on this computer." + + nsDialogs::Create 1018 + Pop $MB_Dlg + ${If} $MB_Dlg == error + Abort + ${EndIf} + + ${NSD_CreateLabel} 0 0 100% 26u "The node makes your groups' content available to other members. It can run only while the MeshBay window is open, start on its own when you sign in, or run as a background service that is up even before you sign in." + Pop $0 + + ${NSD_CreateRadioButton} 6u 34u 96% 12u "Only while MeshBay is open" + Pop $MB_RbOpen + ${NSD_CreateRadioButton} 6u 48u 96% 12u "Automatically when I sign in to Windows" + Pop $MB_RbSignin + ${NSD_CreateRadioButton} 6u 62u 96% 12u "As a background service (starts at boot, before sign-in) -- recommended" + Pop $MB_RbService + + ${NSD_CreateLabel} 0 82u 100% 34u "Setup adds Windows Firewall rules for local-network connections in every case. The background-service option, and those firewall rules, together need one administrator confirmation now -- without it the node cannot be reached and the app is not operational." + Pop $0 + + ${If} $MB_AutoMode == "0" + ${NSD_Check} $MB_RbOpen + ${ElseIf} $MB_AutoMode == "1" + ${NSD_Check} $MB_RbSignin + ${Else} + ${NSD_Check} $MB_RbService + ${EndIf} + + nsDialogs::Show + FunctionEnd + + Function mbAutostartPageLeave + ${NSD_GetState} $MB_RbOpen $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $MB_AutoMode "0" + ${EndIf} + ${NSD_GetState} $MB_RbSignin $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $MB_AutoMode "1" + ${EndIf} + ${NSD_GetState} $MB_RbService $0 + ${If} $0 == ${BST_CHECKED} + StrCpy $MB_AutoMode "2" + ${EndIf} + FunctionEnd +!macroend + !macro customInstall ; resources\node-runtime\meshbay-node.exe is about to be overwritten; a ; daemon still running from a previous version holds the file open. @@ -53,41 +137,45 @@ SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000 ${IfNot} ${Silent} - ; Already configured -- an upgrade, or a repair install -- asks nothing. - ; Checked unelevated: reading firewall rules needs no admin, only - ; creating them does (same reasoning as the service task below). Whether - ; service mode or per-user mode was chosen last time, firewall rules - ; existing already means there is nothing left for this dialog to do. + ; The firewall rules go in for every autostart mode. Read their state + ; first, unelevated -- Get-NetFirewallRule needs no admin, only New/Remove + ; do -- so an upgrade or repair that changes nothing triggers no UAC. nsExec::Exec '"${MB_PWSH}" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\firewall.ps1" check' - Pop $0 - ${If} $0 == 0 - Goto mb_mode_done - ${EndIf} + Pop $R0 ; 0 = every rule already present - ; The choice. Service mode needs admin to CREATE (a boot trigger touches - ; system-wide scheduler state -- the same reason /sc onlogon needed it - ; too); day-to-day start/stop from the Node page does not, once the task - ; exists, because Task Scheduler grants the owning user that much itself. - MessageBox MB_YESNO|MB_ICONQUESTION \ - "Run MeshBay Node as a background service?$\n$\nIt starts automatically at boot, even before you sign in, and needs one administrator confirmation now (which also sets up the Windows Firewall rules, in the same step).$\n$\nChoose No for the normal per-user mode instead: it starts when you sign in, no admin needed, and you will be asked about the firewall rules separately." \ - IDNO mb_peruser_mode - - ; -- Service mode: one elevation, both jobs -------------------------- + ${If} $MB_AutoMode == "2" + ; Background service: the boot-time Scheduled Task AND the firewall + ; rules, in ONE elevation (service-mode.ps1 does both). Skip it only + ; when the task already exists and the rules are already there. + nsExec::Exec '"${MB_PWSH}" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service.ps1" status' + Pop $R1 ; 0 = task installed + ${If} $R1 == 0 + ${AndIf} $R0 == 0 + Goto mb_auto_done + ${EndIf} ExecShellWait "runas" "${MB_PWSH}" \ '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service-mode.ps1" -Action install' \ SW_HIDE - Goto mb_mode_done + Goto mb_auto_done + ${EndIf} + + ; Modes 0 and 1: no scheduled task. One elevation for the firewall rules, + ; and only if one is actually missing. + ${If} $R0 != 0 + ExecShellWait "runas" "${MB_PWSH}" \ + '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\firewall.ps1" add' \ + SW_HIDE + ${EndIf} - mb_peruser_mode: - ; -- Per-user mode: the firewall question stands on its own ---------- - MessageBox MB_YESNO|MB_ICONQUESTION \ - "Allow MeshBay through Windows Firewall now?$\n$\nMeshBay connects to other devices on your local network. Choosing Yes adds the rules in one step (Windows will ask for administrator confirmation). Choosing No is fine too -- Windows will ask you to allow access the first time MeshBay connects." \ - /SD IDYES IDNO mb_mode_done - ExecShellWait "runas" "${MB_PWSH}" \ - '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\firewall.ps1" add' \ - SW_HIDE + ; Mode 1 also drops the per-user sign-in launcher (no admin -- it is just + ; a .vbs in this account's Startup folder). Idempotent, so a repeat run is + ; harmless. meshbay_node.platform.service_install() removes this itself if + ; the user later switches to service mode from the Node page. + ${If} $MB_AutoMode == "1" + nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart install' + ${EndIf} - mb_mode_done: + mb_auto_done: ${EndIf} !macroend @@ -117,8 +205,8 @@ mb_keep_privileged: ${EndIf} - ; meshbay_node.platform._startup_vbs() -- if the user ran "meshbay-node - ; autostart install" (or toggled it in the client), this points wscript at - ; the binary we are about to delete, and would error at every sign-in. + ; meshbay_node.platform.autostart_install() -- if the user picked "at sign-in" + ; (here, or later in the client), this points wscript at the binary we are + ; about to delete, and would error at every sign-in. Delete "$APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\MeshBay Node.vbs" !macroend diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index b52e13a..7421a8c 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -191,66 +191,112 @@ def test_the_uninstaller_clears_the_autostart_launcher(): def test_customInstall_stops_a_running_daemon_before_overwriting_it(): nsh = NSH.read_text(encoding="utf-8") - body = nsh.split("!macro customInstall", 1)[1].split("!macroend", 1)[0] + body = _macro_body(nsh, "customInstall") assert "taskkill /IM meshbay-node.exe /F" in body -# ── the one-time elevated firewall step ───────────────────────────────────── +# ── the autostart choice + the one-time elevated firewall step ────────────── def _macro_body(nsh: str, name: str) -> str: - return nsh.split(f"!macro {name}", 1)[1].split("!macroend", 1)[0] + # \b after the name so "customInstall" does not also match the start of + # "customInstallMode" (which is defined just above it in the file). + return re.split(rf"!macro {re.escape(name)}\b", nsh, maxsplit=1)[1] \ + .split("!macroend", 1)[0] -def test_the_installer_offers_a_service_mode_choice_with_one_elevation(): +def test_the_all_users_install_mode_page_is_suppressed(): """ - Adding a firewall rule or a boot-time Scheduled Task both need admin; the - install itself never elevates (build.nsis allowElevation:false). So this - must be opt-in (a Yes/No the user can decline) and skipped entirely in a - silent install — an unattended `/S` install must never pop a UAC prompt on - its own. Choosing service mode must fold the Scheduled Task AND the - firewall rules into ONE elevation (service-mode.ps1), never two. + MeshBay is per-user only — the keystore and the DPAPI-protected secrets are + bound to the signed-in account (MESHBAY_DESIGN.md §11.2 / §7.5), and + build.nsis forbids elevation — so electron-builder's "anyone who uses this + computer / only me" page only ever showed its first option disabled. + customInstallMode forcing $isForceCurrentInstall skips the page and pins + per-user (multiUserUi.nsh: `${if} $isForceCurrentInstall == "1"` → abort). + """ + nsh = NSH.read_text(encoding="utf-8") + mode = _macro_body(nsh, "customInstallMode") + assert 'StrCpy $isForceCurrentInstall "1"' in mode + + +def test_the_autostart_choice_is_a_radio_page_defaulting_to_service(): + """ + The old two nested Yes/No MessageBoxes are one nsDialogs page now, with the + same three-way meaning: only-while-open / at-sign-in / background service. + Background service is the default selection (MB_AutoMode "2"), set in + customInit so a silent install — where the page never runs — still has a + definite value. + """ + nsh = NSH.read_text(encoding="utf-8") + page = _macro_body(nsh, "customPageAfterChangeDir") + + assert "Page custom mbAutostartPageCreate mbAutostartPageLeave" in page + assert page.count("${NSD_CreateRadioButton}") == 3, ( + "expected exactly three autostart options") + assert "${NSD_Check} $MB_RbService" in page, ( + "the background-service option must be the one checked by default") + assert 'StrCpy $MB_AutoMode "2"' in _macro_body(nsh, "customInit"), ( + "customInit must default MB_AutoMode to service mode for silent installs") + # The MessageBox-driven flow is gone from customInstall entirely. + assert "MessageBox MB_YESNO" not in _macro_body(nsh, "customInstall") + + +def test_the_firewall_rules_are_set_up_in_every_autostart_mode(): + """ + A node that silently accepts no connections is the failure mode + MESHBAY_DESIGN.md §7.5 calls out. So the rules go in whatever the autostart + choice: folded into the service elevation for mode "2" (service-mode.ps1 + does task + firewall in one UAC), their own single elevation for + modes "0"/"1". Still ${Silent}-guarded — an unattended /S install cannot + raise a UAC prompt, and falls back to Windows' own first-connection dialogs. """ nsh = NSH.read_text(encoding="utf-8") install = _macro_body(nsh, "customInstall") - assert "${IfNot} ${Silent}" in install, ( - "the mode choice is not guarded against silent installs") - assert install.count("MessageBox MB_YESNO") == 2, ( - "expected exactly two questions: service-mode-or-not, then (only in " - "the per-user branch) the firewall-only question") + assert "${IfNot} ${Silent}" in install assert 'ExecShellWait "runas"' in install - # Service mode: one elevated call for both jobs, not one each. - assert 'service-mode.ps1" -Action install' in install - assert 'firewall.ps1" add' not in install.split("mb_peruser_mode:", 1)[0], ( - "service mode must not ALSO separately elevate for firewall.ps1 — " - "service-mode.ps1 already does that in the same elevation") - # Per-user mode (declined the service question) keeps today's separate, - # still-opt-in firewall step. - peruser_branch = install.split("mb_peruser_mode:", 1)[1] - assert 'firewall.ps1" add' in peruser_branch + + i_fw_check = install.index('firewall.ps1" check') + i_svc_if = install.index('${If} $MB_AutoMode == "2"') + i_svc_install = install.index('service-mode.ps1" -Action install') + i_fw_add = install.index('firewall.ps1" add') + # firewall check (unelevated) → service branch → firewall-only branch. + assert i_fw_check < i_svc_if < i_svc_install < i_fw_add + # The service branch's single elevation is service-mode.ps1; it does not + # ALSO call firewall.ps1 add (service-mode.ps1 already covers that). + assert 'firewall.ps1" add' not in install[i_svc_if:i_fw_add] + + +def test_the_signin_mode_installs_the_per_user_startup_launcher(): + """Mode "1" ("at sign-in") drops the Startup-folder .vbs via the frozen + daemon's own `autostart install` verb — no admin, idempotent. The nearest + enclosing choice must be `$MB_AutoMode == "1"`, so modes "0"/"2" skip it.""" + nsh = NSH.read_text(encoding="utf-8") + install = _macro_body(nsh, "customInstall") + + i_call = install.index('meshbay-node.exe" autostart install') + last_if = install.rindex("${If} $MB_AutoMode ==", 0, i_call) + assert install[last_if:i_call].startswith('${If} $MB_AutoMode == "1"'), ( + "the Startup launcher must be gated on the at-sign-in choice") def test_reinstalling_with_everything_already_in_place_asks_nothing(): """ Get-NetFirewallRule needs no admin, only New/Remove do — so customInstall - checks the firewall rules first, unelevated, and only reaches the mode - question (and therefore a possible UAC prompt) when something is actually - missing. Without this, running setup a second time — an upgrade, a repair - install — would re-ask the question (and, in service mode, re-trigger UAC) - even though nothing needs to change. Checking the firewall rules alone is - enough: service-mode.ps1 always sets up both together, so if the rules - are there, so is everything else that was chosen last time. + checks the firewall rules first, unelevated, and only elevates when + something is actually missing. Without this, a repair or an upgrade that + changes nothing would still re-trigger UAC. In service mode the task's + presence is checked the same unelevated way (service.ps1 status), and both + must be satisfied to skip. """ nsh = NSH.read_text(encoding="utf-8") install = _macro_body(nsh, "customInstall") check_line = 'firewall.ps1" check' assert check_line in install - mode_question = "Run MeshBay Node as a background service?" - # The check must run, and be evaluated, before the mode question — not after. - assert install.index(check_line) < install.index(mode_question) - assert "Pop $0" in install and "${If} $0 == 0" in install - assert "Goto mb_mode_done" in install + # The check runs before any elevation. + assert install.index(check_line) < install.index('ExecShellWait "runas"') + assert 'service.ps1" status' in install + assert "Goto mb_auto_done" in install def test_the_uninstaller_offers_to_remove_everything_privileged_default_no(): @@ -539,8 +585,8 @@ def test_the_bundled_daemon_goes_on_the_user_path_and_comes_back_off(): nsh = NSH.read_text(encoding="utf-8") assert "!insertmacro WordAdd" in nsh and "!insertmacro un.WordAdd" in nsh - install = nsh.split("!macro customInstall", 1)[1].split("!macroend", 1)[0] - uninstall = nsh.split("!macro customUnInstall", 1)[1].split("!macroend", 1)[0] + install = _macro_body(nsh, "customInstall") + uninstall = _macro_body(nsh, "customUnInstall") assert 'HKCU "Environment" "Path"' in install assert "${WordAdd}" in install and '"+${MB_NODE_BIN}"' in install assert "${un.WordAdd}" in uninstall and '"-${MB_NODE_BIN}"' in uninstall |