aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 13:40:15 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 13:40:15 +0200
commitcf418a095b07c8127042390c748e721d1433879b (patch)
tree6ede268b8354127764da4a97d726e1bc680bb1c4 /packages
parentae7099edefbff5cd6ac0e2329f0684b6caff0b73 (diff)
downloadmeshbay-cf418a095b07c8127042390c748e721d1433879b.tar.gz
fix(client): downloads stream to disk, and two rough edges on first run
**Downloads were going through RAM.** `_openDownloadTarget` tries a granted folder, then a service worker, then its floor: collect the whole file in the page and hand the browser a blob. Both of the first two are absent in the desktop application — `showDirectoryPicker` does not exist, and Chromium refuses a service worker on a custom scheme — so every download under 512 MB took the floor. A gigabyte of film meant a gigabyte of RAM, and the only visible symptom was a Save As dialog at the *end* rather than the start, which is what the operator noticed and asked about. The main process now streams to disk: it honours "save automatically" with a folder chosen once and no dialog, never overwrites (a colliding name gets a suffix), awaits each write so the renderer cannot outrun the disk and queue the file in memory anyway, and unlinks a cancelled download rather than leaving a truncated file that looks complete to whoever opens it next. Settings now offers the native folder picker instead of saying downloads are unsupported. Measured in the running application: the file on disk grows 256 KB → 512 KB → 768 KB → 1 MB as the chunks arrive, and an aborted download leaves nothing behind. **A permanent scrollbar on sign-in.** `.layout` and `.page-center` each reserved `100vh - 52px`, and `.page-center` sits inside `main`'s 24px vertical padding — so the page overflowed by exactly 48px at every window size. Found by measuring in the app rather than reading the stylesheet: `scrollHeight` 819 against a 771 viewport, then the bottom edge of every element. The centring page brings its own padding, so main's is dropped for it and the duplicated arithmetic goes rather than growing a third term. Now `scrollHeight == innerHeight`, no overflowing elements. **The first-run screen was unstyled.** It used a class name I invented (`auth-page`) that appears nowhere in the stylesheet, so it had no card and the button sat against the input. It now uses the same `page-center` + `login-card` markup as sign-in, which is where the 12px gap comes from. The sign-in link in the nav is hidden until a hub is chosen — it led to a page that could not work. 809 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/src/main.js100
-rw-r--r--packages/meshbay-client/src/preload.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js74
-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/platform.js27
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css8
15 files changed, 200 insertions, 38 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 4bc6392..f97be8e 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -459,27 +459,94 @@ function registerBridge() {
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.
+ // Downloads are written to disk as they arrive — never collected in memory
+ // and handed over at the end.
+ //
+ // That is what the browser does through the File System Access API or a
+ // service worker, and **this application has neither**: `showDirectoryPicker`
+ // is absent, and Chromium refuses to register a worker on a custom scheme. So
+ // the chain fell through to its floor, which accumulates the whole file in
+ // the page and hands Chromium a blob — a gigabyte of RAM for a gigabyte of
+ // film, and a Save As dialog at the *end*, which is how it was noticed.
+ //
+ // The renderer still never names a path. It asks; the user chooses once; the
+ // main process holds the handle and the renderer 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')),
+ /** `name`, or the first "name (n).ext" that is not taken — never an overwrite. */
+ function freeName(dir, filename) {
+ if (!fs.existsSync(path.join(dir, filename))) return filename;
+ const ext = path.extname(filename);
+ const stem = path.basename(filename, ext);
+ for (let n = 2; n < 1000; n++) {
+ const candidate = `${stem} (${n})${ext}`;
+ if (!fs.existsSync(path.join(dir, candidate))) return candidate;
+ }
+ throw new Error(`No free name for ${filename}`);
+ }
+
+ ipcMain.handle('folder:choose', async () => {
+ const result = await dialog.showOpenDialog(mainWindow, {
+ properties: ['openDirectory', 'createDirectory'],
});
- if (result.canceled || !result.filePath) return null;
+ if (result.canceled || !result.filePaths.length) return null;
+ config = { ...config, downloadDir: result.filePaths[0] };
+ writeConfig(config);
+ return config.downloadDir;
+ });
+
+ ipcMain.handle('folder:get', () => {
+ const dir = config.downloadDir;
+ // A folder that has been removed or unmounted is not a folder any more,
+ // and saying so beats failing on the first chunk of a download.
+ if (!dir) return null;
+ try { return fs.statSync(dir).isDirectory() ? dir : null; } catch { return null; }
+ });
+
+ ipcMain.handle('folder:forget', () => {
+ config = { ...config, downloadDir: '' };
+ writeConfig(config);
+ return true;
+ });
+
+ ipcMain.handle('save:begin', async (_e, suggestedName, opts) => {
+ const wanted = path.basename(String(suggestedName || 'download'));
+ const remembered = config.downloadDir;
+ let target = null;
+
+ // "Save automatically" means exactly that: no dialog, into the folder that
+ // was chosen once.
+ if (opts && opts.auto && remembered) {
+ try {
+ if (fs.statSync(remembered).isDirectory()) {
+ target = path.join(remembered, freeName(remembered, wanted));
+ }
+ } catch { target = null; }
+ }
+
+ if (!target) {
+ const result = await dialog.showSaveDialog(mainWindow, {
+ defaultPath: remembered ? path.join(remembered, wanted) : wanted,
+ });
+ if (result.canceled || !result.filePath) return null;
+ target = result.filePath;
+ }
+
const id = String(++sinkId);
- sinks.set(id, fs.createWriteStream(result.filePath));
- return { id, name: path.basename(result.filePath) };
+ sinks.set(id, { stream: fs.createWriteStream(target), path: target });
+ return { id, name: path.basename(target), path: target };
});
ipcMain.handle('save:write', async (_e, id, chunk) => {
const sink = sinks.get(String(id));
if (!sink) throw new Error('No such download');
+ // Awaiting the callback is what applies backpressure: without it the
+ // renderer would outrun the disk and queue the file in memory anyway,
+ // which is the thing this exists to avoid.
await new Promise((resolve, reject) =>
- sink.write(Buffer.from(chunk), (err) => (err ? reject(err) : resolve())));
+ sink.stream.write(Buffer.from(chunk),
+ (err) => (err ? reject(err) : resolve())));
return true;
});
@@ -487,7 +554,18 @@ function registerBridge() {
const sink = sinks.get(String(id));
if (!sink) return false;
sinks.delete(String(id));
- await new Promise((resolve) => sink.end(resolve));
+ await new Promise((resolve) => sink.stream.end(resolve));
+ return true;
+ });
+
+ ipcMain.handle('save:abort', async (_e, id) => {
+ const sink = sinks.get(String(id));
+ if (!sink) return false;
+ sinks.delete(String(id));
+ await new Promise((resolve) => sink.stream.close(resolve));
+ // A cancelled download leaves a truncated file, which is worse than none:
+ // it looks like a complete one to whoever opens it next.
+ try { fs.unlinkSync(sink.path); } catch { /* already gone */ }
return true;
});
}
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index b649ae3..caad523 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -66,15 +66,26 @@ contextBridge.exposeInMainWorld('meshbay', {
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);
+ // Where downloads go, chosen once. The renderer never sees or sends a path —
+ // it asks for a dialog and is told the folder's name for display only.
+ folder: {
+ choose: () => ipcRenderer.invoke('folder:choose'),
+ get: () => ipcRenderer.invoke('folder:get'),
+ forget: () => ipcRenderer.invoke('folder:forget'),
+ },
+
+ // A sink that writes to disk as chunks arrive, never a buffer handed over at
+ // the end. `auto` uses the remembered folder without a dialog, which is what
+ // "save automatically" means; without one, or when the person asked to be
+ // prompted, a dialog opens. The renderer holds an id, not a path.
+ saveFile: async (suggestedName, opts) => {
+ const handle = await ipcRenderer.invoke('save:begin', suggestedName, opts);
if (!handle) return null;
return {
name: handle.name,
write: (chunk) => ipcRenderer.invoke('save:write', handle.id, chunk),
close: () => ipcRenderer.invoke('save:end', handle.id),
+ abort: () => ipcRenderer.invoke('save:abort', handle.id),
};
},
});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 4091ae7..f49d412 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -569,7 +569,8 @@ function TransferWidget() {
// ── Nav ──────────────────────────────────────────────────────────────────────
-function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }) {
+function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount,
+ hubUnset }) {
return html`
<nav class="nav">
<div class="nav-left">
@@ -590,7 +591,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }
${user ? html`
<${UserMenu} user=${user} theme=${theme}
onThemeChange=${onThemeChange} onLogout=${onLogout} />
- ` : html`
+ ` : hubUnset ? null : html`
<a class="nav-btn" href="#/login">${t('nav.login')}</a>
`}
</div>
@@ -681,18 +682,21 @@ function FirstRunPage({ onSet }) {
};
return html`
- <div class="auth-page">
- <h2>${t('firstrun.title')}</h2>
- <p class="settings-hint">${t('firstrun.hint')}</p>
- <form onSubmit=${submit}>
- <input type="url" placeholder="https://meshbay.org" required
- value=${url} onInput=${e => setUrl(e.target.value)} />
- <button type="submit" disabled=${busy}>
- ${busy ? '…' : t('firstrun.btn')}
- </button>
- </form>
- ${error && html`<p class="error-msg">${error}</p>`}
- <p class="settings-hint">${t('firstrun.note')}</p>
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('firstrun.title')}</h2>
+ <p class="settings-hint" style="margin-bottom:16px">${t('firstrun.hint')}</p>
+ <form onSubmit=${submit}>
+ <input type="text" placeholder="https://meshbay.org" required
+ autofocus value=${url}
+ onInput=${e => setUrl(e.target.value)} />
+ <button type="submit" disabled=${busy}>
+ ${busy ? t('firstrun.checking') : t('firstrun.btn')}
+ </button>
+ </form>
+ ${error && html`<div class="error-msg">${error}</div>`}
+ <p class="settings-hint" style="margin-top:16px">${t('firstrun.note')}</p>
+ </div>
</div>
`;
}
@@ -1185,6 +1189,26 @@ const PIPELINE_WINDOW = 8;
*/
async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
swSize = size) {
+ // On a desktop build this is the whole answer, and it comes first.
+ //
+ // The two browser paths below are both unavailable there — `showDirectoryPicker`
+ // does not exist, and Chromium refuses a service worker on a custom scheme —
+ // so without this the chain fell all the way through to its floor, which
+ // collects the file in the page and hands the browser a blob. A gigabyte of
+ // film meant a gigabyte of RAM, and a Save As dialog at the *end*.
+ if (platform.capabilities.nativeSave) {
+ try {
+ const native = await platform.nativeSave(
+ filename, { auto: downloads.getMode() === 'auto' });
+ // Null means the person dismissed the dialog, which is not an error and
+ // must not start a transfer.
+ return native || false;
+ } catch (err) {
+ console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err));
+ return false;
+ }
+ }
+
try {
const target = await downloads.openTarget(filename);
if (target) return target;
@@ -4013,10 +4037,20 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {});
}, []);
- useEffect(() => { downloads.savedDirectory().then(setDlDir); }, []);
+ useEffect(() => {
+ // The desktop build remembers a path; the browser remembers a handle. Both
+ // answer "where do downloads go", and the row below renders either.
+ if (platform.folder.available) platform.folder.get().then(setDlDir);
+ else downloads.savedDirectory().then(setDlDir);
+ }, []);
const pickFolder = useCallback(async () => {
try {
+ if (platform.folder.available) {
+ const dir = await platform.folder.choose();
+ if (dir) setDlDir(dir);
+ return;
+ }
const handle = await downloads.chooseDirectory();
setDlDir(handle);
} catch (err) {
@@ -4033,7 +4067,7 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
<div class="settings-section">
<h3 class="settings-heading">${t('settings.downloads')}</h3>
- ${!downloads.SUPPORTED
+ ${!(downloads.SUPPORTED || platform.folder.available)
? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>`
: html`
<label class="settings-choice">
@@ -4054,7 +4088,8 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</label>
<div class="settings-row" style="margin-top:10px">
<span class="settings-label">
- ${dlDir ? t('settings.dl_folder', { name: dlDir.name })
+ ${dlDir ? t('settings.dl_folder',
+ { name: dlDir.name || String(dlDir) })
: t('settings.dl_no_folder')}
</span>
<span>
@@ -4063,7 +4098,8 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</button>
${dlDir && html`
<button class="btn-secondary" onClick=${async () => {
- await downloads.forgetDirectory();
+ if (platform.folder.available) await platform.folder.forget();
+ else await downloads.forgetDirectory();
setDlDir(null);
}}>${t('settings.dl_forget')}</button>
`}
@@ -4869,7 +4905,7 @@ function App() {
onThemeChange=${changeTheme}
onLogout=${authCtx.logout}
onMenuToggle=${() => setMenuOpen(o => !o)}
- unreadCount=${unreadCount} />
+ unreadCount=${unreadCount} hubUnset=${needsHub} />
<div class="layout">
${user && html`<${Sidebar}
groups=${groups}
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 ff7bd8a..e099edb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -201,6 +201,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
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 738a862..a621084 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -193,6 +193,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
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 fb5cb0e..5fb4950 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -197,6 +197,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
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 a849730..c9480b6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -201,6 +201,7 @@ export default {
'firstrun.title': 'Quel hub ?',
'firstrun.hint': 'Un hub détient votre compte et vous met en relation avec les nœuds. Il ne voit ni vos fichiers, ni vos messages, ni vos clés.',
'firstrun.btn': 'Continuer',
+ 'firstrun.checking': 'Vérification…',
'firstrun.note': 'Il n’y a volontairement pas de valeur par défaut : cette application ne fait confiance à un hub que pour son API, jamais pour l’interface, qui est livrée avec l’application.',
'device.this_device': 'Cet appareil',
'settings.keys_heading': 'Clés sur cet appareil',
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 17ad3e0..ac7c06c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -200,6 +200,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
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 47221a6..b5d60ca 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -195,6 +195,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
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 617cdcd..304c90a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -201,6 +201,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
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 ddbd1a9..98f36ce 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -207,6 +207,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
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 18c0921..5db5cd5 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
@@ -199,6 +199,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
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 d868335..b5f16fb 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
@@ -186,6 +186,7 @@ export default {
'firstrun.title': 'Which hub?',
'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
'firstrun.btn': 'Continue',
+ 'firstrun.checking': 'Checking…',
'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index 994f558..372b666 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -176,13 +176,32 @@ export async function apiFetch(url, init) {
* in `downloads.js`. Adding a native writer must not remove the three that
* already work.
*/
-export async function nativeSave(suggestedName, size) {
+export async function nativeSave(suggestedName, { auto = true } = {}) {
if (!bridge || !bridge.saveFile) return null;
- return bridge.saveFile(suggestedName, size);
+ const sink = await bridge.saveFile(suggestedName, { auto });
+ if (!sink) return null;
+ // The shape every caller already expects from a download target: a `writable`
+ // with write/close/abort, and the name it was actually given on disk.
+ return {
+ name: sink.name,
+ writable: {
+ write: (bytes) => sink.write(bytes),
+ close: () => sink.close(),
+ abort: () => sink.abort(),
+ },
+ };
}
+/** Where downloads go on a desktop build. Null in a browser. */
+export const folder = {
+ available: Boolean(bridge && bridge.folder),
+ async choose() { return bridge && bridge.folder ? bridge.folder.choose() : null; },
+ async get() { return bridge && bridge.folder ? bridge.folder.get() : null; },
+ async forget() { return bridge && bridge.folder ? bridge.folder.forget() : false; },
+};
+
export default { isNative, hubBase, capabilities, secrets, nativeSave,
- apiFetch, device, bridgeMessage };
+ apiFetch, device, bridgeMessage, folder };
// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
@@ -191,5 +210,5 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave,
if (typeof window !== 'undefined') {
window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
nativeSave, apiFetch, device,
- bridgeMessage };
+ bridgeMessage, folder };
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 6b0b6fe..828e104 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -278,6 +278,14 @@ a:hover { text-decoration: underline; }
padding: 24px;
}
+/* A centring page already reserves the whole viewport below the header, so the
+ main column's own padding is added on top of it and the page overflows by
+ exactly that much — 48px, and a scrollbar on sign-in at every window size.
+ Measured, not deduced: `.page-center` ended at 795 and `main` at 819.
+ The page brings its own 24px, so dropping main's here changes nothing
+ visible and removes the duplicated arithmetic rather than adding more. */
+.main:has(> .page-center) { padding: 0; }
+
/* ── Cards ────────────────────────────────────────────────────────────────── */
.card {