/** * 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, if a folder has been granted. * * Returns `{writable, name, open}` or null. Null does not mean failure: it * means there is no granted folder, and the caller decides between handing the * browser a blob and asking for a Save As. */ 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, // Reading it back is the only way a page can "open" a file it wrote: hand // the bytes to a tab and let the browser decide what to do with them. No // web page can start a desktop application, or show a file manager. open: async () => { const file = await handle.getFile(); const url = URL.createObjectURL(file); window.open(url, '_blank', 'noopener'); setTimeout(() => URL.revokeObjectURL(url), 60000); }, }; } /** * Below this, a download with no granted folder and no service worker is * collected in memory and handed to the browser. Above it that would mean * holding gigabytes in a tab, so it is worth one Save As dialog instead. */ export const BLOB_LIMIT = 512 * 1024 * 1024; // ── Streaming to disk without the File System Access API ──────────────────── const SW_PATH = '/sw.js'; let _swReady = null; export const STREAMS_VIA_SW = typeof window !== 'undefined' && 'serviceWorker' in navigator && typeof TransformStream === 'function' && window.isSecureContext; async function serviceWorker() { if (!STREAMS_VIA_SW) return null; if (!_swReady) { _swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' }) .then(() => navigator.serviceWorker.ready) .then(async (reg) => { // `reg.active` is not enough. A worker can be active while this page is // still uncontrolled, and an uncontrolled page's requests are never // handed to its fetch handler — so the worker would take our stream and // then never be asked for it. The iframe would 404, nothing would read // the stream, and the first write() would block for good: a download // stuck at one chunk. if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller; // sw.js claims clients on activate, so control usually arrives within a // tick of registration. Wait briefly rather than give up at once. return await new Promise((resolve) => { const done = () => resolve(navigator.serviceWorker.controller || null); navigator.serviceWorker.addEventListener('controllerchange', done, { once: true }); setTimeout(done, 3000); }); }) .catch(err => { console.warn('[MeshBay] service worker unavailable:', err.message); return null; }); } return _swReady; } /** * A sink the browser writes to disk, for Firefox and anything else without the * File System Access API. * * The page keeps the writable half of a stream and gives the readable half to * the service worker, which answers a made-up URL with it. Navigating a hidden * iframe there turns it into an ordinary download: written as it arrives, with * the browser's own progress, and nothing held in the tab. Backpressure is * real — `writer.write()` waits when the browser is behind. * * Returns {writable, name} shaped like the File System Access one, or null if * this browser cannot do it either. */ export async function openStreamedDownload(filename, size = 0) { const worker = await serviceWorker(); if (!worker) return null; const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; const { readable, writable } = new TransformStream(); // The worker tells us when it actually answers the iframe. Without that // confirmation this path fails silently: the write side blocks on // backpressure that will never be relieved, which reads as a download frozen // after one chunk rather than as an error. const chan = new MessageChannel(); const serving = new Promise((resolve) => { chan.port1.onmessage = (e) => { if (e.data && e.data.type === 'mbdl-serving') resolve(true); }; }); try { worker.postMessage({ type: 'mbdl', id, filename, size, readable, port: chan.port2 }, [readable, chan.port2]); } catch (err) { // Transferable streams are what makes the backpressure work; without them // this would be a memory buffer wearing a stream's clothes. console.warn('[MeshBay] streams cannot be transferred here:', err.message); return null; } const frame = document.createElement('iframe'); frame.hidden = true; frame.src = `/_mbdl/${id}`; document.body.appendChild(frame); const answered = await Promise.race([ serving, new Promise((r) => setTimeout(() => r(false), 8000)), ]); if (!answered) { // Some browsers refuse a download started from a hidden iframe, and an // uncontrolled page never reaches the worker at all. Say so and let the // caller fall back rather than hand back a sink nothing drains. console.warn('[MeshBay] the service worker never served the download; ' + 'falling back'); frame.remove(); try { await writable.abort('not served'); } catch { /* already gone */ } return null; } const writer = writable.getWriter(); return { name: filename, writable: { write: (bytes) => writer.write(bytes), close: async () => { await writer.close(); setTimeout(() => frame.remove(), 2000); }, abort: async (reason) => { try { await writer.abort(reason); } catch { /* already gone */ } frame.remove(); }, }, }; }