aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/package.json29
-rw-r--r--packages/meshbay-client/src/main.js331
-rw-r--r--packages/meshbay-client/src/preload.js66
-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
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py218
-rw-r--r--packages/meshbay-hub/tests/test_session_renewal.py12
-rw-r--r--packages/meshbay-node/tests/test_packaging_units.py108
19 files changed, 931 insertions, 5 deletions
diff --git a/packages/meshbay-client/package.json b/packages/meshbay-client/package.json
new file mode 100644
index 0000000..50cfcb4
--- /dev/null
+++ b/packages/meshbay-client/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "meshbay-client",
+ "version": "0.1.0",
+ "description": "MeshBay desktop client — the interface ships with the application, not from the hub",
+ "license": "AGPL-3.0-or-later",
+ "main": "src/main.js",
+ "private": true,
+ "scripts": {
+ "start": "electron .",
+ "sync-ui": "node build/sync-ui.js",
+ "dist": "npm run sync-ui && electron-builder --linux deb rpm"
+ },
+ "devDependencies": {
+ "electron": "^33.0.0",
+ "electron-builder": "^25.0.0"
+ },
+ "build": {
+ "appId": "org.meshbay.client",
+ "productName": "MeshBay",
+ "files": ["src/**", "ui/**"],
+ "linux": {
+ "target": ["deb", "rpm"],
+ "category": "Network",
+ "synopsis": "Peer-to-peer file sharing, streaming and group chat"
+ },
+ "deb": { "depends": ["python3-meshbay-common"] },
+ "rpm": { "depends": ["python3-meshbay-common"] }
+ }
+}
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
new file mode 100644
index 0000000..3fc52ff
--- /dev/null
+++ b/packages/meshbay-client/src/main.js
@@ -0,0 +1,331 @@
+/**
+ * MeshBay desktop client — the Electron main process.
+ *
+ * The reason this exists, stated once so nobody has to rediscover it: **the
+ * interface ships inside this package and loads from disk.** A shell that
+ * points a WebView at the hub's /app/ is a browser with a different icon and
+ * fixes nothing — the hub could still send whatever code it liked, which is
+ * finding T3. The hub is used for its API and for nothing else.
+ *
+ * What that does and does not buy is worth being honest about: it does not make
+ * the hub untrusted. A build downloaded from meshbay.org and signed with a key
+ * its operator holds relocates the trust rather than removing it. What changes
+ * is **detectability** — an attack has to ship as an artifact that can be
+ * hashed and compared, instead of being one HTTP response aimed at one person.
+ * That value is realised by reproducible builds and published hashes (18.7),
+ * not by the packaging format.
+ *
+ * See docs/desktop-client-v1.md §2 and §3.
+ */
+
+'use strict';
+
+const { app, BrowserWindow, dialog, ipcMain, protocol, safeStorage, shell } =
+ require('electron');
+const fs = require('node:fs');
+const fsp = require('node:fs/promises');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+
+const UI_DIR = path.join(__dirname, '..', 'ui');
+const SCHEME = 'app';
+
+// ── The scheme the interface is served from ─────────────────────────────────
+//
+// Not file://. Service workers, ES modules and IndexedDB all misbehave there,
+// and the streamed-download path needs a *controlled* page — a worker that is
+// merely active is not enough, which this codebase has already learned once.
+//
+// `secure` is what makes it a secure context, without which the worker refuses
+// to register and downloads break with no error at all. `standard` gives it a
+// real origin, so IndexedDB survives an update instead of being keyed to
+// something that moves.
+protocol.registerSchemesAsPrivileged([{
+ scheme: SCHEME,
+ privileges: {
+ standard: true,
+ secure: true,
+ supportFetchAPI: true,
+ stream: true, // Range requests, for playing a film
+ corsEnabled: true,
+ },
+}]);
+
+/**
+ * Serve the packaged interface, and refuse to leave it.
+ *
+ * Every path is resolved and checked against the UI directory before anything
+ * is read: the renderer is the least trusted part of this process, and a
+ * traversal here would hand it the user's filesystem.
+ */
+function registerUiProtocol() {
+ protocol.handle(SCHEME, async (request) => {
+ const url = new URL(request.url);
+ const rel = decodeURIComponent(url.pathname).replace(/^\/+/, '') || 'index.html';
+ const target = path.resolve(UI_DIR, rel);
+ const root = path.resolve(UI_DIR);
+ if (target !== root && !target.startsWith(root + path.sep)) {
+ return new Response('Not found', { status: 404 });
+ }
+ try {
+ const body = await fsp.readFile(target);
+ return new Response(body, {
+ headers: { 'Content-Type': contentType(target) },
+ });
+ } catch {
+ return new Response('Not found', { status: 404 });
+ }
+ });
+}
+
+function contentType(file) {
+ const ext = path.extname(file).toLowerCase();
+ return {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.wasm': 'application/wasm',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.woff2': 'font/woff2',
+ }[ext] || 'application/octet-stream';
+}
+
+// ── Configuration ───────────────────────────────────────────────────────────
+
+function configPath() {
+ return path.join(app.getPath('userData'), 'config.json');
+}
+
+function readConfig() {
+ try {
+ return JSON.parse(fs.readFileSync(configPath(), 'utf8'));
+ } catch {
+ // No default hub. A client that picks its own is a client that can be
+ // pointed at one, so the first run asks and the answer is remembered.
+ return { hubBase: '', window: null };
+ }
+}
+
+function writeConfig(next) {
+ const dir = path.dirname(configPath());
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(configPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
+}
+
+let config = readConfig();
+
+// ── Secrets ─────────────────────────────────────────────────────────────────
+//
+// The OS keychain, through safeStorage. What it protects and what it does not
+// is reported rather than assumed: on Linux, safeStorage falls back to a fixed
+// key when no keyring is running — a headless session, a minimal desktop — and
+// it does so silently. Someone who believes the OS is holding their keys should
+// be told when it is not.
+
+function secretsFile() {
+ return path.join(app.getPath('userData'), 'secrets.bin');
+}
+
+function readSecrets() {
+ try {
+ const raw = fs.readFileSync(secretsFile());
+ if (!safeStorage.isEncryptionAvailable()) return {};
+ return JSON.parse(safeStorage.decryptString(raw));
+ } catch {
+ return {};
+ }
+}
+
+function writeSecrets(all) {
+ if (!safeStorage.isEncryptionAvailable()) {
+ throw new Error('No OS key storage is available on this system');
+ }
+ fs.mkdirSync(path.dirname(secretsFile()), { recursive: true });
+ fs.writeFileSync(secretsFile(), safeStorage.encryptString(JSON.stringify(all)),
+ { mode: 0o600 });
+}
+
+function secretsBackend() {
+ if (!safeStorage.isEncryptionAvailable()) return 'unavailable';
+ if (process.platform !== 'linux') return process.platform === 'darwin'
+ ? 'keychain' : 'dpapi';
+ const backend = safeStorage.getSelectedStorageBackend
+ ? safeStorage.getSelectedStorageBackend() : 'unknown';
+ // "basic_text" is Electron's fixed-key fallback: encrypted on disk, but by a
+ // key that is not a secret. Named plainly so the interface can say so.
+ return backend === 'basic_text' ? 'unprotected_fallback' : backend;
+}
+
+// ── Window ──────────────────────────────────────────────────────────────────
+
+let mainWindow = null;
+
+function createWindow() {
+ const bounds = (config.window && config.window.width) ? config.window : {
+ width: 1200, height: 800,
+ };
+
+ mainWindow = new BrowserWindow({
+ ...bounds,
+ minWidth: 320,
+ show: false,
+ webPreferences: {
+ // The three that matter. `sandbox` keeps the Chromium renderer sandbox —
+ // the strongest one available, and the reason Electron is not the
+ // trade-off the earlier design recorded against "native". Without
+ // `contextIsolation` the preload's objects are reachable and mutable from
+ // page script, which would make the bridge below decorative.
+ sandbox: true,
+ contextIsolation: true,
+ nodeIntegration: false,
+ preload: path.join(__dirname, 'preload.js'),
+ // The hub address, handed over as a process argument rather than fetched.
+ // `platform.hubBase()` runs while the module graph is loading — before
+ // anything can await — so it has to be synchronous, and synchronous IPC
+ // would block the renderer on every call for a value that never changes
+ // within a run. Changing it restarts the window.
+ additionalArguments: [`--meshbay-hub=${config.hubBase || ''}`],
+ // The page is loaded over app:// and talks to the hub over https. Neither
+ // needs to reach the local filesystem.
+ webSecurity: true,
+ },
+ });
+
+ mainWindow.once('ready-to-show', () => mainWindow.show());
+
+ // The hub must never become the document origin. Anything that would navigate
+ // away from the packaged interface is refused, and an external link opens in
+ // the user's own browser rather than inside a window holding their keys.
+ const isOurs = (target) => {
+ try { return new URL(target).protocol === `${SCHEME}:`; } catch { return false; }
+ };
+ mainWindow.webContents.on('will-navigate', (event, target) => {
+ if (!isOurs(target)) event.preventDefault();
+ });
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ if (/^https?:$/.test(new URL(url).protocol)) shell.openExternal(url);
+ return { action: 'deny' };
+ });
+ // Nothing in this application needs a camera, a microphone or a location.
+ mainWindow.webContents.session.setPermissionRequestHandler(
+ (_wc, _permission, callback) => callback(false));
+
+ mainWindow.on('close', () => {
+ if (!mainWindow.isMinimized() && !mainWindow.isFullScreen()) {
+ config = { ...config, window: mainWindow.getBounds() };
+ writeConfig(config);
+ }
+ });
+ mainWindow.on('closed', () => { mainWindow = null; });
+
+ mainWindow.loadURL(`${SCHEME}://meshbay/index.html`);
+}
+
+// ── Bridge ──────────────────────────────────────────────────────────────────
+//
+// Everything the interface may ask of this process, enumerated. A handler that
+// takes a path from the renderer and acts on it is the shape to avoid: the
+// renderer parses decrypted content from nodes, which is attacker-controlled
+// input, so it is treated as hostile even though it is our own code.
+
+function registerBridge() {
+ ipcMain.handle('hub:set', (_e, base) => {
+ const url = String(base || '').trim().replace(/\/+$/, '');
+ if (url && !/^https:\/\//.test(url) && !/^http:\/\/(localhost|127\.)/.test(url)) {
+ // http is allowed only to a loopback address, for someone running a hub
+ // on their own machine. Anywhere else it would put the session token on
+ // the wire in clear.
+ throw new Error('The hub address must be https');
+ }
+ config = { ...config, hubBase: url };
+ writeConfig(config);
+ // The renderer reads the address from a process argument, so the window has
+ // to be rebuilt for a change to take. Reloading in place would leave the
+ // interface talking to the old hub with no sign of it.
+ if (mainWindow) {
+ mainWindow.close();
+ createWindow();
+ }
+ return url;
+ });
+
+ ipcMain.handle('secrets:backend', () => secretsBackend());
+ ipcMain.handle('secrets:get', (_e, name) => readSecrets()[String(name)] ?? null);
+ ipcMain.handle('secrets:set', (_e, name, value) => {
+ const all = readSecrets();
+ all[String(name)] = String(value);
+ writeSecrets(all);
+ return true;
+ });
+ ipcMain.handle('secrets:clear', (_e, name) => {
+ const all = readSecrets();
+ delete all[String(name)];
+ writeSecrets(all);
+ return true;
+ });
+
+ // The renderer never names a path. It asks for a dialog; the user chooses;
+ // the main process holds the handle and the renderer only ever refers to it
+ // by an opaque id.
+ const sinks = new Map();
+ let sinkId = 0;
+
+ ipcMain.handle('save:begin', async (_e, suggestedName) => {
+ const result = await dialog.showSaveDialog(mainWindow, {
+ defaultPath: path.basename(String(suggestedName || 'download')),
+ });
+ if (result.canceled || !result.filePath) return null;
+ const id = String(++sinkId);
+ sinks.set(id, fs.createWriteStream(result.filePath));
+ return { id, name: path.basename(result.filePath) };
+ });
+
+ ipcMain.handle('save:write', async (_e, id, chunk) => {
+ const sink = sinks.get(String(id));
+ if (!sink) throw new Error('No such download');
+ await new Promise((resolve, reject) =>
+ sink.write(Buffer.from(chunk), (err) => (err ? reject(err) : resolve())));
+ return true;
+ });
+
+ ipcMain.handle('save:end', async (_e, id) => {
+ const sink = sinks.get(String(id));
+ if (!sink) return false;
+ sinks.delete(String(id));
+ await new Promise((resolve) => sink.end(resolve));
+ return true;
+ });
+}
+
+// ── Lifecycle ───────────────────────────────────────────────────────────────
+
+// One instance. Two would fight over the config file and the secrets blob, and
+// the second would look like the first had lost its state.
+if (!app.requestSingleInstanceLock()) {
+ app.quit();
+} else {
+ app.on('second-instance', () => {
+ if (mainWindow) {
+ if (mainWindow.isMinimized()) mainWindow.restore();
+ mainWindow.focus();
+ }
+ });
+
+ app.whenReady().then(() => {
+ registerUiProtocol();
+ registerBridge();
+ createWindow();
+ app.on('activate', () => {
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
+ });
+ });
+
+ app.on('window-all-closed', () => {
+ if (process.platform !== 'darwin') app.quit();
+ });
+}
+
+module.exports = { contentType, secretsBackend };
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
new file mode 100644
index 0000000..9c273e2
--- /dev/null
+++ b/packages/meshbay-client/src/preload.js
@@ -0,0 +1,66 @@
+/**
+ * The bridge, and the whole of it.
+ *
+ * `contextIsolation` puts this in its own world, so what is exposed here is all
+ * the page can reach — page script cannot read the closure, cannot replace
+ * these functions for other code, and cannot call an IPC channel that is not
+ * named below. That is what makes the enumeration meaningful rather than
+ * decorative.
+ *
+ * The rule for anything added here: **the renderer never names a path, a file
+ * handle or a process.** It asks for a dialog and receives an opaque id. The
+ * renderer parses decrypted content from nodes — video, images, filenames —
+ * which is attacker-controlled input, so it is treated as hostile even though
+ * it is our own code.
+ */
+
+'use strict';
+
+const { contextBridge, ipcRenderer } = require('electron');
+
+const HUB_BASE = (process.argv.find(a => a.startsWith('--meshbay-hub=')) || '')
+ .slice('--meshbay-hub='.length);
+
+contextBridge.exposeInMainWorld('meshbay', {
+ // Where the hub is. The interface prefixes every API path with this; in a
+ // browser the same function returns '' and relative paths go to the origin
+ // that served the page.
+ //
+ // Read from a process argument, not over IPC: the interface asks for this
+ // while its modules are still loading, before anything can await, and
+ // synchronous IPC would block the renderer for a value that cannot change
+ // within a run.
+ hubBase: () => HUB_BASE,
+ setHubBase: (base) => ipcRenderer.invoke('hub:set', base),
+
+ // What this build can do that a browser cannot. The interface renders
+ // features gated on these nowhere at all in a browser, rather than offering
+ // something that fails when clicked.
+ capabilities: {
+ nodeAdmin: true,
+ localFolders: true,
+ nativeSave: true,
+ },
+
+ secrets: {
+ get: (name) => ipcRenderer.invoke('secrets:get', name),
+ set: (name, value) => ipcRenderer.invoke('secrets:set', name, value),
+ clear: (name) => ipcRenderer.invoke('secrets:clear', name),
+ // 'unprotected_fallback' means safeStorage found no keyring and is using a
+ // fixed key. Encrypted on disk, by a key that is not a secret — the
+ // interface says so rather than letting someone believe otherwise.
+ backend: () => ipcRenderer.invoke('secrets:backend'),
+ },
+
+ // A save dialog and a write that never passes back through the page. The
+ // renderer holds an id, not a path.
+ saveFile: async (suggestedName) => {
+ const handle = await ipcRenderer.invoke('save:begin', suggestedName);
+ if (!handle) return null;
+ return {
+ name: handle.name,
+ write: (chunk) => ipcRenderer.invoke('save:write', handle.id, chunk),
+ close: () => ipcRenderer.invoke('save:end', handle.id),
+ };
+ },
+});
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 };
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py
new file mode 100644
index 0000000..13d194e
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_desktop_shell.py
@@ -0,0 +1,218 @@
+"""
+The desktop shell's security contract, pinned by reading its source.
+
+There is no npm on the development machine, so the Electron application cannot
+be installed or launched here. That is stated plainly rather than worked around:
+**nothing below proves the app runs.** What it does prove is that the properties
+the design depends on are present in the source, and it fails if one is removed
+— which is the same treatment `test_downloads.py` gives the three
+browser-specific save paths, for the same reason.
+
+Every assertion here corresponds to a sentence in `docs/desktop-client-v1.md`
+§3. Weak evidence, and the only evidence available without a packaged build; a
+person with an installed client is what confirms the rest.
+"""
+
+from pathlib import Path
+
+import pytest
+
+CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client"
+MAIN = CLIENT / "src" / "main.js"
+PRELOAD = CLIENT / "src" / "preload.js"
+INDEX = CLIENT / "build" / "index.html"
+
+pytestmark = pytest.mark.skipif(
+ not MAIN.exists(), reason="desktop client sources not present")
+
+
+def _main() -> str:
+ return MAIN.read_text(encoding="utf-8")
+
+
+def _preload() -> str:
+ return PRELOAD.read_text(encoding="utf-8")
+
+
+# ── The renderer is confined ────────────────────────────────────────────────
+
+@pytest.mark.parametrize("setting", [
+ "sandbox: true",
+ "contextIsolation: true",
+ "nodeIntegration: false",
+])
+def test_the_renderer_keeps_its_sandbox(setting):
+ """
+ Electron with these keeps the Chromium renderer sandbox — the strongest
+ available, and the reason "native costs the browser sandbox" is false for
+ this shell. Without contextIsolation the preload's objects are reachable and
+ mutable from page script, which would make the bridge decorative.
+ """
+ assert setting in _main(), f"{setting} is missing from the window"
+
+
+def test_the_interface_is_never_loaded_from_the_hub():
+ """
+ The whole reason this application exists. A shell pointing a WebView at the
+ hub's /app/ is a browser with a different icon and fixes nothing (T3).
+ """
+ source = _main()
+ assert "loadURL(`${SCHEME}://" in source or "loadURL('app://" in source
+ assert "loadURL('http" not in source and 'loadURL("http' not in source
+ assert "loadURL(`http" not in source
+
+
+def test_navigation_away_from_the_package_is_refused():
+ source = _main()
+ assert "will-navigate" in source
+ assert "setWindowOpenHandler" in source
+ assert "event.preventDefault()" in source
+
+
+def test_no_permission_is_granted_to_the_page():
+ """Nothing here needs a camera, a microphone or a location."""
+ assert "setPermissionRequestHandler" in _main()
+ assert "callback(false)" in _main()
+
+
+# ── The custom scheme ───────────────────────────────────────────────────────
+
+@pytest.mark.parametrize("privilege", [
+ "standard: true",
+ "secure: true",
+ "supportFetchAPI: true",
+ "stream: true",
+])
+def test_the_scheme_is_privileged(privilege):
+ """
+ Without `secure` the scheme is not a secure context, the service worker
+ silently refuses to register, and streamed downloads break with no error —
+ the same failure mode as an uncontrolled page, which this codebase has
+ already learned once. `standard` gives a real origin, so IndexedDB survives
+ an update instead of being keyed to something that moves.
+ """
+ assert privilege in _main(), f"{privilege} missing from the scheme privileges"
+
+
+def test_the_protocol_handler_cannot_be_walked_out_of():
+ """
+ The renderer parses decrypted content from nodes, which is
+ attacker-controlled input. A traversal here would hand it the filesystem.
+ """
+ source = _main()
+ assert "path.resolve(UI_DIR" in source
+ assert "startsWith(root + path.sep)" in source
+ assert "status: 404" in source
+
+
+# ── Content Security Policy ─────────────────────────────────────────────────
+
+def test_the_policy_keeps_wasm_unsafe_eval():
+ """
+ The bundle key is Argon2id in WebAssembly. A policy that forbids it does not
+ degrade anything — it locks every user out of their keys.
+ """
+ assert "'wasm-unsafe-eval'" in _directive("script-src")
+
+
+def _policy() -> str:
+ """The meta tag's content, not the file — the comment above it names the
+ same directives and would satisfy a naive search."""
+ import re
+ page = INDEX.read_text(encoding="utf-8")
+ match = re.search(
+ r'http-equiv="Content-Security-Policy"\s+content="([^"]*)"', page)
+ assert match, "no Content-Security-Policy meta tag"
+ return match.group(1)
+
+
+def _directive(name: str) -> str:
+ for part in _policy().split(";"):
+ part = part.strip()
+ if part.startswith(name + " "):
+ return part
+ return ""
+
+
+def test_the_hub_is_reachable_but_never_executable():
+ """
+ connect-src allows the hub's API and its signaling socket. script-src does
+ not include it: nothing the hub returns is ever executed.
+ """
+ connect = _directive("connect-src")
+ assert "https:" in connect and "wss:" in connect
+
+ script = _directive("script-src")
+ assert script, "no script-src directive"
+ assert "https:" not in script, "the hub can serve script under this policy"
+ assert "'unsafe-eval'" not in script.replace("'wasm-unsafe-eval'", "")
+ assert "default-src 'none'" in _policy()
+
+
+# ── The bridge ──────────────────────────────────────────────────────────────
+
+def test_the_bridge_is_the_only_way_in():
+ source = _preload()
+ assert "contextBridge.exposeInMainWorld" in source
+ # Handing the raw ipcRenderer to the page would expose every channel in the
+ # main process, named or not.
+ assert "exposeInMainWorld('meshbay', ipcRenderer" not in source
+ assert "ipcRenderer)" not in source.replace("require('electron');", "")
+
+
+def test_the_renderer_never_names_a_path():
+ """
+ It asks for a dialog and receives an opaque id; the main process holds the
+ handle. A channel that took a path from the renderer and wrote to it would
+ be the whole confinement undone.
+ """
+ source = _preload()
+ assert "save:begin" in source
+ assert "handle.id" in source
+ assert "filePath" not in source, "the preload passes a filesystem path around"
+
+
+def test_the_hub_address_is_not_fetched_synchronously_over_ipc():
+ """
+ `platform.hubBase()` runs while the module graph is loading, before anything
+ can await. Synchronous IPC would block the renderer on every call for a
+ value that cannot change within a run.
+ """
+ source = _preload()
+ assert "--meshbay-hub=" in source
+ assert "sendSync" not in source
+
+
+def test_plain_http_is_refused_except_to_loopback():
+ """Anywhere else it would put the session token on the wire in clear."""
+ source = _main()
+ assert "must be https" in source
+ # The guard is a regular expression, so the dot is escaped in the source.
+ assert "127\\." in source and "localhost" in source
+
+
+# ── One interface, one source ───────────────────────────────────────────────
+
+def test_the_interface_is_copied_not_forked():
+ """
+ §2.7: the hub's static directory is the single source. A silent fork is the
+ only real way to end up maintaining the interface twice, so the copy is
+ generated and the generated tree is not committed.
+ """
+ sync = (CLIENT / "build" / "sync-ui.js").read_text(encoding="utf-8")
+ assert "meshbay-hub" in sync and "static" in sync
+ assert "rmSync" in sync, "a stale file could survive a rebuild"
+
+ gitignore = (CLIENT.parents[1] / ".gitignore").read_text(encoding="utf-8")
+ assert "meshbay-client/ui/" in gitignore, (
+ "the generated copy is committed, which is how a fork begins")
+
+
+def test_the_packaged_page_loads_the_shared_modules():
+ """The app's index.html is its own — the hub's carries a /a/<hash>/ prefix
+ that would point back at the hub — but it must load the same files."""
+ page = INDEX.read_text(encoding="utf-8")
+ for module in ("keyderive.js", "crypto.js", "transport.js", "app.js",
+ "style.css", "argon2.min.js"):
+ assert module in page, f"{module} is not loaded by the packaged page"
+ assert "/a/" not in page, "the packaged page points at the hub's asset prefix"
diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py
index 38db67f..6c47f7a 100644
--- a/packages/meshbay-hub/tests/test_session_renewal.py
+++ b/packages/meshbay-hub/tests/test_session_renewal.py
@@ -28,6 +28,7 @@ broken client.
import json
import shutil
import subprocess
+import re
from pathlib import Path
import pytest
@@ -223,5 +224,12 @@ def test_the_connection_signs_its_offer_with_a_live_token():
assert "await ensureFreshToken()" in connect, (
"the offer is signed with whatever token the effect captured, which is "
"no longer refreshed by a re-run")
- assert "new window.MeshBayTransport('', live)" in connect, (
- "the transport is built with the captured token rather than the live one")
+ # Asserted on the argument, not on the whole call: the first argument is
+ # the hub's base URL and became configurable when the interface started
+ # shipping in a package. Pinning the literal made this fail for a change
+ # that had nothing to do with tokens.
+ built = re.search(r"new window\.MeshBayTransport\(([^)]*)\)", connect)
+ assert built, "the transport is not built in connect()"
+ args = [a.strip() for a in built.group(1).split(",")]
+ assert args[-1] == "live", (
+ f"the transport is built with {args[-1]!r} rather than the live token")
diff --git a/packages/meshbay-node/tests/test_packaging_units.py b/packages/meshbay-node/tests/test_packaging_units.py
new file mode 100644
index 0000000..bbfb536
--- /dev/null
+++ b/packages/meshbay-node/tests/test_packaging_units.py
@@ -0,0 +1,108 @@
+"""
+The systemd units, and which directory each belongs in.
+
+`meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` —
+into the *user* unit directory. 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 caught it because nothing had built and installed the RPM.
+
+These read the files rather than installing them: no rpmbuild here. Weak
+evidence, and enough for this defect, which is a file in the wrong place.
+"""
+
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[3]
+SYSTEMD = ROOT / "packaging" / "systemd"
+SPEC = ROOT / "packaging" / "rpm" / "meshbay-node.spec"
+
+pytestmark = pytest.mark.skipif(not SPEC.exists(), reason="packaging not present")
+
+
+def _system_unit() -> str:
+ return (SYSTEMD / "meshbay-node.service").read_text(encoding="utf-8")
+
+
+def _user_unit() -> str:
+ return (SYSTEMD / "meshbay-node-user.service").read_text(encoding="utf-8")
+
+
+def _directives(unit: str) -> list[str]:
+ """
+ The lines systemd acts on — comments dropped.
+
+ Searching the whole file finds the comment explaining why a directive is
+ absent, and calls that the directive. The same mistake as reading a CSP out
+ of the HTML comment above the meta tag.
+ """
+ return [line.strip() for line in unit.splitlines()
+ if line.strip() and not line.strip().startswith("#")]
+
+
+def test_the_system_unit_is_a_template_that_names_its_user():
+ directives = _directives(_system_unit())
+ assert any(d == "User=%i" for d in directives), (
+ "the system template must run as the instance name")
+ assert any("%h" in d for d in directives), "it reads the instance's own home"
+
+
+def test_the_user_unit_names_no_user():
+ """
+ It already runs as its owner. `User=` in a user unit is not ignored —
+ systemd refuses to load the file at all.
+ """
+ directives = _directives(_user_unit())
+ assert not any(d.startswith("User=") for d in directives)
+ assert not any(d.startswith("Group=") for d in directives)
+
+
+def test_each_unit_is_installed_where_it_can_run():
+ spec = SPEC.read_text(encoding="utf-8")
+ install = spec.split("%files")[0]
+
+ # The template goes to the system directory, instantiated per person.
+ assert "%{_unitdir}/meshbay-node@.service" in install
+ # The user unit goes to the user directory, enabled without a password.
+ assert "%{_userunitdir}/meshbay-node.service" in install
+
+ system_line = next(l for l in install.splitlines()
+ if "meshbay-node-user.service" in l)
+ idx = install.splitlines().index(system_line)
+ destination = install.splitlines()[idx + 1]
+ assert "_userunitdir" in destination, (
+ "the per-user unit is installed as a system unit")
+
+
+def test_both_units_are_listed_in_files():
+ files = SPEC.read_text(encoding="utf-8").split("%files")[1]
+ assert "%{_unitdir}/meshbay-node@.service" in files
+ assert "%{_userunitdir}/meshbay-node.service" in files
+
+
+def test_the_user_unit_can_be_reloaded_without_dropping_anyone():
+ """
+ `meshbay-node reload` sends SIGHUP so a group's directories can change
+ without restarting. Without ExecReload the desktop client's reload would
+ have to stop the service, which drops whoever is watching a film.
+ """
+ directives = _directives(_user_unit())
+ reload_line = next((d for d in directives if d.startswith("ExecReload=")), "")
+ assert reload_line, "no ExecReload"
+ assert "HUP" in reload_line
+
+
+def test_the_user_unit_documents_how_a_drive_outside_home_is_added():
+ """
+ ProtectSystem=strict hides it, and a volume mounted after the service
+ started is invisible inside the unit's mount namespace — so the drop-in
+ needs RequiresMountsFor as well as ReadWritePaths. Written down where
+ somebody debugging an empty directory will find it.
+ """
+ unit = _user_unit()
+ assert "ProtectSystem=strict" in _directives(unit)
+ # These two belong in the comment: they are what an operator has to write in
+ # a drop-in, not what this file declares.
+ assert "RequiresMountsFor" in unit
+ assert "meshbay-node.service.d" in unit