summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-15 14:34:08 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-15 14:34:08 +0200
commit8528bce49bb637f4e9fb653fff148b7ea45cfdac (patch)
treece34c75cb71fcd2a6526bf72474ca42442f7825d
parent41e2b79cb1bc9d188853aeff5a55cd2237268587 (diff)
downloadmeshbay-8528bce49bb637f4e9fb653fff148b7ea45cfdac.tar.gz
feat(settings): choose between Save As and saving into a folder
Downloading a selection of twenty files meant twenty Save As dialogs, which is the wrong answer for the feature that had just been built. Settings → Downloads now offers saving automatically, and that is the default; asking every time stays available for people who want it. The correction worth recording: a web page cannot be given a filesystem path and cannot read one either. There is no ~/Downloads to configure and nothing to type, on any operating system — which is also why none of this will need changing on Windows. What a browser grants is a handle to a folder the user picked in a dialog, so that is what the setting keeps: picked once, stored in IndexedDB, re-confirmed once a session because the grant comes back as a claim rather than a permission. Where no folder has been granted, and in Firefox and Safari where none can be, files go to the browser's own download folder — which on most machines is the folder that was meant all along. Automatic saving has one risk a dialog does not: it can silently replace a file. It does not — a taken name gets a suffix before the extension, `clip (2).mp4`, so a download folder does not fill up with files the system no longer recognises. That, and the default, are what test_downloads.py pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--docs/USERGUIDE.md18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js134
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js159
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css11
-rw-r--r--packages/meshbay-hub/tests/test_downloads.py97
6 files changed, 406 insertions, 32 deletions
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index eec61d9..1bc3045 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -540,6 +540,24 @@ context menu at a time was the thing that made it awkward.
Selection is remembered as you walk into folders, so you can tick something in
one and something else in another before choosing an action.
+**Where downloads are written** is a setting, under Settings → Downloads:
+
+- **Save automatically** (the default) writes into a folder you pick once, with
+ no dialog. Downloading twenty files puts twenty files there. A name already in
+ use gets a suffix — `clip (2).mp4` — rather than replacing what is there.
+- **Ask every time** opens a Save As dialog per file, which is right for one file
+ and wrong for a selection of twenty.
+
+A web page cannot be given a filesystem path, and cannot read one either: there
+is no `~/Downloads` to configure, on any operating system, and nothing changes
+here on Windows for the same reason. What a browser grants is access to a folder
+the user picked in a dialog, and MeshBay only ever writes inside it. That grant
+is remembered, but the browser may ask you to confirm it once per session.
+
+Firefox and Safari have no File System Access API, so no folder can be granted:
+downloads go to the browser's own download folder, and Settings says so instead
+of offering a choice that would do nothing.
+
**Transfers run outside the page.** They are listed in the widget next to the
bell, with a progress bar, the current rate, and a cancel button each:
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 03b76d5..b84b7b1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -5,6 +5,7 @@ import {
import { t, getLocale, setLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
import { transfers, formatSpeed } from './transfers.js';
+import * as downloads from './downloads.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -930,6 +931,32 @@ function canPreview(e) {
const CHUNK_SIZE = 1024 * 1024;
const PIPELINE_WINDOW = 8;
+/**
+ * Open somewhere to write, honouring the user's download setting.
+ *
+ * Returns a target ({writable, name}), null for "no stream available — collect
+ * it and hand the browser a blob", or false for "the person dismissed the
+ * dialog", which is not an error and must not start a transfer.
+ */
+async function _openDownloadTarget(filename, pickerOpts = {}) {
+ try {
+ const target = await downloads.openTarget(filename);
+ if (target) return target;
+ } catch (err) {
+ console.warn('[MeshBay] download folder unusable:', err.message);
+ }
+ if (!window.showSaveFilePicker) return null;
+ try {
+ const handle = await window.showSaveFilePicker({
+ suggestedName: filename, ...pickerOpts,
+ });
+ return { writable: await handle.createWritable(), name: handle.name || filename };
+ } catch (err) {
+ if (err.name === 'AbortError') return false;
+ throw err;
+ }
+}
+
/** The download of last resort, for browsers with no way to stream to disk. */
function _saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
@@ -1172,33 +1199,26 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
if (!transport || !transport.connected) return;
const gek = gekRef.current;
- // The file picker has to be opened here, in the click, before anything is
- // handed to the store: a browser only grants one from a user gesture.
- let handle = null;
- if (window.showSaveFilePicker) {
- try {
- handle = await window.showSaveFilePicker({ suggestedName: entry.name });
- } catch (err) {
- if (err.name === 'AbortError') return;
- throw err;
- }
- }
+ // Both of these have to happen inside the click: a browser grants a file
+ // picker, and re-grants a folder, only from a user gesture.
+ const target = await _openDownloadTarget(entry.name);
+ if (target === false) return; // the picker was dismissed
transfers.start({
- kind: 'download', name: entry.name, total: entry.size, transport,
+ kind: 'download', name: (target && target.name) || entry.name,
+ total: entry.size, transport,
run: async ({ signal, onProgress }) => {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
let done = 0;
const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); };
- if (handle) {
- const writable = await handle.createWritable();
+ if (target) {
try {
await pipelinedDownload(transport, gek, entry.id, totalChunks,
- onChunk, writable, signal);
- await writable.close();
+ onChunk, target.writable, signal);
+ await target.writable.close();
} catch (err) {
- await writable.abort().catch(() => {});
+ await target.writable.abort().catch(() => {});
throw err;
}
} else {
@@ -1291,19 +1311,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0);
const suggested = (dir.split('/').pop() || 'files') + '.zip';
- let handle = null;
- if (window.showSaveFilePicker) {
- try {
- handle = await window.showSaveFilePicker({
- suggestedName: suggested,
- types: [{ description: 'ZIP archive',
- accept: { 'application/zip': ['.zip'] } }],
- });
- } catch (err) {
- if (err.name === 'AbortError') return;
- throw err;
- }
- } else if (!confirm(t('group.zip_no_stream', {
+ const target = await _openDownloadTarget(suggested, {
+ types: [{ description: 'ZIP archive',
+ accept: { 'application/zip': ['.zip'] } }],
+ });
+ if (target === false) return;
+ if (!target && !confirm(t('group.zip_no_stream', {
size: formatSize(totalBytes), name: suggested,
}))) {
return;
@@ -1311,9 +1324,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth,
const gek = gekRef.current;
transfers.start({
- kind: 'download', name: suggested, total: totalBytes, transport,
+ kind: 'download', name: (target && target.name) || suggested,
+ total: totalBytes, transport,
run: async ({ signal, onProgress }) => {
- const writable = handle ? await handle.createWritable() : null;
+ const writable = target ? target.writable : null;
const parts = writable ? null : [];
let written = 0;
try {
@@ -2651,6 +2665,20 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
}
}, [muted, user.token]);
+ const [dlMode, setDlMode] = useState(() => downloads.getMode());
+ const [dlDir, setDlDir] = useState(null);
+
+ useEffect(() => { downloads.savedDirectory().then(setDlDir); }, []);
+
+ const pickFolder = useCallback(async () => {
+ try {
+ const handle = await downloads.chooseDirectory();
+ setDlDir(handle);
+ } catch (err) {
+ if (err.name !== 'AbortError') setNodeKeyStatus(err.message);
+ }
+ }, []);
+
const submitNodeKey = useCallback(async () => {
const key = nodeKey.trim();
if (!key) return;
@@ -2676,6 +2704,48 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) {
<h2>${t('settings.title')}</h2>
<div class="settings-section">
+ <h3 class="settings-heading">${t('settings.downloads')}</h3>
+ ${!downloads.SUPPORTED
+ ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>`
+ : html`
+ <label class="settings-choice">
+ <input type="radio" name="dlmode" checked=${dlMode === 'auto'}
+ onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} />
+ <span>
+ <strong>${t('settings.dl_auto')}</strong>
+ <span class="settings-hint">${t('settings.dl_auto_hint')}</span>
+ </span>
+ </label>
+ <label class="settings-choice">
+ <input type="radio" name="dlmode" checked=${dlMode === 'ask'}
+ onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} />
+ <span>
+ <strong>${t('settings.dl_ask')}</strong>
+ <span class="settings-hint">${t('settings.dl_ask_hint')}</span>
+ </span>
+ </label>
+ <div class="settings-row" style="margin-top:10px">
+ <span class="settings-label">
+ ${dlDir ? t('settings.dl_folder', { name: dlDir.name })
+ : t('settings.dl_no_folder')}
+ </span>
+ <span>
+ <button class="admin-btn" onClick=${pickFolder}>
+ ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')}
+ </button>
+ ${dlDir && html`
+ <button class="btn-secondary" onClick=${async () => {
+ await downloads.forgetDirectory();
+ setDlDir(null);
+ }}>${t('settings.dl_forget')}</button>
+ `}
+ </span>
+ </div>
+ <p class="settings-hint">${t('settings.dl_path_note')}</p>
+ `}
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('settings.profile')}</h3>
<div class="settings-row">
<span class="settings-label">${t('settings.username')}</span>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
new file mode 100644
index 0000000..9c6ced1
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -0,0 +1,159 @@
+/**
+ * Where downloads go.
+ *
+ * A web page cannot be told a path. It cannot read "~/Downloads", cannot write
+ * to it, and cannot be configured with "C:\Users\…" either — which is also why
+ * none of this needs to change when someone runs it on Windows. What a browser
+ * grants is a *handle* to a directory the user picked, once, in a dialog. That
+ * handle is what this module keeps.
+ *
+ * Two modes, and the default matters:
+ *
+ * auto (default) — write into the granted folder without asking. Downloading
+ * twelve files puts twelve files there. Without a granted folder, the file
+ * goes wherever the browser puts downloads, which on most machines is the
+ * same folder anyway.
+ * ask — a Save As dialog for every file, which is the right answer for one
+ * file and the wrong one for twelve.
+ *
+ * Firefox and Safari have no File System Access API at all: no folder can be
+ * granted there, and both modes fall back to the browser's own download folder.
+ * The setting says so rather than offering a choice that does nothing.
+ */
+
+const PREF_KEY = 'mb_dl_mode';
+const IDB_NAME = 'meshbay-downloads';
+const IDB_STORE = 'handles';
+const HANDLE_KEY = 'target-dir';
+
+export const SUPPORTED = typeof window !== 'undefined'
+ && typeof window.showDirectoryPicker === 'function';
+
+export function getMode() {
+ const v = localStorage.getItem(PREF_KEY);
+ return v === 'ask' ? 'ask' : 'auto';
+}
+
+export function setMode(mode) {
+ localStorage.setItem(PREF_KEY, mode === 'ask' ? 'ask' : 'auto');
+}
+
+// ── The granted directory ────────────────────────────────────────────────────
+
+function idb() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(IDB_NAME, 1);
+ req.onupgradeneeded = () => {
+ if (!req.result.objectStoreNames.contains(IDB_STORE)) {
+ req.result.createObjectStore(IDB_STORE);
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+async function idbGet(key) {
+ try {
+ const db = await idb();
+ const tx = db.transaction(IDB_STORE, 'readonly');
+ const req = tx.objectStore(IDB_STORE).get(key);
+ const out = await new Promise((r, j) => {
+ req.onsuccess = () => r(req.result); req.onerror = () => j(req.error);
+ });
+ db.close();
+ return out || null;
+ } catch { return null; }
+}
+
+async function idbPut(key, value) {
+ try {
+ const db = await idb();
+ const tx = db.transaction(IDB_STORE, 'readwrite');
+ if (value === null) tx.objectStore(IDB_STORE).delete(key);
+ else tx.objectStore(IDB_STORE).put(value, key);
+ await new Promise((r, j) => { tx.oncomplete = r; tx.onerror = j; });
+ db.close();
+ } catch { /* best effort: the setting simply will not stick */ }
+}
+
+/** The folder handle, if one was ever granted. Says nothing about permission. */
+export async function savedDirectory() {
+ return SUPPORTED ? idbGet(HANDLE_KEY) : null;
+}
+
+/** Ask for a folder. Must be called from a click — browsers require a gesture. */
+export async function chooseDirectory() {
+ const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
+ await idbPut(HANDLE_KEY, handle);
+ return handle;
+}
+
+export async function forgetDirectory() {
+ await idbPut(HANDLE_KEY, null);
+}
+
+/**
+ * Permission survives a reload as a *claim*, not as a grant: the handle comes
+ * back from IndexedDB in state "prompt" and has to be re-authorized once per
+ * session. Doing that quietly during a download click is why this takes no
+ * arguments and returns a boolean instead of throwing.
+ */
+export async function ensurePermission(handle, { prompt = true } = {}) {
+ if (!handle) return false;
+ try {
+ const opts = { mode: 'readwrite' };
+ if (await handle.queryPermission(opts) === 'granted') return true;
+ if (!prompt) return false;
+ return await handle.requestPermission(opts) === 'granted';
+ } catch {
+ return false;
+ }
+}
+
+// ── Names ────────────────────────────────────────────────────────────────────
+
+/**
+ * A name not already taken, as "clip (2).mp4".
+ *
+ * Writing into a folder repeatedly is the whole point of the automatic mode, so
+ * the second copy of a file must not silently replace the first. `exists` is
+ * passed in so this can be tested without a filesystem.
+ */
+export async function freeName(name, exists) {
+ if (!await exists(name)) return name;
+ const dot = name.lastIndexOf('.');
+ const stem = dot > 0 ? name.slice(0, dot) : name;
+ const ext = dot > 0 ? name.slice(dot) : '';
+ for (let n = 2; n < 1000; n++) {
+ const candidate = `${stem} (${n})${ext}`;
+ if (!await exists(candidate)) return candidate;
+ }
+ return `${stem} (${Date.now()})${ext}`;
+}
+
+/**
+ * Where this download should be written.
+ *
+ * Returns a writable stream, or null meaning "there is nowhere to stream to —
+ * collect it and hand the browser a blob". The caller opens the picker itself
+ * in "ask" mode, because that one has to happen inside the click.
+ */
+export async function openTarget(filename) {
+ if (!SUPPORTED || getMode() === 'ask') return null;
+
+ const dir = await savedDirectory();
+ if (!dir || !await ensurePermission(dir)) return null;
+
+ const exists = async (name) => {
+ try {
+ await dir.getFileHandle(name);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+ const name = await freeName(filename, exists);
+ const handle = await dir.getFileHandle(name, { create: true });
+ return { writable: await handle.createWritable(), name };
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index f43981b..25fbd77 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -124,6 +124,25 @@ const en = {
// Settings
'settings.title': 'Settings',
'settings.coming_soon': 'Coming soon.',
+ 'settings.downloads': 'Downloads',
+ 'settings.dl_auto': 'Save automatically',
+ 'settings.dl_auto_hint': 'Files go straight into the folder you choose below, '
+ + 'with no dialog. Downloading twenty files puts twenty files there.',
+ 'settings.dl_ask': 'Ask every time',
+ 'settings.dl_ask_hint': 'A Save As dialog for each file — one per file, '
+ + 'including when you download a selection.',
+ 'settings.dl_folder': 'Folder: {name}',
+ 'settings.dl_no_folder': 'No folder chosen — downloads go wherever your browser '
+ + 'puts them',
+ 'settings.dl_choose': 'Choose folder',
+ 'settings.dl_change': 'Change',
+ 'settings.dl_forget': 'Forget',
+ 'settings.dl_path_note': 'A web page cannot be given a path, so there is nothing '
+ + 'to type here: your browser grants access to the folder you pick, and MeshBay '
+ + 'only ever writes inside it. You may be asked to confirm once per session.',
+ 'settings.dl_unsupported': 'This browser cannot write into a folder of your '
+ + 'choosing (no File System Access API), so downloads go to its own download '
+ + 'folder. Chrome and Edge support choosing one.',
'settings.profile': 'Profile',
'settings.username': 'Username',
'settings.node_pins': 'Node identities',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 24e8843..2296b2d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1179,6 +1179,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.group-desc-edit textarea:focus { outline: none; border-color: var(--border-focus); }
.group-desc-edit div { display: flex; gap: 6px; margin-top: 4px; }
+.settings-choice {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 7px 0;
+ cursor: pointer;
+}
+.settings-choice input { margin-top: 3px; flex-shrink: 0; }
+.settings-choice strong { display: block; font-size: 0.88em; color: var(--text); }
+.settings-choice .settings-hint { display: block; margin-top: 2px; }
+
/* ── Transfers widget ────────────────────────────────────────────────────── */
.transfer-wrap { position: relative; display: flex; align-items: center; }
diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py
new file mode 100644
index 0000000..cc4fdd1
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_downloads.py
@@ -0,0 +1,97 @@
+"""
+Where downloads are written.
+
+The module is mostly browser plumbing — a directory handle from a picker, kept
+in IndexedDB — but two pieces decide behaviour and can be checked here: that
+automatic is the default, and that writing into the same folder repeatedly does
+not quietly replace what is already there. The second one is the whole risk of
+the automatic mode: a Save As dialog warns you about a collision, and a folder
+you never look at does not.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+DOWNLOADS = STATIC / "downloads.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not DOWNLOADS.exists(),
+ reason="node or the SPA sources are not available")
+
+
+def _run(body, tmp_path):
+ module = tmp_path / "downloads.mjs"
+ module.write_text(DOWNLOADS.read_text())
+ script = tmp_path / "case.mjs"
+ script.write_text(
+ # A localStorage good enough for a preference, so the module can be
+ # imported outside a browser at all.
+ "const store = new Map();\n"
+ "globalThis.localStorage = {\n"
+ " getItem: k => (store.has(k) ? store.get(k) : null),\n"
+ " setItem: (k, v) => store.set(k, String(v)),\n"
+ "};\n"
+ f"const M = await import('{module.as_posix()}');\n"
+ "const out = [];\n"
+ "const say = (...a) => out.push(...a);\n"
+ f"{body}\n"
+ "console.log(JSON.stringify(out));\n")
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def test_saving_automatically_is_the_default(tmp_path):
+ """
+ Grouped downloads are the reason this setting exists: twelve files must not
+ mean twelve dialogs unless someone asked for that.
+ """
+ assert _run("say(M.getMode());", tmp_path) == ["auto"]
+
+
+def test_the_choice_is_remembered_and_nothing_else_is_accepted(tmp_path):
+ result = _run("""
+M.setMode('ask'); say(M.getMode());
+M.setMode('auto'); say(M.getMode());
+M.setMode('nonsense'); say(M.getMode());
+""", tmp_path)
+ assert result == ["ask", "auto", "auto"]
+
+
+def test_a_second_copy_does_not_replace_the_first(tmp_path):
+ result = _run("""
+const taken = new Set(['clip.mp4', 'clip (2).mp4', 'notes']);
+const exists = async n => taken.has(n);
+say(await M.freeName('clip.mp4', exists));
+say(await M.freeName('other.mp4', exists));
+say(await M.freeName('notes', exists));
+say(await M.freeName('archive.tar.gz', exists));
+""", tmp_path)
+ assert result == [
+ "clip (3).mp4", # (2) was taken as well
+ "other.mp4", # free: left alone
+ "notes (2)", # no extension to keep
+ "archive.tar.gz", # free, and the double extension is not mangled
+ ]
+
+
+def test_the_suffix_goes_before_the_extension(tmp_path):
+ """
+ "clip.mp4 (2)" would stop being a video as far as the operating system is
+ concerned, which is how a download folder ends up full of files nothing opens.
+ """
+ result = _run("""
+say(await M.freeName('clip.mp4', async () => false));
+say(await M.freeName('clip.mp4', async n => n === 'clip.mp4'));
+""", tmp_path)
+ assert result == ["clip.mp4", "clip (2).mp4"]
+
+
+def test_a_browser_without_the_api_reports_it(tmp_path):
+ """`SUPPORTED` decides whether Settings offers a choice or an explanation."""
+ assert _run("say(M.SUPPORTED);", tmp_path) == [False]