aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-09 18:19:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 09:57:29 +0200
commit42b562fcf312ddceb78438b5daea49d4c9b465c8 (patch)
tree770913e96292b4c5123f8c5ecba2d62577307f67 /packages/meshbay-client
parent25152ddb61a89ea3ea29d3aef5de13343429d32a (diff)
downloadmeshbay-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>
Diffstat (limited to 'packages/meshbay-client')
-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
3 files changed, 157 insertions, 12 deletions
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