aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 09:42:34 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 09:42:34 +0200
commit30e855f55f1d920b25da0bdd8e538c249d3c0c26 (patch)
tree587dcad0beb8a120352613be8aa751a97040015c /packages/meshbay-hub/src
parent768e07046368819b8a8f15c8b21e5a8bbfcdf282 (diff)
downloadmeshbay-30e855f55f1d920b25da0bdd8e538c249d3c0c26.tar.gz
feat(client): the platform seam, and an Electron shell that has never been run
Stage D, and the honest half of it. D1 — the seam (done, and verified) ---------------------------------- `static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is built with the same base, so one address has one source. In a browser it returns '' and every path stays relative to the origin that served the page — the acceptance criterion for this split was "the browser SPA behaves identically", and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the content hash and a cached browser would never ask for it. D2 — the shell (written, never launched) ----------------------------------------- **There is no npm on this machine. Electron was never installed and `packages/meshbay-client/` has not been run once.** That is stated here rather than discovered later. What is there: a main process serving the packaged interface over a privileged `app://` scheme (`secure` and `standard` are not cosmetic — without them the service worker refuses to register and streamed downloads break silently), a preload exposing an enumerated bridge that never passes a filesystem path, a window with `sandbox`, `contextIsolation` and no node integration, navigation away from the package refused, and a CSP where the hub is reachable over connect-src and is not a script source. The hub address arrives as a process argument because `platform.hubBase()` runs before anything can await. `test_desktop_shell.py` pins each of those by reading the source — the treatment `test_downloads.py` already gives the three browser save paths. It catches a property being removed and proves nothing about the application running. Two were checked by breaking them. The interface is *copied* into the package by `build/sync-ui.js` from the hub's static directory, and `ui/` is gitignored: a silent fork is the only real way to end up maintaining the interface twice. D3 — partial ------------ The bridge, and the part worth having now: safeStorage's backend is reported rather than assumed. On Linux it falls back to a fixed key when no keyring is running, silently — someone who believes the OS is holding their keys is told when it is not. The native key lifecycle belongs with D4 and needs a running application to mean anything. D8 — partial, and a real defect found -------------------------------------- `meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` — into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry `User=`; systemd refuses the file, so the packaged unit could never have started. Nothing noticed because nobody had built and installed the RPM. Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service` that a person enables themselves without a password — which is what lets the desktop client install a node without asking for one. It carries ExecReload, so `meshbay-node reload` does not have to stop a service somebody is streaming from, and documents the drop-in for a drive outside the home, RequiresMountsFor included. 798 tests pass; e2e.py still passes end to end. Nothing here was built or launched: no npm, no rpmbuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js103
13 files changed, 169 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 392e8d1..7028071 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -23,8 +23,12 @@ router = APIRouter(tags=["webapp"])
# Assets the shell pulls in, in load order. Everything else is imported by
# app.js and rides on the same query string via window.__MB_ASSET_V.
+# Every module the page loads. A file missing from here is a file whose change
+# does not move the URL, so a browser holding the old one never asks for it —
+# which is the failure this list exists to prevent, and it is silent.
_ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
- "i18n.js", "downloads.js", "transfers.js", "zipstream.js")
+ "i18n.js", "downloads.js", "transfers.js", "zipstream.js",
+ "platform.js")
def _asset_version() -> str:
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index a48535a..abb3374 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -6,10 +6,15 @@ import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
import { transfers, formatSpeed } from './transfers.js';
import * as downloads from './downloads.js';
+import * as platform from './platform.js';
// ── Constants ────────────────────────────────────────────────────────────────
-const HUB = '';
+// Where the hub is. Empty in a browser — it served this page, so a relative
+// path cannot be pointed at the wrong place. In the installed app the page
+// comes from disk and has no origin of its own, so the base is configured.
+// See platform.js.
+const HUB = platform.hubBase();
const AUTH_KEY = 'mb_auth';
// Renew an access token with this much life left rather than waiting for it to
// fail. Generous against a one-hour token: a film is watched without the hub
@@ -1323,7 +1328,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
// connection at all. Renewals are shared, so if one is already in
// flight this waits for it instead of starting a second.
const live = (await ensureFreshToken()) || token;
- const transport = new window.MeshBayTransport('', live);
+ // The same base the API calls use: signaling is a hub endpoint like
+ // any other, and two sources for one address is how they drift.
+ const transport = new window.MeshBayTransport(HUB, live);
transportRef.current = transport;
const ack = await transport.connect(
@@ -3941,6 +3948,15 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
// Read from the hub rather than written here: the two constants that used to
// sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on.
const [hubInfo, setHubInfo] = useState(null);
+ // On a desktop build, whether the OS is really holding the keys. Electron's
+ // safeStorage falls back to a fixed key when no keyring is running — a
+ // headless session, a minimal desktop — and does it silently. Somebody who
+ // believes the OS is protecting their keys deserves to be told when it is not.
+ const [keyBackend, setKeyBackend] = useState('');
+ useEffect(() => {
+ if (!platform.secrets.available) return;
+ platform.secrets.backend().then(setKeyBackend).catch(() => {});
+ }, []);
useEffect(() => {
hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {});
@@ -4043,6 +4059,19 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</div>
`}
+ ${keyBackend && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings.keys_heading')}</h3>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.keys_where')}</span>
+ <span class="settings-value">${keyBackend}</span>
+ </div>
+ ${keyBackend === 'unprotected_fallback' && html`
+ <p class="error-msg">${t('settings.keys_unprotected')}</p>
+ `}
+ </div>
+ `}
+
<div class="settings-section">
<h3 class="settings-heading">${t('settings.about')}</h3>
<div class="settings-row">
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 5a946f7..ae356ba 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -198,6 +198,9 @@ export default {
'settings.theme_system': 'System',
'settings.language': 'Sprache',
'settings.about': 'Über',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': 'Version',
'settings.protocol': 'Protokoll',
'settings.role': 'Rolle',
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 fd2d6ec..fe6bd0f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -190,6 +190,9 @@ export default {
'settings.theme_system': 'System',
'settings.language': 'Language',
'settings.about': 'About',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': 'Version',
'settings.protocol': 'Protocol',
'settings.role': 'Role',
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 e564164..3066963 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -194,6 +194,9 @@ export default {
'settings.theme_system': 'Sistema',
'settings.language': 'Idioma',
'settings.about': 'Acerca de',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': 'Versión',
'settings.protocol': 'Protocolo',
'settings.role': 'Rol',
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 5848f9d..c94089d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -198,6 +198,9 @@ export default {
'settings.theme_system': 'Système',
'settings.language': 'Langue',
'settings.about': 'À propos',
+ 'settings.keys_heading': 'Clés sur cet appareil',
+ 'settings.keys_where': 'Protégées par',
+ 'settings.keys_unprotected': 'Aucun trousseau système ne fonctionne : vos clés sont chiffrées avec une clé qui n’est pas secrète. Quiconque peut lire les fichiers de cette machine peut les lire. Démarrez un trousseau, ou considérez cet appareil comme non fiable.',
'settings.version': 'Version',
'settings.protocol': 'Protocole',
'settings.role': 'Rôle',
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 61913e3..56beb8d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -197,6 +197,9 @@ export default {
'settings.theme_system': 'Sistema',
'settings.language': 'Lingua',
'settings.about': 'Informazioni',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': 'Versione',
'settings.protocol': 'Protocollo',
'settings.role': 'Ruolo',
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 aa656ee..5d83bb9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -192,6 +192,9 @@ export default {
'settings.theme_system': 'システムに合わせる',
'settings.language': '言語',
'settings.about': 'このアプリについて',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': 'バージョン',
'settings.protocol': 'プロトコル',
'settings.role': '権限',
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 dae903f..55c7d81 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -198,6 +198,9 @@ export default {
'settings.theme_system': 'Systeem',
'settings.language': 'Taal',
'settings.about': 'Over',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': 'Versie',
'settings.protocol': 'Protocol',
'settings.role': 'Rol',
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 65c81ca..3cf909f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -204,6 +204,9 @@ export default {
'settings.theme_system': 'Systemowy',
'settings.language': 'Język',
'settings.about': 'O programie',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': 'Wersja',
'settings.protocol': 'Protokół',
'settings.role': 'Rola',
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 729a7fc..714dff6 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
@@ -196,6 +196,9 @@ export default {
'settings.theme_system': 'Sistema',
'settings.language': 'Idioma',
'settings.about': 'Sobre',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': 'Versão',
'settings.protocol': 'Protocolo',
'settings.role': 'Função',
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 0ff5a49..c3d9494 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
@@ -183,6 +183,9 @@ export default {
'settings.theme_system': '跟随系统',
'settings.language': '语言',
'settings.about': '关于',
+ 'settings.keys_heading': 'Keys on this device',
+ 'settings.keys_where': 'Protected by',
+ 'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
'settings.version': '版本',
'settings.protocol': '协议',
'settings.role': '角色',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
new file mode 100644
index 0000000..0663a42
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -0,0 +1,103 @@
+/**
+ * What differs between running in a browser and running as an installed app.
+ *
+ * The interface is the same code either way — that is the whole reason Electron
+ * was chosen over a shell that replaces the engine (docs/desktop-client-v1.md
+ * §2). What genuinely differs is small and lives here:
+ *
+ * · **where the hub is.** Served from the hub, it is the current origin. Ship
+ * the interface in a package and it becomes a configured URL, because the
+ * page is loaded from disk and has no hub origin of its own.
+ * · **where a downloaded file goes**, and whether a native dialog picks it.
+ * · **what the app can do at all** — managing a local node, choosing folders
+ * on this machine. Features gated on these render nowhere in a browser
+ * rather than failing when clicked.
+ *
+ * The browser implementation below is exactly today's behaviour, so nothing
+ * changes for anyone until an app is installed. That is the acceptance
+ * criterion for this split: **the browser SPA behaves identically.**
+ *
+ * The native side arrives through `window.meshbay`, which the Electron preload
+ * exposes over a context bridge. Absent, everything falls back to the browser
+ * path — so this file is safe to load anywhere and there is no build flag.
+ */
+
+const bridge = (typeof window !== 'undefined' && window.meshbay) || null;
+
+export const isNative = Boolean(bridge);
+
+/**
+ * The hub's base URL, prefixed to every API path.
+ *
+ * Empty string in a browser: the hub served this page, so a relative path goes
+ * to the right place and no configuration can be wrong. In the app it is
+ * whatever the user signed in against, and it is deliberately *not* guessed —
+ * a client that picks its own hub is a client that can be pointed at one.
+ */
+export function hubBase() {
+ return bridge ? (bridge.hubBase() || '') : '';
+}
+
+/** Native-only capabilities. A browser renders none of what these gate. */
+export const capabilities = {
+ // Install, configure and drive a node running on this machine.
+ nodeAdmin: Boolean(bridge && bridge.capabilities && bridge.capabilities.nodeAdmin),
+ // Choose directories on this machine to share.
+ localFolders: Boolean(bridge && bridge.capabilities && bridge.capabilities.localFolders),
+ // A real save dialog and a write that does not pass through the page.
+ nativeSave: Boolean(bridge && bridge.capabilities && bridge.capabilities.nativeSave),
+};
+
+/**
+ * Where the identity keys live.
+ *
+ * In a browser: exactly where they live today — IndexedDB and sessionStorage,
+ * with the keypair bundle on the node as the way a second browser recovers
+ * them, which is finding C4 and is the reason the app exists.
+ *
+ * In the app: the OS keychain, and no bundle is stored anywhere. That is what
+ * closes C4 for a native device — unconditionally for that device, and for the
+ * account only once it stops signing in from a browser too.
+ */
+export const secrets = {
+ available: Boolean(bridge && bridge.secrets),
+ async get(name) {
+ if (!bridge || !bridge.secrets) return null;
+ return bridge.secrets.get(name);
+ },
+ async set(name, value) {
+ if (!bridge || !bridge.secrets) return false;
+ return bridge.secrets.set(name, value);
+ },
+ async clear(name) {
+ if (!bridge || !bridge.secrets) return false;
+ return bridge.secrets.clear(name);
+ },
+ /**
+ * Whether the OS is really protecting them.
+ *
+ * Electron's safeStorage falls back to a fixed key when no keyring is
+ * running — a headless session, a minimal desktop — and silently. A user who
+ * believes their keys are protected by the OS deserves to be told when they
+ * are not, so this is surfaced rather than swallowed.
+ */
+ async backend() {
+ if (!bridge || !bridge.secrets) return 'browser';
+ return bridge.secrets.backend();
+ },
+};
+
+/**
+ * Save a decrypted file to disk.
+ *
+ * Returns null when there is no native path, so the caller keeps today's
+ * behaviour — File System Access, a service worker stream, or a blob, decided
+ * in `downloads.js`. Adding a native writer must not remove the three that
+ * already work.
+ */
+export async function nativeSave(suggestedName, size) {
+ if (!bridge || !bridge.saveFile) return null;
+ return bridge.saveFile(suggestedName, size);
+}
+
+export default { isNative, hubBase, capabilities, secrets, nativeSave };