aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/PACKAGING-GUIDE.md21
-rw-r--r--docs/WINDOWS-PORT.md6
-rw-r--r--packages/meshbay-client/build/appx-extensions.xml16
-rw-r--r--packages/meshbay-client/build/appx-manifest.xml68
-rw-r--r--packages/meshbay-client/src/main.js85
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js19
-rw-r--r--packages/meshbay-node/src/meshbay_node/cli/lifecycle.py37
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py156
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py220
-rw-r--r--packages/meshbay-node/tests/test_platform.py2
-rw-r--r--packages/meshbay-node/tests/test_windows_node_lifecycle.py106
-rw-r--r--packaging/win/README.md4
-rw-r--r--packaging/win/build-node-runtime.ps16
-rw-r--r--packaging/win/electron-builder.msix.yml82
-rw-r--r--packaging/win/meshbay-node.spec25
-rw-r--r--packaging/win/node-entry.py17
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()