summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
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 /packages/meshbay-hub/src/meshbay_hub/static/downloads.js
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>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/downloads.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js159
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 };
+}