aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src')
-rw-r--r--packages/meshbay-client/src/main.js100
-rw-r--r--packages/meshbay-client/src/preload.js19
2 files changed, 104 insertions, 15 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),
};
},
});