/** * 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 }; }