diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 14:34:08 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 14:34:08 +0200 |
| commit | 8528bce49bb637f4e9fb653fff148b7ea45cfdac (patch) | |
| tree | ce34c75cb71fcd2a6526bf72474ca42442f7825d /packages/meshbay-hub/src/meshbay_hub/static/downloads.js | |
| parent | 41e2b79cb1bc9d188853aeff5a55cd2237268587 (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/downloads.js | 159 |
1 files changed, 159 insertions, 0 deletions
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 }; +} |