diff options
Diffstat (limited to 'packages/meshbay-hub')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 134 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/downloads.js | 159 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 19 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 11 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_downloads.py | 97 |
5 files changed, 388 insertions, 32 deletions
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] |