diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-client/build/installer-light.nsh | 67 | ||||
| -rw-r--r-- | packages/meshbay-client/package.json | 3 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 249 |
3 files changed, 318 insertions, 1 deletions
diff --git a/packages/meshbay-client/build/installer-light.nsh b/packages/meshbay-client/build/installer-light.nsh new file mode 100644 index 0000000..86e41ba --- /dev/null +++ b/packages/meshbay-client/build/installer-light.nsh @@ -0,0 +1,67 @@ +; electron-builder NSIS customisation for the "Light" target +; (packaging/win/electron-builder.light.yml's nsis.include). +; +; Deliberately much smaller than build/installer.nsh (Full's): there is no +; bundled node, so there is nothing to autostart, no PATH entry to add, no +; process to stop before overwriting a file, and no boot-time Scheduled Task. +; The only thing this installer does, all conditional on interactive setup +; (never ${Silent}): set up the Windows Firewall rules Light still needs -- +; the client's own WebRTC ICE rule and the two LAN-casting rules (see +; packaging/win/firewall.ps1's own header for why the *client*, not just a +; node, needs an inbound allow). One elevation, no radio page -- there is +; nothing to choose, so unlike Full's installer.nsh there is no mode +; question at all. +; +; Per-user only, same reasoning as Full: the keystore and the DPAPI-protected +; hub device key are bound to the signed-in account (MESHBAY_DESIGN.md +; 11.2 / 7.5), and build.win's nsis config forbids elevation at install time. +; +; MUST NOT touch anything a co-installed Full client owns. Full and Light can +; be installed side by side (distinct appId/productName/install dir), and it +; is entirely plausible for a machine to have both -- Light's installer must +; be a no-op with respect to a Full install's node process, its per-user +; Startup entry, its Scheduled Task, and the "MeshBay Node" firewall rule. +; Concretely: this file never stops a process by image name, never touches +; the per-user PATH registry value, never references either of Full's two +; service scripts, and never removes a startup shortcut of its own -- +; test_packaging_win.py pins that absence. + +!include "WinMessages.nsh" +!include "LogicLib.nsh" + +!define MB_PWSH "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" + +; ── force per-user, skip the all-users / current-user page ────────────────── +; Same as Full's installer.nsh -- see that file's own comment for why. +!macro customInstallMode + StrCpy $isForceCurrentInstall "1" +!macroend + +!macro customInstall + ${IfNot} ${Silent} + ; Unelevated first: Get-NetFirewallRule needs no admin, only New/Remove + ; do, so a repeat/repair install that already has the rules in place + ; raises no UAC prompt. + nsExec::Exec '"${MB_PWSH}" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\firewall.ps1" check' + Pop $R0 ; 0 = every rule already present + ${If} $R0 != 0 + ExecShellWait "runas" "${MB_PWSH}" \ + '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\firewall.ps1" add' \ + SW_HIDE + ${EndIf} + ${EndIf} +!macroend + +!macro customUnInstall + ; Opt-in, default No -- a stale allow-rule is inert, so this should not + ; nag. A silent uninstall skips it entirely (no UAC prompt of its own). + ${IfNot} ${Silent} + MessageBox MB_YESNO|MB_ICONQUESTION \ + "Remove MeshBay Light's Windows Firewall rules? This needs one administrator confirmation. They are harmless if left." \ + /SD IDNO IDNO mb_keep_firewall + ExecShellWait "runas" "${MB_PWSH}" \ + '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\firewall.ps1" remove' \ + SW_HIDE + mb_keep_firewall: + ${EndIf} +!macroend diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json index e88b2f0..614e6d6 100644 --- a/packages/meshbay-client/package.json +++ b/packages/meshbay-client/package.json @@ -11,7 +11,8 @@ "start": "electron .", "sync-ui": "node scripts/sync-ui.js", "dist": "bash ../../packaging/build/build-client.sh", - "dist:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ../../packaging/win/build-win.ps1" + "dist:win": "powershell -NoProfile -ExecutionPolicy Bypass -File ../../packaging/win/build-win.ps1", + "dist:win:light": "powershell -NoProfile -ExecutionPolicy Bypass -File ../../packaging/win/build-win-light.ps1" }, "devDependencies": { "electron": "^44.2.0", diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index 7421a8c..b223156 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -21,6 +21,20 @@ CLIENT = ROOT / "packages" / "meshbay-client" PKG_JSON = CLIENT / "package.json" WIN = ROOT / "packaging" / "win" NSH = CLIENT / "build" / "installer.nsh" +MAIN_JS = CLIENT / "src" / "main.js" +PRELOAD_JS = CLIENT / "src" / "preload.js" + +# The "Light" target: Electron client + UI, no bundled node. See +# C:\Users\admin\devel\light-client.md for the evaluation this implements. +LIGHT_NSH = CLIENT / "build" / "installer-light.nsh" +LIGHT_YML = WIN / "electron-builder.light.yml" +BUILD_WIN_LIGHT = WIN / "build-win-light.ps1" +BUILD_WIN_COMMON = WIN / "build-win-common.ps1" + +# sync-ui.js copies this into CLIENT/ui/ verbatim -- read the source of +# truth, same as every other cross-package check in this file already does +# for meshbay-client's own src/. +HUB_STATIC = ROOT / "packages" / "meshbay-hub" / "src" / "meshbay_hub" / "static" pytestmark = pytest.mark.skipif( not PKG_JSON.exists() or not WIN.exists(), @@ -659,3 +673,238 @@ def test_ffmpeg_bundling_is_the_default_not_opt_in(): win_src = (WIN / "build-win.ps1").read_text(encoding="utf-8") assert "SkipFfmpeg" in win_src assert "FfmpegDir" not in win_src + + +# ------------------------------------------------------------------------ +# The "Light" target: Electron client + UI, no bundled node. See +# C:\Users\admin\devel\light-client.md for the evaluation. Weak, text- +# reading evidence throughout, same reasoning as the rest of this file: +# there is no electron-builder/PowerShell/NSIS runner here, and it is the +# right kind of evidence for what these guard against -- a config drifting +# back to carrying the node-runtime it must not, or the two orchestrators' +# shared steps diverging silently. +# ------------------------------------------------------------------------ + +def test_light_config_is_standalone_and_ships_no_node(): + """ + electron-builder.light.yml is passed via --config, which (per + app-builder-lib/out/util/config/load.js's getConfig) makes electron- + builder read ONLY that file -- package.json's own build field, and + therefore its node-runtime/service*.ps1 extraResources, is never even + loaded. Pins the config's own content regardless: it must name neither + the node runtime nor the two service scripts, and must still carry + firewall.ps1 (the one thing Light still needs -- packaging/win/ + firewall.ps1's header explains why the client needs an inbound rule too, + not only a node). + """ + assert LIGHT_YML.exists(), f"{LIGHT_YML} is missing" + yml = LIGHT_YML.read_text(encoding="utf-8") + + assert "appId: org.meshbay.client.light" in yml + assert "productName: MeshBay Light" in yml + assert "output: dist-light" in yml + + assert "node-runtime" not in yml + assert "service.ps1" not in yml + assert "service-mode.ps1" not in yml + assert "firewall.ps1" in yml + + assert "include: installer-light.nsh" in yml, ( + "nsis.include must point at the Light NSIS customisation, not the " + "default build/installer.nsh (Full's)") + + +def test_light_config_keeps_the_same_no_admin_nsis_policy(): + """Per-user, no elevation at install time -- identical policy to Full's + package.json build.nsis, for the same reason (MESHBAY_DESIGN.md + 11.2/7.5: the DPAPI-protected hub device key is account-bound).""" + yml = LIGHT_YML.read_text(encoding="utf-8") + assert "oneClick: false" in yml + assert "perMachine: false" in yml + assert "allowElevation: false" in yml + + +def test_dist_win_light_delegates_to_the_light_orchestrator(): + pkg = _pkg() + scripts = pkg["scripts"] + assert "dist:win:light" in scripts + assert "build-win-light.ps1" in scripts["dist:win:light"] + assert "dist:win" in scripts + assert "build-win.ps1" in scripts["dist:win"] + assert "build-win-light.ps1" not in scripts["dist:win"] + + +def test_build_win_light_skips_the_node_runtime_step(): + """The entire point of Light: no PyInstaller freeze, no ffmpeg fetch.""" + assert BUILD_WIN_LIGHT.exists(), f"{BUILD_WIN_LIGHT} is missing" + src = BUILD_WIN_LIGHT.read_text(encoding="utf-8") + assert "build-node-runtime.ps1" not in src + assert "electron-builder.light.yml" in src + assert "--config" in src + + +def test_build_orchestrators_share_the_common_steps_not_a_copy(): + """ + Node check / npm ci / Electron bump / sync-ui must live in exactly one + place (build-win-common.ps1), dot-sourced by both -- a copy would let + the two drift the way the installer flow itself once did (the W3 + one-shot dialog bug). Both orchestrators must call the shared + functions, neither may inline its own npm ci / Electron-bump logic. + """ + assert BUILD_WIN_COMMON.exists(), f"{BUILD_WIN_COMMON} is missing" + common = BUILD_WIN_COMMON.read_text(encoding="utf-8") + for fn in ("Assert-NodeVersion", "Invoke-NpmCi", "Invoke-ElectronBump", "Invoke-SyncUi"): + assert f"function {fn}" in common, f"{fn} is not defined in build-win-common.ps1" + + full = (WIN / "build-win.ps1").read_text(encoding="utf-8") + light = BUILD_WIN_LIGHT.read_text(encoding="utf-8") + for src, name in ((full, "build-win.ps1"), (light, "build-win-light.ps1")): + assert '. (Join-Path $WinDir "build-win-common.ps1")' in src, ( + f"{name} does not dot-source build-win-common.ps1") + for fn in ("Assert-NodeVersion", "Invoke-NpmCi", "Invoke-ElectronBump", "Invoke-SyncUi"): + assert fn in src, f"{name} does not call {fn}" + assert "npm ci --ignore-scripts" not in src, ( + f"{name} must not re-implement npm ci inline") + + +def test_firewall_ps1_skips_the_node_rule_when_the_exe_is_missing(): + """ + The one behaviour Light structurally depends on: firewall.ps1 is + shipped unforked (test_light_config_is_standalone_and_ships_no_node + above), relying on `add` already skipping any rule whose target .exe + does not exist -- true for "MeshBay Node" in a Light install, where + node-runtime\\meshbay-node.exe is never there. If this guard were ever + removed, New-NetFirewallRule would be pointed at a path that does not + exist and Light's one firewall elevation would start failing. + """ + src = (WIN / "firewall.ps1").read_text(encoding="utf-8") + add_block = src.split('if ($Action -eq "add")', 1)[1] + assert "if (-not (Test-Path $r.Path))" in add_block + skip_stanza = add_block.split("if (-not (Test-Path $r.Path))", 1)[1].split("}", 1)[0] + assert "continue" in skip_stanza + + +def test_light_installer_forces_per_user_and_elevates_firewall_once(): + assert LIGHT_NSH.exists(), f"{LIGHT_NSH} is missing" + nsh = LIGHT_NSH.read_text(encoding="utf-8") + install = _macro_body(nsh, "customInstall") + mode = _macro_body(nsh, "customInstallMode") + + assert 'StrCpy $isForceCurrentInstall "1"' in mode + + assert "${IfNot} ${Silent}" in install + i_check = install.index('firewall.ps1" check') + i_runas = install.index('ExecShellWait "runas"') + assert i_check < i_runas, "the unelevated check must run before any elevation" + assert 'firewall.ps1" add' in install + + assert "MB_AutoMode" not in nsh, "there is no autostart choice for Light" + assert "Page custom" not in nsh + assert "MessageBox MB_YESNO" not in install, ( + "customInstall itself must ask nothing -- only customUnInstall's " + "opt-in firewall-removal prompt uses MessageBox") + + +def test_light_installer_never_touches_a_co_installed_full_clients_node(): + """ + Full and Light can be installed side by side (distinct appId/ + productName/install dir -- test_light_config_is_standalone_and_ships_ + no_node above). Light's installer/uninstaller must be a complete no-op + with respect to anything a co-installed Full client owns: its node + process, its Startup .vbs, its Scheduled Task, its own firewall rule. + The easy mistake here is copy-pasting installer.nsh and trimming it, + leaving one of these in by accident. + """ + nsh = LIGHT_NSH.read_text(encoding="utf-8") + for forbidden in ("taskkill", 'HKCU "Environment"', "service.ps1", + "service-mode.ps1", ".vbs", "MeshBay Node.vbs"): + assert forbidden not in nsh, ( + f"installer-light.nsh must not reference {forbidden!r} -- that " + "belongs to a co-installed Full client, not to Light") + + +def test_light_uninstaller_offers_to_remove_the_firewall_rules_default_no(): + nsh = LIGHT_NSH.read_text(encoding="utf-8") + uninstall = _macro_body(nsh, "customUnInstall") + assert "${IfNot} ${Silent}" in uninstall + assert "/SD IDNO" in uninstall + assert 'firewall.ps1" remove' in uninstall + assert 'ExecShellWait "runas"' in uninstall + + +# ── the app-side gaps a bundle-less build would otherwise hit ────────────── + +def test_has_bundled_node_is_windows_only_and_packaged_only(): + """ + Off win32, or unpackaged (dev), this must stay true unconditionally -- + only a packaged Windows build can even be Light, so nothing here may + change today's behaviour for Linux, macOS or `npm start`. + """ + src = MAIN_JS.read_text(encoding="utf-8") + assert "function hasBundledNode()" in src + body = src.split("function hasBundledNode()", 1)[1].split("\n }", 1)[0] + assert "process.platform === 'win32'" in body + assert "app.isPackaged" in body + assert "return true" in body, ( + "must fall back to true (today's behaviour) off win32 / unpackaged") + + +def test_node_bundled_ipc_channel_exists_end_to_end(): + """main.js handles it, preload.js exposes it, platform.js wraps it -- + the same three-layer shape every other node.* capability already has.""" + main = MAIN_JS.read_text(encoding="utf-8") + assert "ipcMain.handle('node:bundled'" in main + assert "hasBundledNode()" in main + + preload = PRELOAD_JS.read_text(encoding="utf-8") + assert "ipcRenderer.invoke('node:bundled')" in preload + + platform_js = (HUB_STATIC / "platform.js").read_text(encoding="utf-8") + assert "bridge.node.bundled()" in platform_js + + +def test_can_elevate_checks_service_mode_ps1_actually_exists(): + """ + app.isPackaged alone used to gate canElevate -- true for Light too, + where service-mode.ps1 is never shipped, so the Node page would offer + "background service" and only fail when clicked. Both nodeServiceStatus + branches (service installed, and the per-user Startup branch) must go + through the same helper rather than reimplementing the check. + """ + src = MAIN_JS.read_text(encoding="utf-8") + assert "function winCanElevateServiceMode()" in src + helper = src.split("function winCanElevateServiceMode()", 1)[1].split("\n }", 1)[0] + assert "app.isPackaged" in helper + assert "'service-mode.ps1'" in helper + assert src.count("canElevate: winCanElevateServiceMode()") == 2, ( + "both nodeServiceStatus branches must use the helper, not a bare " + "app.isPackaged") + assert "canElevate: app.isPackaged," not in src + + +def test_startup_mode_is_null_not_a_lie_when_no_node_is_found(): + """ + The per-user Startup branch used to report mode: 'startup' unconditionally, + so a Light install with no node anywhere (bundled or on PATH) showed a + working-looking autostart dropdown for a node that did not exist -- + node-page.js's showStartupRow is gated on typeof info.mode === 'string'. + """ + src = MAIN_JS.read_text(encoding="utf-8") + assert "mode: bin ? 'startup' : null," in src + assert "mode: 'startup'," not in src + + +def test_create_group_page_falls_back_when_no_node_is_bundled(): + """ + The wizard's node-linking step is exactly the known "Detecting local + node..." hang when nothing ever answers. Gating it on `bundled` (not + just `available`, which is true for Light too -- both are Electron) + routes a Light install to CreateGroupFormSimple: not a lesser feature, + but the same node-free form a plain browser member already uses. + """ + src = (HUB_STATIC / "create-group-page.js").read_text(encoding="utf-8") + body = src.split("export function CreateGroupPage(props) {", 1)[1] \ + .split("\nfunction CreateGroupFormSimple", 1)[0] + assert "platform.node.bundled()" in body + assert "platform.node.available && bundled" in body + assert "CreateGroupWizard" in body and "CreateGroupFormSimple" in body |