diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-11 17:47:42 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-11 17:51:51 +0200 |
| commit | edbff1768054afa80efda721cd1011b29c7fe355 (patch) | |
| tree | 4ae3d27877bd08e92cc0981b80bcc9502e82bff5 /packages/meshbay-node | |
| parent | d4935aa2a28fcbab8c3556e3532e53667092701e (diff) | |
| download | meshbay-edbff1768054afa80efda721cd1011b29c7fe355.tar.gz | |
feat(packaging): an MSIX target for Microsoft Store submission
Store certification of the NSIS "MSI/EXE" submission failed on three
checks (silent-install verification, Add/Remove Programs entry, bundleware
check) -- traced and reproduced live to one cause: SmartScreen blocks an
unsigned, internet-downloaded installer at the shell layer before
Microsoft's own unattended validation bot ever gets to run it. MSIX
sidesteps this class of failure entirely: submitted through the Store's
native pipeline, there is no browser-download-then-launch step for
SmartScreen to intercept, and Microsoft signs the package itself at
publish time -- free, and specific to this submission type (Trusted
Signing remains a paid service for the MSI/EXE path). Full plan and
findings: C:\Users\admin\devel\msix-installer.md (out of repo).
electron-builder.msix.yml carries the same bundle as Full (node runtime,
ffmpeg, both service scripts) -- an AppX/MSIX install never elevates, by
design, but that changes only *when* the two elevated operations can run,
not whether the daemon ships. No main.js changes were needed: the on-demand
elevation path for service-mode (winElevateServiceMode(), driven from the
Node page) already existed for a different reason and depends only on
service-mode.ps1 being present as an extraResource, true for any packaged
Windows target. identityName/publisher/publisherDisplayName are the real
values from Partner Center's app-identity reservation, not placeholders.
build-win-msix.ps1 points electron-builder at the system Windows 10 SDK
(auto-detected) instead of letting it download its own bundled copy --
that download's 7z extraction creates symlinks this target never uses and
fails without SeCreateSymbolicLinkPrivilege, reproduced on this machine.
build/appx/ carries the four tile images the AppX target requires
regardless of showNameOnTiles, generated once from the existing app icon
(see that directory's README) since the system-SDK redirect has no vendor
samples to fall back to. build/appx-extensions.xml declares
windows.startupTask by hand rather than via electron-builder's
addAutoLaunchExtension, which always targets the Electron shell -- this
points at the bundled node binary instead, matching what "starts at sign
in" already means for Full.
Verified live via a signed sideload install (self-signed test cert,
cleaned up after): the package installs and the app runs correctly. One
finding worth carrying forward -- the declared network capabilities
(internetClientServer, privateNetworkClientServer) do not create any
firewall exemption for this app, most likely because automatic
capability-based exemption is an AppContainer-sandbox property and this
app deliberately runs full-trust, outside any sandbox. Not a regression:
no install-time elevation was possible either way, so the cost is the same
one-time OS firewall prompt firewall.ps1's own header already documents as
its fallback today.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/tests/test_packaging_win.py | 204 |
1 files changed, 199 insertions, 5 deletions
diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index b223156..2cc8f12 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -31,6 +31,13 @@ LIGHT_YML = WIN / "electron-builder.light.yml" BUILD_WIN_LIGHT = WIN / "build-win-light.ps1" BUILD_WIN_COMMON = WIN / "build-win-common.ps1" +# The "MSIX" target: same feature set as Full, packaged for Microsoft Store +# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for +# the plan this implements. +MSIX_YML = WIN / "electron-builder.msix.yml" +BUILD_WIN_MSIX = WIN / "build-win-msix.ps1" +MSIX_EXTENSIONS_XML = CLIENT / "build" / "appx-extensions.xml" + # 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/. @@ -746,10 +753,10 @@ def test_build_win_light_skips_the_node_runtime_step(): 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. + place (build-win-common.ps1), dot-sourced by all three orchestrators -- + a copy would let them drift the way the installer flow itself once did + (the W3 one-shot dialog bug). Every orchestrator must call the shared + functions, none 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") @@ -758,7 +765,9 @@ def test_build_orchestrators_share_the_common_steps_not_a_copy(): 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")): + msix = BUILD_WIN_MSIX.read_text(encoding="utf-8") + for src, name in ((full, "build-win.ps1"), (light, "build-win-light.ps1"), + (msix, "build-win-msix.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"): @@ -908,3 +917,188 @@ def test_create_group_page_falls_back_when_no_node_is_bundled(): assert "platform.node.bundled()" in body assert "platform.node.available && bundled" in body assert "CreateGroupWizard" in body and "CreateGroupFormSimple" in body + + +# ------------------------------------------------------------------------ +# The "MSIX" target: same feature set as Full, packaged for Microsoft Store +# submission instead of NSIS. See C:\Users\admin\devel\msix-installer.md for +# the plan. Unlike Light, this target keeps the node runtime and both +# service scripts -- what changes is packaging format, not what ships. +# Weak, text-reading evidence throughout, same reasoning as the rest of +# this file: there is no electron-builder/appx runner here either. +# ------------------------------------------------------------------------ + +def test_msix_config_is_standalone_and_keeps_the_full_bundle(): + """ + Unlike Light, MSIX ships the same node-runtime/ffmpeg/service scripts as + Full -- an AppX install never elevating (msix-installer.md 4) is not a + reason to drop the daemon, only to change how its two elevated + operations get triggered (see the two tests below). --config still + means this file is read alone (app-builder-lib's getConfig), so it + cannot silently inherit Full's package.json build.nsis or any signing + config meant for NSIS. + """ + assert MSIX_YML.exists(), f"{MSIX_YML} is missing" + yml = MSIX_YML.read_text(encoding="utf-8") + + assert "appId: org.meshbay.client" in yml + assert "target: appx" in yml + assert "output: dist-msix" in yml + + assert "node-runtime" in yml + assert "service.ps1" in yml + assert "service-mode.ps1" in yml + assert "firewall.ps1" in yml + + +def test_msix_identity_matches_the_partner_center_reservation(): + """ + identityName/publisher/publisherDisplayName are assigned by Partner + Center's "App identity" page when the app name is reserved -- they + cannot be chosen freely, and a mismatch fails Store validation outright + rather than warning. Pins the exact reservation (done 2026-09-11) so a + future edit cannot silently drift back to electron-builder's own + unconfigured-build stand-in ("CN=ms", see windowsSignToolManager.js's + computePublisherName) without a test noticing. + publisherDisplayName is "MeshBay" as Partner Center assigned it, NOT + package.json's author company name ("MeshBay Team") -- AppXOptions.d.ts's + own default (company name from app metadata) would pick the wrong one if + this were ever left unset instead of explicit. + """ + yml = MSIX_YML.read_text(encoding="utf-8") + assert "identityName: MeshBay.MeshBay" in yml + assert 'publisher: "CN=CE32BB0D-6B7C-4D3A-AA42-E259B778CAC9"' in yml + assert "publisherDisplayName: MeshBay" in yml + assert '"CN=ms"' not in yml, ( + "must not have drifted back to electron-builder's own placeholder " + "publisher") + + +def test_msix_declares_no_csc_on_purpose(): + """ + No certificateFile/certificateSubjectName/certificateSha1 anywhere in + this config -- per app-builder-lib's own windowsSignToolManager.js, an + AppX target built with no certificate configured is logged as "Windows + Store only build" and left unsigned; Microsoft signs it at publish time + (msix-installer.md 3). Configuring a cert here would be wasted work, not + extra safety, and would risk this target picking up whatever might one + day be configured for Full's NSIS signing if it were ever added to this + file instead of package.json's own build.win. + """ + yml = MSIX_YML.read_text(encoding="utf-8") + for forbidden in ("certificateFile", "certificateSubjectName", "certificateSha1"): + assert forbidden not in yml + + +def test_msix_declares_the_network_capabilities_firewall_ps1_would_add(): + """ + Matches firewall.ps1's own rules, which are `-Profile Any` (private AND + public network) -- msix-installer.md 8's #1 open item: whether Windows + actually auto-exempts a full-trust packaged app on the strength of + these declarations is unverified until sideloaded, but the declaration + itself must at least match what the elevated NSIS path grants today, or + an MSIX install would be silently narrower than Full/Light. + """ + yml = MSIX_YML.read_text(encoding="utf-8") + caps_block = yml.split("capabilities:", 1)[1].split("customExtensionsPath", 1)[0] + assert "internetClientServer" in caps_block + assert "privateNetworkClientServer" in caps_block + + +def test_msix_startup_task_targets_the_node_not_the_electron_shell(): + """ + addAutoLaunchExtension's built-in windows.startupTask (app-builder-lib's + AppxTarget.js) always targets the package's own main executable -- the + Electron shell -- which is not what "starts at sign in" means today + (main.js's WIN_STARTUP_VBS launches meshbay-node.exe directly, keeping + the daemon running whether or not the UI is ever opened). This config + must NOT use addAutoLaunchExtension for that reason, and must instead + supply its own extension via customExtensionsPath pointing at the node + binary's in-package path -- app\\resources\\node-runtime\\meshbay-node.exe, + derived from AppxTarget.js's own `"app\\\\" + appOutDir-relative path` + mapping (build() in that file), which is not the same prefix + process.resourcesPath resolves to at runtime and easy to get wrong. + """ + yml = MSIX_YML.read_text(encoding="utf-8") + # The comment explaining *why* addAutoLaunchExtension is not used + # necessarily names it -- check the directive, not the prose (the same + # "parse directives, not text" mistake CLAUDE.md's engineering lessons + # already record for a differently-shaped bug). + lines = [ln.strip() for ln in yml.splitlines()] + assert not any(ln.startswith("addAutoLaunchExtension:") for ln in lines), ( + "must not set addAutoLaunchExtension -- it always targets the " + "Electron shell, not the node binary (see this test's docstring)") + assert "customExtensionsPath: build/appx-extensions.xml" in yml + + assert MSIX_EXTENSIONS_XML.exists(), f"{MSIX_EXTENSIONS_XML} is missing" + ext = MSIX_EXTENSIONS_XML.read_text(encoding="utf-8") + assert 'Category="windows.startupTask"' in ext + assert 'Executable="app\\resources\\node-runtime\\meshbay-node.exe"' in ext + assert 'EntryPoint="Windows.FullTrustApplication"' in ext + + +def test_msix_ships_the_four_required_tile_images(): + """ + app-builder-lib's AppxTarget.js requires StoreLogo/Square44x44Logo/ + Square150x150Logo/Wide310x150Logo regardless of showNameOnTiles, falling + back to its own vendor-bundled samples if build/appx/ does not supply + them. This repo points ELECTRON_BUILDER_WINDOWS_KITS_PATH at the system + Windows SDK instead of that vendor bundle (build-win-msix.ps1), which + has no samples to fall back to -- so a missing one here is a hard + makeappx failure, not a cosmetic gap. See build/appx/README.md for + where these came from. + """ + appx_assets = CLIENT / "build" / "appx" + for name in ("StoreLogo.png", "Square44x44Logo.png", + "Square150x150Logo.png", "Wide310x150Logo.png"): + assert (appx_assets / name).exists(), f"{name} is missing from {appx_assets}" + + +def test_build_win_msix_keeps_the_node_runtime_step(): + """The opposite of Light's equivalent test: MSIX is Full's feature set, + repackaged -- build-node-runtime.ps1 must still run.""" + assert BUILD_WIN_MSIX.exists(), f"{BUILD_WIN_MSIX} is missing" + src = BUILD_WIN_MSIX.read_text(encoding="utf-8") + assert "build-node-runtime.ps1" in src + assert "electron-builder.msix.yml" in src + assert "--config" in src + assert "--win appx" in src + + +def test_dist_win_msix_delegates_to_the_msix_orchestrator(): + pkg = _pkg() + scripts = pkg["scripts"] + assert "dist:win:msix" in scripts + assert "build-win-msix.ps1" in scripts["dist:win:msix"] + + +def test_build_win_msix_points_electron_builder_at_the_system_sdk(): + """ + electron-builder's own bundled AppX tooling download (winCodeSign-*.7z) + extracts symlinks for binaries this target never uses and fails without + SeCreateSymbolicLinkPrivilege -- reproduced on this machine's build + shell. ELECTRON_BUILDER_WINDOWS_KITS_PATH (app-builder-lib's + getWindowsKitsBundle) is the documented escape hatch; this must be set + automatically, not left as a step a future build is expected to + remember. + """ + src = BUILD_WIN_MSIX.read_text(encoding="utf-8") + assert "ELECTRON_BUILDER_WINDOWS_KITS_PATH" in src + assert "makeappx.exe" in src + + +# ── main.js needs nothing new for this target ────────────────────────────── +# +# Unlike Light (which needed hasBundledNode/winCanElevateServiceMode/the +# create-group gate because the bundle itself is smaller), MSIX ships +# everything Full does, and the two elevation paths it cannot get from an +# installer already exist independently of installer.nsh: +# firewall.ps1's per-first-use Windows prompt (no admin needed for that +# fallback -- see firewall.ps1's own header) and main.js's +# winElevateServiceMode(), driven from the Node page ("the other door", +# already exercised by test_can_elevate_checks_service_mode_ps1_actually_ +# exists above) rather than from setup. There is deliberately no +# MSIX-specific test here pinning main.js: the Light-target tests above +# 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. |