From cf418a095b07c8127042390c748e721d1433879b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 18 Aug 2026 13:40:15 +0200 Subject: fix(client): downloads stream to disk, and two rough edges on first run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- packages/meshbay-client/src/main.js | 100 ++++++++++++++++++--- packages/meshbay-client/src/preload.js | 19 +++- packages/meshbay-hub/src/meshbay_hub/static/app.js | 74 +++++++++++---- .../src/meshbay_hub/static/locales/de.js | 1 + .../src/meshbay_hub/static/locales/en.js | 1 + .../src/meshbay_hub/static/locales/es.js | 1 + .../src/meshbay_hub/static/locales/fr.js | 1 + .../src/meshbay_hub/static/locales/it.js | 1 + .../src/meshbay_hub/static/locales/ja.js | 1 + .../src/meshbay_hub/static/locales/nl.js | 1 + .../src/meshbay_hub/static/locales/pl.js | 1 + .../src/meshbay_hub/static/locales/pt-BR.js | 1 + .../src/meshbay_hub/static/locales/zh-CN.js | 1 + .../meshbay-hub/src/meshbay_hub/static/platform.js | 27 +++++- .../meshbay-hub/src/meshbay_hub/static/style.css | 8 ++ 15 files changed, 200 insertions(+), 38 deletions(-) (limited to 'packages') 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`