diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-09 18:19:06 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-10 09:57:29 +0200 |
| commit | 42b562fcf312ddceb78438b5daea49d4c9b465c8 (patch) | |
| tree | 770913e96292b4c5123f8c5ecba2d62577307f67 | |
| parent | 25152ddb61a89ea3ea29d3aef5de13343429d32a (diff) | |
| download | meshbay-42b562fcf312ddceb78438b5daea49d4c9b465c8.tar.gz | |
feat(packaging): the Store package declares what the NSIS scripts do
A test-signed install of the MSIX build, in the WindowsApps folder a Store
install uses, showed that every script-made piece of the NSIS model breaks
there, because each names the install folder and every update deletes it:
the firewall rules went stale, the PATH entries piled up pointing at deleted
folders, and the Startup-folder .vbs was refused ("Permission denied") right
after sign-in. The network capabilities the manifest declared covered
nothing: they make rules for sandboxed apps only, and a listener in the
package still got the Windows firewall prompt. The package's own startup
task was on by default, started the node whatever mode the Node page said,
and ran the console executable, whose window stopped the node when closed.
The package now declares what Windows then creates at install, carries
across updates and removes with the app, all without an administrator
prompt (each measured on the real install, through an update and a reboot):
- firewall rules for the node, in a custom manifest template, since only a
package-level element can hold them;
- the startup task, off by default, running meshbay-nodew.exe, a new build
of the daemon without a console;
- an execution alias for meshbay-node.exe, so the app adds no PATH entry.
The node's CLI switches the startup task (platform.startup_task, ctypes over
the WinRT ABI): Windows gives the package's identity to the executables in
it, not to a powershell.exe the app starts, which got "Element not found".
`meshbay-node autostart install | remove | status` therefore works in the
Store package from the app and a terminal alike; the app caches the answer,
since the Node page polls. Starting at boot stays the .exe installer's: the
Store package offers no service mode, and the CLI refuses `service install`
there. Process listings count both image names.
The Node page's status poll cleared the message of a refused action within
five seconds; the two errors are kept apart now (all Windows builds).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
26 files changed, 728 insertions, 152 deletions
diff --git a/docs/PACKAGING-GUIDE.md b/docs/PACKAGING-GUIDE.md index fdc0a57..a9aa7fc 100644 --- a/docs/PACKAGING-GUIDE.md +++ b/docs/PACKAGING-GUIDE.md @@ -127,6 +127,27 @@ firewall rules and the boot-time service task with one administrator confirmation (none if neither is there). None of this touches `%LOCALAPPDATA%\meshbay\` (the keystore). +### Microsoft Store version + +The same client and node, installed from the Microsoft Store. It never asks +for administrator rights, and Windows itself manages what the installer above +sets up with scripts: + +- **Firewall**: the rules for the node are part of the package. Windows adds + them at install, keeps them through updates and removes them with the app. +- **When the node runs**: **Only while MeshBay is open** (the default) or + **At sign-in**, chosen on the **Node** page. At sign-in is the + *MeshBay Node* entry of **Settings → Apps → Startup**; switching it off + there is the same as choosing **Only while MeshBay is open**, and the Node + page cannot switch it back on until it is on there again. +- **At boot, before anyone signs in, is not available**: it needs the + `.exe` installer above. +- **`meshbay-node` in a terminal** works as with the installer. + +Runtime data lives in the same `%LOCALAPPDATA%\meshbay\` and survives +uninstalling the app. Uninstall from **Settings → Apps**; it removes the +firewall rules and the sign-in entry with it. + ### Build from source See [`packaging/win/README.md`](../packaging/win/README.md). On a machine with diff --git a/docs/WINDOWS-PORT.md b/docs/WINDOWS-PORT.md index 1894706..7e13aa0 100644 --- a/docs/WINDOWS-PORT.md +++ b/docs/WINDOWS-PORT.md @@ -723,5 +723,7 @@ uses a Scheduled Task (S4U logon), not `pywin32`/NSSM — see §5.3. packaging, no daemon lifecycle, no testing. - **Windows ARM** — not considered. Electron supports it; Python and ffmpeg availability would need checking. -- **Windows Store / MSIX** — not planned for v1. NSIS per-user installer - is the target. +- **Windows Store / MSIX** — built since (`npm run dist:win:msix`, + `packaging/win/electron-builder.msix.yml`, which says what the manifest + declares in place of the NSIS scripts). Starting at boot stays the NSIS + installer's. diff --git a/packages/meshbay-client/build/appx-extensions.xml b/packages/meshbay-client/build/appx-extensions.xml index 1ee1cca..3326dd8 100644 --- a/packages/meshbay-client/build/appx-extensions.xml +++ b/packages/meshbay-client/build/appx-extensions.xml @@ -1,3 +1,15 @@ - <desktop:Extension Category="windows.startupTask" Executable="app\resources\node-runtime\meshbay-node.exe" EntryPoint="Windows.FullTrustApplication"> - <desktop:StartupTask TaskId="MeshBayNodeStartup" Enabled="true" DisplayName="MeshBay Node" /> + <!-- At sign-in: off until the user picks that mode on the Node page, which + switches it through `meshbay-node autostart`. meshbay-nodew.exe, the build + without a console: Windows runs a startup task's executable as is, and + the console one opened a window whose close button stopped the node. --> + <desktop:Extension Category="windows.startupTask" Executable="app\resources\node-runtime\meshbay-nodew.exe" EntryPoint="Windows.FullTrustApplication"> + <desktop:StartupTask TaskId="MeshBayNodeStartup" Enabled="false" DisplayName="MeshBay Node" /> </desktop:Extension> + <!-- `meshbay-node` in a terminal: %LOCALAPPDATA%\Microsoft\WindowsApps is + on PATH already and this alias keeps its path across versions, where a + PATH entry naming the install folder went stale at each update. --> + <uap5:Extension Category="windows.appExecutionAlias" Executable="app\resources\node-runtime\meshbay-node.exe" EntryPoint="Windows.FullTrustApplication"> + <uap5:AppExecutionAlias> + <uap5:ExecutionAlias Alias="meshbay-node.exe" /> + </uap5:AppExecutionAlias> + </uap5:Extension> diff --git a/packages/meshbay-client/build/appx-manifest.xml b/packages/meshbay-client/build/appx-manifest.xml new file mode 100644 index 0000000..a8dba41 --- /dev/null +++ b/packages/meshbay-client/build/appx-manifest.xml @@ -0,0 +1,68 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- electron-builder's own appx template (app-builder-lib/templates/appx/), + plus what only a package-level element can declare: the node's firewall + rules. AppxTarget.js fills in its macros as for the stock one, and fails + on any it does not know, even inside a comment; re-check against that + file when electron-builder is upgraded. --> +<Package + xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" + xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" + xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5" + xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" + xmlns:desktop2="http://schemas.microsoft.com/appx/manifest/desktop/windows10/2" + xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"> + <!-- use single quotes to avoid double quotes escaping in the publisher value --> + <Identity Name="${identityName}" + ProcessorArchitecture="${arch}" + Publisher='${publisher}' + Version="${version}" /> + <Properties> + <DisplayName>${displayName}</DisplayName> + <PublisherDisplayName>${publisherDisplayName}</PublisherDisplayName> + <Description>${description}</Description> + <Logo>${logo}</Logo> + </Properties> + <Resources> + ${resourceLanguages} + </Resources> + <Dependencies> + <TargetDeviceFamily Name="Windows.Desktop" MinVersion="${minVersion}" MaxVersionTested="${maxVersionTested}" /> + </Dependencies> + ${capabilities} + <Applications> + <Application Id="${applicationId}" Executable="${executable}" EntryPoint="Windows.FullTrustApplication"> + <uap:VisualElements + BackgroundColor="${backgroundColor}" + DisplayName="${displayName}" + Square150x150Logo="${square150x150Logo}" + Square44x44Logo="${square44x44Logo}" + Description="${description}"> + ${lockScreen} + ${defaultTile} + ${splashScreen} + </uap:VisualElements> + ${extensions} + </Application> + </Applications> + <!-- The node accepts connections from peers (WebRTC, QUIC, casting). Rules + declared here are created by Windows at install with no administrator + prompt, follow the executable's versioned path on every update, and go + with the package. A rule the app made itself named that path and was + stale after the next update; the network capabilities create rules for + sandboxed apps only, which a full-trust process is not. Both measured + on a real install. --> + <Extensions> + <desktop2:Extension Category="windows.firewallRules"> + <desktop2:FirewallRules Executable="app\resources\node-runtime\meshbay-node.exe"> + <desktop2:Rule Direction="in" IPProtocol="TCP" Profile="all" /> + <desktop2:Rule Direction="in" IPProtocol="UDP" Profile="all" /> + </desktop2:FirewallRules> + </desktop2:Extension> + <desktop2:Extension Category="windows.firewallRules"> + <desktop2:FirewallRules Executable="app\resources\node-runtime\meshbay-nodew.exe"> + <desktop2:Rule Direction="in" IPProtocol="TCP" Profile="all" /> + <desktop2:Rule Direction="in" IPProtocol="UDP" Profile="all" /> + </desktop2:FirewallRules> + </desktop2:Extension> + </Extensions> +</Package> diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index f01b8f2..603dedc 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -1361,6 +1361,10 @@ function registerBridge() { // dialog over. function winEnsureNodeOnPath() { if (process.platform !== 'win32' || !hasBundledNode()) return; + // The Store package declares meshbay-node.exe as an execution alias, in a + // folder already on PATH and the same for every version; an entry added + // here would name the versioned install folder, deleted by the next update. + if (WIN_STORE) 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'); @@ -1428,6 +1432,54 @@ function registerBridge() { try { fs.rmSync(WIN_STARTUP_VBS, { force: true }); } catch { /* not there */ } } + // ── Windows, Store package: the startup task stands in for the launcher ── + // The .vbs names the node by its path, and in the Store package that path is + // C:\Program Files\WindowsApps\MeshBay.MeshBay_<version>_..., which every + // update deletes; right after sign-in Windows also refused the launcher the + // file outright ("Permission denied"). Both found on a real install. The + // package declares a startup task instead (build/appx-extensions.xml, off by + // default): Windows runs it, lists it in Settings > Apps > Startup, keeps its + // state across updates and removes it with the package. Only a process with + // the package's identity may switch it, and Windows gives that to the + // executables inside the package but not to one started from elsewhere: a + // powershell.exe run from here got "Element not found". The node's CLI is + // inside, so `autostart install | remove | status` does it, and its first + // line says "startup task <state>" (platform.startup_task). + const WIN_STORE = process.platform === 'win32' && Boolean(process.windowsStore); + + // The Node page asks for the status every couple of seconds, and each answer + // here is a process. The user can also switch the task in Windows Settings, + // so the answer is kept for a while, not for good. + const STARTUP_TASK_TTL_MS = 15000; + let startupTaskKnown = null; // { state, at } + + async function winStartupTask(sub) { + const r = await winNodeCli(['autostart', sub]); + const m = /^startup task (\S+)/m.exec(r.out); + if (m) startupTaskKnown = { state: m[1], at: Date.now() }; + if (!r.ok || !m) { + // The state line is for this file; the rest is the CLI's reason. + const why = r.out.replace(/^startup task \S+\s*/m, '').trim(); + throw new Error(why || r.out || 'the startup task did not answer'); + } + return m[1]; + } + + // Whether the node starts at sign-in: the startup task in the Store package, + // the Startup-folder launcher otherwise. + async function winSigninEnabled() { + if (!WIN_STORE) return winAutostartInstalled(); + if (startupTaskKnown && Date.now() - startupTaskKnown.at < STARTUP_TASK_TTL_MS) { + return startupTaskKnown.state.startsWith('Enabled'); + } + try { + return (await winStartupTask('status')).startsWith('Enabled'); + } catch (err) { + console.error('[node]', err.message); + return false; + } + } + // Windows: starting, stopping and restarting the node is the CLI's, and only // the CLI's (meshbay_node/cli/lifecycle.py) -- one implementation behind // every front door, the Node page, the tray, node:start and a terminal @@ -1459,7 +1511,8 @@ function registerBridge() { return r; } - // Every meshbay-node.exe running, in any session (tasklist lists session 0, + // Every node process running (meshbay-node.exe, and meshbay-nodew.exe, the + // build without a console the Store package's startup task runs), in any session (tasklist lists session 0, // where a service node runs). Whether a node is there is a question for the // process list as well as its control API: the API closes first on the way // down, and a node started some other way than the service task -- from a @@ -1467,7 +1520,7 @@ function registerBridge() { // Both made the Node page say "Stopped" about a node that was running. function winNodePids() { return new Promise((resolve) => { - execFile('tasklist', ['/FI', 'IMAGENAME eq meshbay-node.exe', '/NH', '/FO', 'CSV'], + execFile('tasklist', ['/FI', 'IMAGENAME eq meshbay-node*', '/NH', '/FO', 'CSV'], { windowsHide: true }, (err, stdout) => { if (err) return resolve([]); resolve(String(stdout || '').split(/\r?\n/) @@ -1521,7 +1574,7 @@ function registerBridge() { // boot task, the sign-in launcher, or neither -- "only while MeshBay is open". async function winStartupMode() { if ((await winServiceTaskStatus()).installed) return 'service'; - if (winAutostartInstalled()) return 'signin'; + if (await winSigninEnabled()) return 'signin'; return 'open'; } @@ -1688,7 +1741,7 @@ function registerBridge() { const [bin, svc] = await Promise.all([findNodeBinary(), winServiceTaskStatus()]); return { installed: Boolean(bin), - autostart: winAutostartInstalled(), + autostart: await winSigninEnabled(), service: svc.installed, }; } @@ -1728,7 +1781,7 @@ function registerBridge() { // check below, with an actionable error) -- correct but a dead click the // Node page should not offer in the first place. function winCanElevateServiceMode() { - return app.isPackaged + return app.isPackaged && !WIN_STORE && fs.existsSync(path.join(process.resourcesPath, 'service-mode.ps1')); } @@ -1761,7 +1814,8 @@ function registerBridge() { // gated on `installed`, so with no autostart configured they silently // vanished — the daemon was perfectly manageable, just not launchable // at sign-in. `autostart` carries that state as its own field instead. - const [{ activeState }, bin] = await Promise.all([winNodeActivity(), findNodeBinary()]); + const [{ activeState }, bin, autostart] = await Promise.all( + [winNodeActivity(), findNodeBinary(), winSigninEnabled()]); return { supported: true, // Only claim "startup mode" once a node was actually found (bundled @@ -1771,10 +1825,13 @@ function registerBridge() { // a string, so `null` here hides that whole row. mode: bin ? 'startup' : null, installed: Boolean(bin), - autostart: winAutostartInstalled(), + autostart, activeState, subState: activeState === 'active' ? 'running' : '', canElevate: winCanElevateServiceMode(), + // The Store package starts the node at sign-in at most: at boot is the + // .exe installer's (a boot task names the versioned WindowsApps path). + store: WIN_STORE, }; } if (process.platform !== 'linux') return { supported: false }; @@ -1857,7 +1914,8 @@ Its log: ${nodeLogHint()}`); return { restarted: true }; } - // Install / remove the Windows Startup-folder launcher, and query it. + // Install / remove the Windows Startup-folder launcher (the startup task in + // the Store package), and query it. handle('node:autostart', async (_e, action) => { if (process.platform !== 'win32') return { supported: false }; if (action === 'install') { @@ -1865,16 +1923,23 @@ Its log: ${nodeLogHint()}`); if ((await winServiceTaskStatus()).installed) { throw new Error('the node already runs as a background service'); } + if (WIN_STORE) { + // Refused, with the CLI's reason, when the user switched it off in + // Windows Settings: only they can switch it back on, there. + await winStartupTask('install'); + return { supported: true, installed: true }; + } const bin = await findNodeBinary(); if (!bin) throw new Error('meshbay-node not found on PATH'); winAutostartInstall(bin); return { supported: true, installed: true }; } if (action === 'remove') { - winAutostartRemove(); + if (WIN_STORE) await winStartupTask('remove'); + else winAutostartRemove(); return { supported: true, installed: false }; } - return { supported: true, installed: winAutostartInstalled() }; + return { supported: true, installed: await winSigninEnabled() }; }); // Turn service mode on or off after install — one elevation, task + firewall 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 f3c3b79..c512fe5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -942,6 +942,7 @@ export default { 'node.startup_mode_service': 'Als Hintergrunddienst (startet beim Booten)', 'node.startup_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…', 'node.startup_mode_service_unavailable_hint': 'Der Hintergrunddienst-Modus erfordert eine installierte Version. Führen Sie zum lokalen Testen "meshbay-node service install" in einer PowerShell mit Administratorrechten aus.', + 'node.startup_mode_service_store_hint': 'Der Start beim Hochfahren, bevor sich jemand anmeldet, erfordert das MeshBay-Installationsprogramm (.exe) statt der Version aus dem Microsoft Store.', '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 013a2a5..f5b3808 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1041,6 +1041,7 @@ export default { 'node.startup_mode_service': 'As a background service (starts at boot)', 'node.startup_mode_updating': 'Switching mode — check for an administrator prompt…', 'node.startup_mode_service_unavailable_hint': 'Background service mode needs an installed build. For local testing, run "meshbay-node service install" from an elevated PowerShell.', + 'node.startup_mode_service_store_hint': 'Starting at boot, before anyone signs in, needs the MeshBay installer (.exe) rather than the Microsoft Store version.', '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 284f454..6e152fb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -936,6 +936,7 @@ export default { 'node.startup_mode_service': 'Como servicio en segundo plano (se inicia al arrancar)', 'node.startup_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…', 'node.startup_mode_service_unavailable_hint': 'El modo de servicio en segundo plano requiere una versión instalada. Para pruebas locales, ejecute "meshbay-node service install" desde una PowerShell con privilegios de administrador.', + 'node.startup_mode_service_store_hint': 'Iniciar al arrancar, antes de que nadie inicie sesión, requiere el instalador de MeshBay (.exe) en lugar de la versión de Microsoft Store.', '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 4554ffb..40c7501 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -939,6 +939,7 @@ export default { 'node.startup_mode_service': 'Comme service en arrière-plan (démarre au boot)', 'node.startup_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…', 'node.startup_mode_service_unavailable_hint': 'Le mode service en arrière-plan nécessite une version installée. Pour un test local, exécutez "meshbay-node service install" depuis un PowerShell administrateur.', + 'node.startup_mode_service_store_hint': 'Démarrer au boot, avant toute ouverture de session, nécessite l\'installeur MeshBay (.exe) plutôt que la version du Microsoft Store.', '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 bc091e3..29a1f5d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -938,6 +938,7 @@ export default { 'node.startup_mode_service': 'Come servizio in background (si avvia all\'avvio del sistema)', 'node.startup_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…', 'node.startup_mode_service_unavailable_hint': 'La modalità servizio in background richiede una build installata. Per test locali, eseguire "meshbay-node service install" da un PowerShell con privilegi di amministratore.', + 'node.startup_mode_service_store_hint': 'L\'avvio all\'accensione, prima che qualcuno acceda, richiede il programma di installazione di MeshBay (.exe) anziché la versione del Microsoft Store.', '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 0522f4c..4cb4787 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -926,6 +926,7 @@ export default { 'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)', 'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…', 'node.startup_mode_service_unavailable_hint': 'バックグラウンドサービスモードにはインストール済みのビルドが必要です。ローカルでテストする場合は、管理者権限の PowerShell で "meshbay-node service install" を実行してください。', + 'node.startup_mode_service_store_hint': 'サインイン前の起動時に開始するには、Microsoft Store 版ではなく MeshBay インストーラー (.exe) が必要です。', '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 b258b04..926fadf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -940,6 +940,7 @@ export default { 'node.startup_mode_service': 'Als achtergrondservice (start bij het opstarten)', 'node.startup_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…', 'node.startup_mode_service_unavailable_hint': 'Achtergrondservice-modus vereist een geïnstalleerde build. Voer voor lokaal testen "meshbay-node service install" uit vanuit een PowerShell met beheerdersrechten.', + 'node.startup_mode_service_store_hint': 'Starten bij het opstarten, voordat iemand zich aanmeldt, vereist het MeshBay-installatieprogramma (.exe) in plaats van de Microsoft Store-versie.', '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 a750c2f..2f3a28c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -958,6 +958,7 @@ export default { 'node.startup_mode_service': 'Jako usługa w tle (uruchamia się przy starcie systemu)', 'node.startup_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…', 'node.startup_mode_service_unavailable_hint': 'Tryb usługi w tle wymaga zainstalowanej wersji. Aby przetestować lokalnie, uruchom "meshbay-node service install" w PowerShell z uprawnieniami administratora.', + 'node.startup_mode_service_store_hint': 'Uruchamianie przy starcie systemu, zanim ktokolwiek się zaloguje, wymaga instalatora MeshBay (.exe) zamiast wersji ze sklepu Microsoft Store.', '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 bd91cee..faacbf6 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 @@ -937,6 +937,7 @@ export default { 'node.startup_mode_service': 'Como serviço em segundo plano (inicia na inicialização)', 'node.startup_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…', 'node.startup_mode_service_unavailable_hint': 'O modo de serviço em segundo plano requer uma versão instalada. Para testes locais, execute "meshbay-node service install" em um PowerShell com privilégios de administrador.', + 'node.startup_mode_service_store_hint': 'Iniciar com o sistema, antes de qualquer login, exige o instalador do MeshBay (.exe) em vez da versão da Microsoft Store.', '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 d7b0aeb..232a4ca 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 @@ -914,6 +914,7 @@ export default { 'node.startup_mode_service': '作为后台服务(开机时启动)', 'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…', 'node.startup_mode_service_unavailable_hint': '后台服务模式需要已安装的版本。如需本地测试,请在具有管理员权限的 PowerShell 中运行 "meshbay-node service install"。', + 'node.startup_mode_service_store_hint': '在任何人登录之前随系统启动,需要使用 MeshBay 安装程序 (.exe),而不是 Microsoft Store 版本。', '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 04f4abc..11cd07e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -25,15 +25,20 @@ export function pairedFrom(result) { function NodeServicePanel({ onChanged, token, username }) { const [info, setInfo] = useState(null); const [busy, setBusy] = useState(''); - const [err, setErr] = useState(''); + // Two errors, not one: the status poll below cleared the message of an + // action that had just been refused, within five seconds and often before + // it was read (found switching startup mode on a real Store install). + const [pollErr, setPollErr] = useState(''); + const [actErr, setActErr] = useState(''); + const err = actErr || pollErr; const refresh = useCallback(async () => { try { const r = await platform.node.service.status(); setInfo(r); - setErr(''); + setPollErr(''); } catch (e) { - setErr(platform.bridgeMessage(e)); + setPollErr(platform.bridgeMessage(e)); } }, []); @@ -46,13 +51,13 @@ function NodeServicePanel({ onChanged, token, username }) { const act = useCallback(async (name, fn) => { setBusy(name); - setErr(''); + setActErr(''); try { await fn(); await refresh(); if (onChanged) onChanged(); } catch (e) { - setErr(platform.bridgeMessage(e)); + setActErr(platform.bridgeMessage(e)); } finally { setBusy(''); } @@ -162,7 +167,9 @@ function NodeServicePanel({ onChanged, token, username }) { ${busy === 'startupMode' && html` <p class="settings-hint">${t('node.startup_mode_updating')}</p>`} ${!info.canElevate && html` - <p class="settings-hint">${t('node.startup_mode_service_unavailable_hint')}</p>`} + <p class="settings-hint">${t(info.store + ? 'node.startup_mode_service_store_hint' + : 'node.startup_mode_service_unavailable_hint')}</p>`} `} </div>`; } diff --git a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py index b461055..1c9e026 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py +++ b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py @@ -179,6 +179,35 @@ def restart_daemon(args) -> None: return +# In the Store package the sign-in start is the package's startup task, which +# Windows lists in Settings > Apps > Startup (platform.startup_task). +STORE_SIGNIN = ("In MeshBay from the Microsoft Store, the node starts at sign-in through " + "the \"MeshBay Node\" entry of Windows Settings > Apps > Startup " + "(meshbay-node autostart install | remove).") + + +def _store_autostart(sub: str) -> None: + """autostart install | remove | status in the Store package. The first line + is "startup task <state>", which the desktop app reads.""" + from meshbay_node import platform as _plat + action = {"install": "enable", "remove": "disable", "status": "query"}[sub] + try: + state = _plat.startup_task(action) + except RuntimeError as e: + print(f"Could not reach {e}") + sys.exit(1) + print(f"startup task {state}") + if state == "DisabledByUser": + print("Switched off in Windows Settings > Apps > Startup (\"MeshBay Node\"): " + "only you can switch it back on, there.") + elif state.endswith("ByPolicy"): + print("Set by a policy on this computer.") + elif state == "Enabled": + print("meshbay-node starts at each sign-in (no window, no admin).") + if sub == "install" and not state.startswith("Enabled"): + sys.exit(1) + + def autostart(args) -> None: from meshbay_node import platform as _plat if sys.platform != "win32": @@ -186,6 +215,9 @@ def autostart(args) -> None: "'systemctl --user enable --now meshbay-node'.") sys.exit(1) sub = args.subcommand or "status" + if sub in ("install", "remove", "status") and _plat.in_store_package(): + _store_autostart(sub) + return service_mode = _plat.service_status()["installed"] if sub == "install": if service_mode: @@ -226,6 +258,11 @@ def service(args) -> None: "'systemctl --user enable --now meshbay-node'.") sys.exit(1) sub = args.subcommand or "status" + if sub == "install" and _plat.in_store_package(): + print("Starting the node at boot, before anyone signs in, needs MeshBay's .exe " + "installer: the Microsoft Store version starts it at sign-in at most. " + + STORE_SIGNIN) + sys.exit(1) if sub == "install": # A node already running in this session holds the control API's port: # the service's own would exit at once, leaving the old one in charge. diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py index c0e4d00..df29784 100644 --- a/packages/meshbay-node/src/meshbay_node/platform.py +++ b/packages/meshbay-node/src/meshbay_node/platform.py @@ -236,6 +236,141 @@ def autostart_supported() -> bool: return sys.platform == "win32" +STORE_PACKAGE_FAMILY_PREFIX = "MeshBay.MeshBay_" + + +def in_store_package() -> bool: + """Whether this process runs as part of MeshBay's Microsoft Store package. + + There, what starts the node at sign-in is the package's own startup task, + which Windows manages and the app switches on and off; a Startup-folder + launcher or a boot task would name the versioned WindowsApps path, which + each update deletes (and which Windows refused to a launcher right after + sign-in, found on a real install). The family name is checked, not merely + "some package": a process started from another packaged application -- a + developer's terminal inside one -- carries that application's identity. + """ + if sys.platform != "win32": + return False + import ctypes + length = ctypes.c_uint32(0) + kernel32 = ctypes.windll.kernel32 + if kernel32.GetCurrentPackageFamilyName(ctypes.byref(length), None) != 122: + return False # 15700: no package identity at all + buf = ctypes.create_unicode_buffer(length.value) + if kernel32.GetCurrentPackageFamilyName(ctypes.byref(length), buf) != 0: + return False + return buf.value.startswith(STORE_PACKAGE_FAMILY_PREFIX) + + +# The startup task the Store package declares (meshbay-client/build/ +# appx-extensions.xml), through Windows.ApplicationModel.StartupTask. Only a +# process with the package's identity may ask, and Windows gives it to the +# executables inside the package -- this one -- but not to one it starts from +# elsewhere: a powershell.exe the app ran for this got "Element not found". +# So the CLI does it, for the app and a terminal alike. ctypes over the WinRT +# ABI rather than a binding package: four calls do not justify a dependency. +STARTUP_TASK_ID = "MeshBayNodeStartup" +STARTUP_TASK_STATES = ("Disabled", "DisabledByUser", "Enabled", + "DisabledByPolicy", "EnabledByPolicy") +_IID_STARTUP_TASK_STATICS = "{EE5B60BD-A148-41A7-B26E-E8B88A1E62F8}" +_IID_ASYNC_INFO = "{00000036-0000-0000-C000-000000000046}" + + +def _winrt_method(obj, index: int, *argtypes): + """Method `index` of a COM/WinRT interface pointer. IUnknown takes 0-2 and + IInspectable 3-5, so an interface's own methods start at 6.""" + import ctypes + vtbl = ctypes.cast(obj, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p)))[0] + proto = ctypes.WINFUNCTYPE(ctypes.HRESULT, ctypes.c_void_p, *argtypes) + return lambda *args: proto(vtbl[index])(obj, *args) + + +def _winrt_release(obj) -> None: + import ctypes + if obj: + ctypes.WINFUNCTYPE(ctypes.c_ulong, ctypes.c_void_p)( + ctypes.cast(obj, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p)))[0][2])(obj) + + +def _winrt_await(op, result_type, timeout: float = 15.0): + """Wait for an IAsyncOperation<T> and return its result (T).""" + import ctypes + import time + import uuid + iid = (ctypes.c_byte * 16).from_buffer_copy(uuid.UUID(_IID_ASYNC_INFO).bytes_le) + info = ctypes.c_void_p() + _winrt_method(op, 0, ctypes.c_void_p, ctypes.c_void_p)(ctypes.byref(iid), ctypes.byref(info)) + try: + status = ctypes.c_int(0) + deadline = time.monotonic() + timeout + while True: + _winrt_method(info, 7, ctypes.POINTER(ctypes.c_int))(ctypes.byref(status)) + if status.value: # 1 Completed, 2 Canceled, 3 Error + break + if time.monotonic() > deadline: + raise RuntimeError("Windows did not answer about the startup task") + time.sleep(0.02) + if status.value != 1: + code = ctypes.c_long(0) + _winrt_method(info, 8, ctypes.POINTER(ctypes.c_long))(ctypes.byref(code)) + raise OSError(None, "the startup task", None, code.value) + finally: + _winrt_release(info) + result = result_type() + _winrt_method(op, 8, ctypes.POINTER(result_type))(ctypes.byref(result)) # GetResults + return result + + +def startup_task(action: str = "query") -> str: + """Query, enable or disable the Store package's startup task and return + its state, one of STARTUP_TASK_STATES. A task the user switched off in + Settings > Apps > Startup stays "DisabledByUser": Windows lets only the + user turn it back on, there. Raises RuntimeError outside the package.""" + if action not in ("query", "enable", "disable"): + raise ValueError(action) + if not in_store_package(): + raise RuntimeError("the startup task exists only in the Microsoft Store package") + import ctypes + import uuid + combase = ctypes.WinDLL("combase") + combase.RoGetActivationFactory.restype = ctypes.HRESULT # raises on failure + combase.RoInitialize(1) # multithreaded; already initialised is fine + name = "Windows.ApplicationModel.StartupTask" + class_id, task_id = ctypes.c_void_p(), ctypes.c_void_p() + combase.WindowsCreateString(ctypes.c_wchar_p(name), len(name), ctypes.byref(class_id)) + combase.WindowsCreateString(ctypes.c_wchar_p(STARTUP_TASK_ID), len(STARTUP_TASK_ID), + ctypes.byref(task_id)) + statics, op, task = ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_void_p() + try: + iid = (ctypes.c_byte * 16).from_buffer_copy(uuid.UUID(_IID_STARTUP_TASK_STATICS).bytes_le) + combase.RoGetActivationFactory(class_id, ctypes.byref(iid), ctypes.byref(statics)) + _winrt_method(statics, 7, ctypes.c_void_p, ctypes.c_void_p)( # GetAsync + task_id, ctypes.byref(op)) + task = _winrt_await(op, ctypes.c_void_p) + if action == "enable": + enable_op = ctypes.c_void_p() + _winrt_method(task, 6, ctypes.c_void_p)(ctypes.byref(enable_op)) # RequestEnableAsync + try: + _winrt_await(enable_op, ctypes.c_int) + finally: + _winrt_release(enable_op) + elif action == "disable": + _winrt_method(task, 7)() # Disable + state = ctypes.c_int(-1) + _winrt_method(task, 8, ctypes.POINTER(ctypes.c_int))(ctypes.byref(state)) # get_State + except OSError as e: + raise RuntimeError(f"the startup task: {e}") from e + finally: + for obj in (task, op, statics): + _winrt_release(obj) + combase.WindowsDeleteString(class_id) + combase.WindowsDeleteString(task_id) + if 0 <= state.value < len(STARTUP_TASK_STATES): + return STARTUP_TASK_STATES[state.value] + return f"unknown ({state.value})" + + def _startup_vbs() -> Path: base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") return (Path(base) / "Microsoft" / "Windows" / "Start Menu" / "Programs" @@ -366,7 +501,10 @@ def autostart_run() -> None: subprocess.Popen([exe], creationflags=0x00000200 | 0x08000000, close_fds=True) -NODE_IMAGE = "meshbay-node.exe" +# The daemon's image names: the console build, which is also every CLI verb, +# and the windowless one the Store package's startup task runs +# (packaging/win/meshbay-node.spec). A node is a node whichever started it. +NODE_IMAGES = ("meshbay-node.exe", "meshbay-nodew.exe") def autostart_end() -> None: @@ -391,15 +529,17 @@ def autostart_end() -> None: """ if not autostart_supported(): return - argv = ["taskkill", "/F", "/T", "/IM", NODE_IMAGE] + argv = ["taskkill", "/F", "/T"] + for image in NODE_IMAGES: + argv += ["/IM", image] for pid in {os.getpid(), os.getppid()}: argv += ["/FI", f"PID ne {pid}"] subprocess.run(argv, capture_output=True) def node_pids() -> list[int]: - """Every meshbay-node.exe running, in any session, except this process and - its parent (the CLI is meshbay-node.exe too). Empty off Windows. + """Every node process running (NODE_IMAGES), in any session, except this + process and its parent (the CLI is meshbay-node.exe too). Empty off Windows. What says whether a node is still there after a stop: its control API closes first, so a process that has not exited yet answers nothing and @@ -407,13 +547,15 @@ def node_pids() -> list[int]: """ if sys.platform != "win32": return [] - r = subprocess.run(["tasklist", "/FI", f"IMAGENAME eq {NODE_IMAGE}", "/NH", "/FO", "CSV"], - capture_output=True, text=True) + # One filter cannot name two images, so the whole list, filtered here. + r = subprocess.run(["tasklist", "/NH", "/FO", "CSV"], capture_output=True, text=True) + images = {i.lower() for i in NODE_IMAGES} mine = {os.getpid(), os.getppid()} pids = [] for line in r.stdout.splitlines(): cells = [c.strip('"') for c in line.split('","')] - if len(cells) > 1 and cells[1].isdigit() and int(cells[1]) not in mine: + if (len(cells) > 1 and cells[0].lower() in images and cells[1].isdigit() + and int(cells[1]) not in mine): pids.append(int(cells[1])) return pids diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index b5f6191..6f6388e 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -1263,35 +1263,41 @@ def test_create_group_page_falls_back_when_no_node_is_bundled(): # ------------------------------------------------------------------------ -# The "MSIX" target: same feature set as Full, packaged for Microsoft Store -# submission instead of NSIS. Unlike Light, this target keeps the node -# runtime and both service scripts -- what changes is packaging format, not -# what ships. +# The "MSIX" target: the bundled node, packaged for Microsoft Store +# submission instead of NSIS. What the NSIS build does with scripts (firewall +# rules, a sign-in launcher, a PATH entry) this package declares in its +# manifest, because everything a script writes names the versioned install +# folder each Store update deletes. At boot is the .exe installer's alone. # 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(): +def _yml_from_entries(yml: str) -> list[str]: + """The `from:` of every extraResources entry: directives, not the prose + around them, which names the very scripts it explains are absent.""" + return [ln.split("from:", 1)[1].strip() for ln in yml.splitlines() + if ln.strip().startswith("- from:")] + + +def test_msix_config_is_standalone_and_ships_the_node_not_the_scripts(): """ - Unlike Light, MSIX ships the same node-runtime/ffmpeg/service scripts as - Full -- an AppX install never elevating 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. + --config 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. It ships the node runtime and none of the scripts + whose output names the install folder: on a real Store install each of + them was stale after the next update. """ 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 + sources = _yml_from_entries(yml) + assert "node-runtime" in sources + for script in ("firewall.ps1", "service.ps1", "service-mode.ps1", "ensure-node-path.ps1"): + assert not any(src.endswith(script) for src in sources), ( + f"{script} must not ship in the Store package (see this test's docstring)") def test_msix_identity_matches_the_partner_center_reservation(): @@ -1333,51 +1339,100 @@ def test_msix_declares_no_csc_on_purpose(): assert forbidden not in yml -def test_msix_declares_the_network_capabilities_firewall_ps1_would_add(): +MSIX_MANIFEST_XML = CLIENT / "build" / "appx-manifest.xml" + + +def test_msix_declares_the_firewall_rules_of_both_node_executables(): """ - Matches firewall.ps1's own rules, which are `-Profile Any` (private AND - public network). 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. + Declared rules are created at install without an administrator prompt, + follow the executable's versioned path on every update and go with the + package (all three measured on a real install). The capabilities + internetClientServer / privateNetworkClientServer were here before and + covered nothing: they make rules for sandboxed apps, which a full-trust + process is not, and a listener got the Windows prompt regardless. """ 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 + assert "customManifestPath: build/appx-manifest.xml" in yml + assert MSIX_MANIFEST_XML.exists(), f"{MSIX_MANIFEST_XML} is missing" + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + package_level = xml.split("</Applications>", 1)[1] + for exe in ("meshbay-node.exe", "meshbay-nodew.exe"): + block = package_level.split(f'Executable="app\\resources\\node-runtime\\{exe}"', 1) + assert len(block) == 2, f"no firewall rules for {exe}" + rules = block[1].split("</desktop2:FirewallRules>", 1)[0] + for proto in ("TCP", "UDP"): + assert f'Direction="in" IPProtocol="{proto}" Profile="all"' in rules, (exe, proto) + assert 'Category="windows.firewallRules"' in package_level + assert 'xmlns:desktop2="http://schemas.microsoft.com/appx/manifest/desktop/windows10/2"' in xml -def test_msix_startup_task_targets_the_node_not_the_electron_shell(): +def test_msix_manifest_keeps_every_macro_of_electron_builders_template(): + """AppxTarget.js fills the custom template the way it fills its own. One + dropped from ours would leave a field electron-builder computes (identity, + version, languages) out; one it does not know fails the build, comments + included ("Macro macros is not defined", from a comment that said so).""" + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + stock = CLIENT / "node_modules" / "app-builder-lib" / "templates" / "appx" / "appxmanifest.xml" + if not stock.exists(): + pytest.skip("electron-builder is not installed (npm ci in packages/meshbay-client)") + macros = set(re.findall(r"\$\{([a-zA-Z0-9]+)\}", stock.read_text(encoding="utf-8"))) + assert macros == set(re.findall(r"\$\{([a-zA-Z0-9]+)\}", xml)) + + +def test_msix_manifest_fragments_are_valid_xml_comments_included(): + """makeappx refuses a manifest with "--" inside a comment ('>' expected), + and only at the very end of a build.""" + for path in (MSIX_MANIFEST_XML, MSIX_EXTENSIONS_XML): + for comment in re.findall(r"<!--(.*?)-->", path.read_text(encoding="utf-8"), re.S): + assert "--" not in comment, (path.name, comment[:60]) + + +def test_msix_startup_task_is_the_windowless_node_and_off_by_default(): """ - 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. + Windows runs a startup task's executable as is: the console build opened + a window whose close button stopped the node, so the task runs + meshbay-nodew.exe. Off by default -- it was on, and started the node at + every sign-in whatever mode the Node page said; the app switches it now. + Not addAutoLaunchExtension, which always starts the Electron shell. """ 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 not any(ln.startswith("addAutoLaunchExtension:") for ln in lines) assert "customExtensionsPath: build/appx-extensions.xml" in yml + ext = MSIX_EXTENSIONS_XML.read_text(encoding="utf-8") + task = ext.split('Category="windows.startupTask"', 1)[1].split("</desktop:Extension>", 1)[0] + assert 'Executable="app\\resources\\node-runtime\\meshbay-nodew.exe"' in task + assert 'TaskId="MeshBayNodeStartup"' in task and 'Enabled="false"' in task + # The id the CLI asks Windows for. + from meshbay_node import platform as plat + assert 'TaskId="' + plat.STARTUP_TASK_ID + '"' in task - assert MSIX_EXTENSIONS_XML.exists(), f"{MSIX_EXTENSIONS_XML} is missing" + +def test_msix_gives_the_cli_an_execution_alias_and_the_windows_it_needs(): + """`meshbay-node` in a terminal: an alias keeps one path across versions, + where the PATH entry the app used to add named the install folder. + Aliases need Windows 10 1709; the declared minimum is 1809.""" 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 + alias = ext.split('Category="windows.appExecutionAlias"', 1)[1] + assert 'Executable="app\\resources\\node-runtime\\meshbay-node.exe"' in alias + assert 'Alias="meshbay-node.exe"' in alias + yml = MSIX_YML.read_text(encoding="utf-8") + assert "minVersion: 10.0.17763.0" in yml + xml = MSIX_MANIFEST_XML.read_text(encoding="utf-8") + assert 'xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5"' in xml + + +def test_the_node_runtime_has_a_windowless_daemon_beside_the_cli(): + spec = (WIN / "meshbay-node.spec").read_text(encoding="utf-8") + windowless = spec.split('name="meshbay-nodew"', 1) + assert len(windowless) == 2, "meshbay-node.spec must build meshbay-nodew.exe" + assert "console=False" in windowless[1].split(")", 1)[0] + coll = spec.split("COLLECT(", 1)[1].split(")", 1)[0] + assert "exe_windowless" in coll, "both executables share one _internal/" + assert '"meshbay-nodew.exe"' in (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8") + # Started with no stdio at all, it must not die on a library's write. + entry = (WIN / "node-entry.py").read_text(encoding="utf-8") + assert "os.devnull" in entry and entry.index("os.devnull") < entry.index("import main") def test_msix_ships_the_four_required_tile_images(): @@ -1430,21 +1485,49 @@ def test_build_win_msix_points_electron_builder_at_the_system_sdk(): 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. +# ── main.js in the Store package ────────────────────────────────────────── + +def test_main_js_switches_the_startup_task_in_the_store_package(): + """In the Store package "at sign-in" is the package's startup task, read + and switched through the node's CLI, which has the package's identity + where a powershell.exe the app started had none ("Element not found"). + Never the Startup-folder .vbs, whose path every update deletes.""" + src = MAIN_JS.read_text(encoding="utf-8") + assert "const WIN_STORE = process.platform === 'win32' && Boolean(process.windowsStore);" in src + task_fn = _fn_body(src, "async function winStartupTask(sub)") + assert "winNodeCli(['autostart', sub])" in task_fn + assert "powershell" not in task_fn.lower() + assert "STARTUP_TASK_TTL_MS" in _fn_body(src, "async function winSigninEnabled()"), ( + "the Node page polls; each uncached answer is a process") + signin = _fn_body(src, "async function winSigninEnabled()") + assert "if (!WIN_STORE) return winAutostartInstalled();" in signin + assert "winStartupTask('status')" in signin + assert "await winSigninEnabled()" in _fn_body(src, "async function winStartupMode()") + handler = src.split("handle('node:autostart'", 1)[1].split("\n });\n", 1)[0] + assert "winStartupTask('install')" in handler and "winStartupTask('remove')" in handler + # Every reader of the sign-in state asks the same function. + callers = [ln for ln in src.splitlines() if "winAutostartInstalled()" in ln + and not ln.strip().startswith("//") and "function winAutostartInstalled" not in ln] + assert len(callers) == 1, callers + + +def test_main_js_offers_no_service_mode_and_adds_no_path_entry_in_the_store_package(): + src = MAIN_JS.read_text(encoding="utf-8") + assert "!WIN_STORE" in _fn_body(src, "function winCanElevateServiceMode()") + status = _fn_body(src, "async function nodeServiceStatus()") + assert "store: WIN_STORE" in status + path_fn = src.split("function winEnsureNodeOnPath()", 1)[1].split("\n }", 1)[0] + assert "if (WIN_STORE) return;" in path_fn + page = (HUB_STATIC / "node-page.js").read_text(encoding="utf-8") + assert "info.store" in page and "node.startup_mode_service_store_hint" in page + + +def test_main_js_counts_the_windowless_daemon_as_a_node(): + """A node started at sign-in by the startup task is meshbay-nodew.exe: + Stop, the status and Quit must see it as they see meshbay-node.exe.""" + src = MAIN_JS.read_text(encoding="utf-8") + pids = _fn_body(src, "function winNodePids()") + assert "'IMAGENAME eq meshbay-node*'" in pids # ------------------------------------------------------------------------ @@ -1479,13 +1562,14 @@ def test_ensure_node_path_script_is_idempotent_and_unelevated(): "installer.nsh's own SendMessage") -def test_ensure_node_path_shipped_to_full_and_msix_not_light(): +def test_ensure_node_path_shipped_to_full_only(): 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 + msix_sources = _yml_from_entries(MSIX_YML.read_text(encoding="utf-8")) + assert not any(src.endswith("ensure-node-path.ps1") for src in msix_sources), ( + "the Store package has an execution alias instead") light_yml = LIGHT_YML.read_text(encoding="utf-8") assert "ensure-node-path.ps1" not in light_yml, ( diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py index 4805d8a..d9ce75c 100644 --- a/packages/meshbay-node/tests/test_platform.py +++ b/packages/meshbay-node/tests/test_platform.py @@ -301,7 +301,7 @@ def test_the_forced_stop_really_spares_its_caller(tmp_path): script = textwrap.dedent(f""" import sys; sys.path.insert(0, {str(src)!r}) from meshbay_node import platform as p - p.NODE_IMAGE = {image!r} + p.NODE_IMAGES = ({image!r},) p.autostart_end() print("survived") """) diff --git a/packages/meshbay-node/tests/test_windows_node_lifecycle.py b/packages/meshbay-node/tests/test_windows_node_lifecycle.py index 241f583..b85b6ea 100644 --- a/packages/meshbay-node/tests/test_windows_node_lifecycle.py +++ b/packages/meshbay-node/tests/test_windows_node_lifecycle.py @@ -173,7 +173,7 @@ def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch): shutil.copy(r"C:\Windows\System32\PING.EXE", exe) proc = subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - monkeypatch.setattr(plat, "NODE_IMAGE", "mbpidtest.exe") + monkeypatch.setattr(plat, "NODE_IMAGES", ("mbpidtest.exe",)) try: assert plat.node_pids() == [proc.pid] plat.kill_pid(proc.pid) @@ -184,6 +184,110 @@ def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch): proc.kill() +@pytest.mark.skipif(sys.platform != "win32", reason="lists and kills real processes") +def test_node_pids_counts_the_windowless_daemon_too(tmp_path, monkeypatch): + """The Store package's startup task runs meshbay-nodew.exe: a Stop that + looked for meshbay-node.exe alone would call that node gone.""" + import shutil + procs = [] + for name in ("mbpidtest.exe", "mbpidtestw.exe"): + shutil.copy(r"C:\Windows\System32\PING.EXE", tmp_path / name) + procs.append(subprocess.Popen([str(tmp_path / name), "-n", "300", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + monkeypatch.setattr(plat, "NODE_IMAGES", ("mbpidtest.exe", "mbpidtestw.exe")) + try: + assert sorted(plat.node_pids()) == sorted(p.pid for p in procs) + finally: + for p in procs: + p.kill() + assert set(plat.NODE_IMAGES) == {"mbpidtest.exe", "mbpidtestw.exe"} + monkeypatch.undo() + assert plat.NODE_IMAGES == ("meshbay-node.exe", "meshbay-nodew.exe") + + +def _current_package_family() -> str: + import ctypes + length = ctypes.c_uint32(0) + k32 = ctypes.windll.kernel32 + if k32.GetCurrentPackageFamilyName(ctypes.byref(length), None) != 122: + return "" + buf = ctypes.create_unicode_buffer(length.value) + k32.GetCurrentPackageFamilyName(ctypes.byref(length), buf) + return buf.value + + +@pytest.mark.skipif(sys.platform != "win32", reason="package identity is Windows'") +def test_only_meshbays_own_package_counts_as_the_store_install(monkeypatch): + """A terminal inside another packaged application (an IDE or an agent + from the Store) gives its processes that application's identity, and the + test suite itself may run in one: that is not MeshBay's package.""" + assert plat.in_store_package() is False + family = _current_package_family() + if family: + monkeypatch.setattr(plat, "STORE_PACKAGE_FAMILY_PREFIX", family.split("_")[0] + "_") + assert plat.in_store_package() is True + + +def test_the_store_package_switches_its_startup_task_not_a_launcher(monkeypatch, capsys): + """There a Startup-folder launcher or a boot task would name the versioned + WindowsApps path each update deletes (and right after sign-in Windows + refused the launcher that path): `autostart` switches the package's + startup task, and says so on a first line the desktop app reads.""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(plat, "in_store_package", lambda: True) + monkeypatch.setattr(plat, "autostart_install", lambda *a: pytest.fail("wrote a launcher")) + monkeypatch.setattr(plat, "autostart_remove", lambda *a: pytest.fail("removed a launcher")) + monkeypatch.setattr(plat, "service_install", lambda *a: pytest.fail("made a boot task")) + monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""}) + asked = [] + answers = {"enable": "Enabled", "disable": "Disabled", "query": "Disabled"} + monkeypatch.setattr(plat, "startup_task", lambda a: asked.append(a) or answers[a]) + + class Args: + config = None + subcommand = "install" + for sub, action, state in (("install", "enable", "Enabled"), ("remove", "disable", "Disabled"), + ("status", "query", "Disabled")): + Args.subcommand = sub + lifecycle.autostart(Args) + assert asked[-1] == action + assert capsys.readouterr().out.splitlines()[0] == f"startup task {state}" + + # Switched off by the user in Windows Settings: theirs to switch back on. + answers["enable"] = "DisabledByUser" + Args.subcommand = "install" + with pytest.raises(SystemExit) as e: + lifecycle.autostart(Args) + assert e.value.code == 1 + assert "Settings > Apps > Startup" in capsys.readouterr().out + + # At boot is the .exe installer's. + with pytest.raises(SystemExit) as e: + lifecycle.service(Args) + assert e.value.code == 1 + assert ".exe" in capsys.readouterr().out + + +def test_a_refused_action_stays_on_the_node_page_until_the_next_action(): + """The status poll (every five seconds) cleared the one error state there + was, so a refusal vanished before it was read.""" + page = _js(STATIC / "node-page.js") + panel = page.split("function NodeServicePanel(", 1)[1].split("\nfunction ", 1)[0] + refresh = panel.split("const refresh = useCallback(", 1)[1].split("}, []);", 1)[0] + act = panel.split("const act = useCallback(", 1)[1].split("}, [", 1)[0] + assert "setActErr" not in refresh and "setPollErr('')" in refresh + assert "setActErr(platform.bridgeMessage(e))" in act + assert "const err = actErr || pollErr;" in panel + + +def test_the_startup_task_is_refused_outside_the_store_package(monkeypatch): + monkeypatch.setattr(plat, "in_store_package", lambda: False) + with pytest.raises(RuntimeError, match="Microsoft Store package"): + plat.startup_task("query") + with pytest.raises(ValueError): + plat.startup_task("toggle") + + # ── the desktop application: what it says, and when it sets the node up ────── def test_the_node_page_asks_the_node_not_only_the_task(): diff --git a/packaging/win/README.md b/packaging/win/README.md index b84ba98..eda728e 100644 --- a/packaging/win/README.md +++ b/packaging/win/README.md @@ -15,7 +15,9 @@ Linux). `meshbay-common` rides along inside the node runtime. │ ├─ service.ps1 install/remove/status/run/end the boot-time task │ ├─ service-mode.ps1 elevated helper: service.ps1 + firewall.ps1 in one UAC prompt │ └─ node-runtime\ -│ ├─ meshbay-node.exe frozen daemon (PyInstaller onedir) +│ ├─ meshbay-node.exe frozen daemon and CLI (PyInstaller onedir) +│ ├─ meshbay-nodew.exe the same daemon without a console (the Store +│ │ package's startup task runs it) │ ├─ _internal\ … its Python + deps (aiortc, av, aioquic, …) │ ├─ ffmpeg.exe, ffprobe.exe bundled by default, see Video (ffmpeg) below │ ├─ av*.dll, swscale/swresample*.dll what those two link against diff --git a/packaging/win/build-node-runtime.ps1 b/packaging/win/build-node-runtime.ps1 index 42d1faa..aa5e759 100644 --- a/packaging/win/build-node-runtime.ps1 +++ b/packaging/win/build-node-runtime.ps1 @@ -105,8 +105,10 @@ finally { } $frozen = Join-Path $pyiDist "meshbay-node" -if (-not (Test-Path (Join-Path $frozen "meshbay-node.exe"))) { - throw "PyInstaller did not produce meshbay-node.exe at $frozen" +foreach ($name in "meshbay-node.exe", "meshbay-nodew.exe") { + if (-not (Test-Path (Join-Path $frozen $name))) { + throw "PyInstaller did not produce $name at $frozen" + } } # --- 3b. licences ---------------------------------------------------- diff --git a/packaging/win/electron-builder.msix.yml b/packaging/win/electron-builder.msix.yml index d3adad6..577e039 100644 --- a/packaging/win/electron-builder.msix.yml +++ b/packaging/win/electron-builder.msix.yml @@ -1,8 +1,22 @@ -# Standalone electron-builder config for the "MSIX" Windows target: same -# feature set as Full (bundled node + ffmpeg), packaged for Microsoft Store -# submission instead of NSIS. The package carries none of installer.nsh's -# elevation logic: an AppX/MSIX install never elevates, by design. What is -# still unverified here is called out at each declaration below. +# Standalone electron-builder config for the "MSIX" Windows target: the +# bundled node + ffmpeg, packaged for Microsoft Store submission instead of +# NSIS. +# +# What the NSIS build does with scripts -- firewall rules, a Startup-folder +# launcher, a boot task, a PATH entry -- this package DECLARES in its manifest, +# and Windows creates it at install, carries it across updates and removes it +# with the package, all without an administrator prompt. Scripts could not do +# that job here: everything they write names the install folder, and a Store +# install lives in C:\Program Files\WindowsApps\MeshBay.MeshBay_<version>_..., +# which each update deletes. Measured on a real install (2026-10-08): +# - firewall: build/appx-manifest.xml declares the node's rules; +# - at sign-in: build/appx-extensions.xml declares a startup task, off by +# default, that the node's CLI switches (`meshbay-node autostart`), +# for the app's Node page and a terminal alike; +# - `meshbay-node` in a terminal: an execution alias, also in that file; +# - at boot, before anyone signs in: not offered. It needs a boot task, +# created elevated and left behind on uninstall; that is the .exe +# installer's job. # # Deliberately NOT layered onto package.json's `build` field, same reasoning # as electron-builder.light.yml: --config reads ONLY this file, so nothing @@ -42,33 +56,15 @@ win: icon: build/icon.ico artifactName: "MeshBay-${version}.${ext}" extraResources: - # Same bundle as Full (package.json's own build.win.extraResources) -- - # this target drops NSIS, not the node/ffmpeg runtime or the two service - # scripts. service-mode.ps1 in particular still works unmodified: it is - # invoked from main.js's winElevateServiceMode() on demand, from the - # running (unelevated) app, not from an installer step -- that path - # already existed before this target did (see "the other door" comment - # in src/main.js) and needs nothing new here beyond the file being - # present to find. + # The node runtime as Full ships it, both executables: meshbay-node.exe + # (CLI, and what the app starts) and meshbay-nodew.exe (no console, for + # the startup task). No scripts: firewall.ps1, the service scripts and + # ensure-node-path.ps1 are the manifest's job here (see the top of this + # file), and main.js offers no service mode without service-mode.ps1. - from: node-runtime to: node-runtime filter: - "**/*" - - from: ../../packaging/win/firewall.ps1 - to: firewall.ps1 - - from: ../../packaging/win/service.ps1 - 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 # The application's own licence, beside the app (Electron's LICENSE and # LICENSES.chromium.html are put next to the exe by electron-builder). - from: ../../LICENSE @@ -99,26 +95,14 @@ appx: - it-IT - nl-NL - pl-PL - # Both are the "common" (general, non-restricted) capability group per - # app-builder-lib's AppxCapabilities.js -- no special Store justification - # needed, unlike the `rescap`-namespaced restricted ones. Matches - # firewall.ps1's own rules, which are `-Profile Any` (private AND public - # network). Whether Windows Firewall actually auto-exempts a full-trust - # packaged app on the strength of these declarations -- eliminating the - # elevation firewall.ps1 exists for entirely -- is an open item: verify - # live before relying on it, the declaration alone only proves the - # manifest is well-formed. - capabilities: - - internetClientServer - - privateNetworkClientServer - # windows.startupTask, the MSIX-native equivalent of the NSIS "at sign in" - # mode's Startup-folder .vbs (main.js's WIN_STARTUP_VBS) -- but pointed at - # the node daemon, not the Electron shell, which is what `addAutoLaunchExtension: - # true` would do instead (it always targets the package's own main - # executable, per AppxTarget.js's `executable` macro -- there is no config - # switch to point it at a different bundled exe). build/appx-extensions.xml - # declares that extension by hand for exactly this reason. Whether - # Windows actually launches a *non-primary* bundled exe through this - # mechanism is the other open item -- untested until sideloaded. + # Execution aliases need Windows 10 1709; 1809 is the oldest still + # serviced. electron-builder's default (10.0.14316.0, for both) predates both. + minVersion: 10.0.17763.0 + maxVersionTested: 10.0.26100.0 + # The stock template plus the package-level firewall rules. + customManifestPath: build/appx-manifest.xml + # The startup task and the execution alias. Not addAutoLaunchExtension: + # that one always starts the package's main executable, the Electron shell, + # where "at sign-in" means the node. customExtensionsPath: build/appx-extensions.xml showNameOnTiles: false diff --git a/packaging/win/meshbay-node.spec b/packaging/win/meshbay-node.spec index 3261778..a1173a2 100644 --- a/packaging/win/meshbay-node.spec +++ b/packaging/win/meshbay-node.spec @@ -153,8 +153,33 @@ exe = EXE( icon="../../packages/meshbay-client/build/icon.ico", version=_version_info, ) +# The same daemon without a console, for what starts it with nobody watching: +# the Store package's startup task runs its executable as is, and a console +# executable opened a window whose close button killed the node. The CLI stays +# meshbay-node.exe, which has to print. Same entry point and archive, same +# _internal\ beside both (pythonw.exe beside python.exe). +exe_windowless = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="meshbay-nodew", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon="../../packages/meshbay-client/build/icon.ico", + version=_version_info, +) coll = COLLECT( exe, + exe_windowless, a.binaries, a.datas, strip=False, diff --git a/packaging/win/node-entry.py b/packaging/win/node-entry.py index 6fadad7..a502a37 100644 --- a/packaging/win/node-entry.py +++ b/packaging/win/node-entry.py @@ -1,12 +1,23 @@ """PyInstaller entry point for the bundled Windows node daemon. `meshbay_node.daemon:main` is a module function; PyInstaller freezes a script. -This is that script — nothing more. The frozen binary is `meshbay-node.exe`, +This is that script. The frozen binary is `meshbay-node.exe`, relocatable, and is what the desktop client spawns and what the W3 autostart -launcher points at (`meshbay_node.platform._node_exe`). +launcher points at (`meshbay_node.platform._node_exe`). `meshbay-nodew.exe` is +the same script built without a console (meshbay-node.spec). """ -from meshbay_node.daemon import main +import os +import sys + +# meshbay-nodew.exe, the build without a console, starts with no stdio at all +# (sys.stdout and sys.stderr are None), and a library that writes to either or +# asks whether it is a terminal would raise. The daemon logs to node.log. +for _name in ("stdout", "stderr"): + if getattr(sys, _name) is None: + setattr(sys, _name, open(os.devnull, "w", encoding="utf-8")) + +from meshbay_node.daemon import main # noqa: E402 if __name__ == "__main__": main() |