diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 13:40:15 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 13:40:15 +0200 |
| commit | cf418a095b07c8127042390c748e721d1433879b (patch) | |
| tree | 6ede268b8354127764da4a97d726e1bc680bb1c4 | |
| parent | ae7099edefbff5cd6ac0e2329f0684b6caff0b73 (diff) | |
| download | meshbay-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>
17 files changed, 218 insertions, 39 deletions
@@ -402,6 +402,23 @@ anything that assumes one key per person. for it would have failed these tests for no visible reason. Use `Object.defineProperty`; the suite is now green on 18 and 24 +- **A fallback chain reaches its floor silently.** `_openDownloadTarget` tries a + granted folder, then a service worker, then "collect it in memory and hand the + browser a blob". In the desktop application the first two do not exist — + `showDirectoryPicker` is absent and Chromium refuses a worker on a custom + scheme — so **every download under 512 MB went through RAM**, and the only + visible symptom was a Save As dialog at the *end* instead of the start. + Nothing errored. When a chain degrades, check what the floor costs on every + platform that will reach it + +- **Two elements each claiming `100vh - 52px`, one inside the other's padding.** + `.layout` and `.page-center` both reserved the viewport below the header, and + `main`'s `24px` top and bottom were added on top — a permanent 48px scrollbar + on sign-in at every window size. Found by measuring in the running app + (`document.documentElement.scrollHeight` against `innerHeight`, then the + bottom edge of every element), not by reading the stylesheet, which is the + standing rule here + ## Two lessons that cost four rounds of live testing - **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md index 0989f52..23287dd 100644 --- a/docs/desktop-client-v1.md +++ b/docs/desktop-client-v1.md @@ -1061,7 +1061,7 @@ build existing. | D4 | ✅ **DONE 2026-08-18, verified against a hub running this code** — first-run hub prompt (no default, on purpose), passphrase sign-in registers this device, later launches sign in with the device key and no passphrase. **The renderer never holds that key**: it is generated, stored and used entirely in the main process, which signs on request — the same rule as the save dialog, because the renderer is the part that parses hostile input. Measured: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → a stranger's key 401. **Verified end to end on a real desktop** (Ubuntu 24.04 GNOME, 2026-08-18): the application was quit and relaunched, and signed in **without asking for the passphrase**. safeStorage really uses the keyring — `secrets.bin` carries Chromium's `v11` prefix, the marker for keyring-backed encryption | 1 | | D5 | Node management panel over the Stage-B ops, root selection included | 2 | | D6 | First-run wizard — detect, enable the unit, link, group, `gek-init`, pair (§7.4) | 2 | -| D7 | Native save dialog, streamed write from the main process | 2 | +| D7 | ✅ **DONE 2026-08-18, and it was not optional** — the two browser paths are both absent in the application (`showDirectoryPicker` does not exist, and Chromium refuses a service worker on a custom scheme), so the chain fell through to its floor: **the whole file collected in the page and handed over as a blob**. A gigabyte of film meant a gigabyte of RAM, and a Save As dialog at the *end* — which is how the operator noticed. The main process now streams to disk, honours "save automatically" with a remembered folder and no dialog, never overwrites, applies backpressure, and unlinks a cancelled download rather than leaving a truncated file. Measured: the file on disk grows 256 KB → 512 KB → 768 KB → 1 MB as chunks arrive | | D8 | ◐ **PARTIAL** — `electron-builder` configured for deb and rpm, and `meshbay-node-user.service` added. **Found doing it:** the spec installed the *system* template (`User=%i`) into the user unit directory, where systemd refuses the file — the packaged unit could never have started. Fixed, with `test_packaging_units.py`. Nothing has been built: no npm, no rpmbuild here | 1 | | D9 | Python sidecar — `group://` over QUIC | 2 | | D10 | Video thumbnails, own bounded pool (§6.10) | 2 | 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 { |