/** * 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 }); const writable = await handle.createWritable(); return { // `abort()` on a FileSystemWritableFileStream discards the swap file and // leaves the target as it was — which here is the empty file // `getFileHandle({create: true})` just made, before a single byte arrived. // So every cancelled or failed download left a 0-byte file behind, and // because `freeName` avoids collisions, three cancels left `film.mkv`, // `film (2).mkv` and `film (3).mkv`, all empty, in the person's folder. // // Removing it is safe *here and only here*: `freeName` guarantees this name // was not taken, so the file being deleted is one we created moments ago // and nothing else. The `showSaveFilePicker` path in file-utils.js must not // do the same — there the person may have picked an existing file, whose // contents `abort()` correctly preserves. writable: { write: (bytes) => writable.write(bytes), close: () => writable.close(), abort: async (reason) => { try { await writable.abort(reason); } catch { /* already gone */ } try { await dir.removeEntry(name); } catch { /* already gone */ } }, }, name, // A held-open `FileSystemWritableFileStream`: pausing is simply not // writing to it, and nothing is lost while nothing is written. pausable: true, // 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'; // Kept in step with sw.js's own PREFIX. const PREFIX_PATH = '/_mbdl/'; // On Firefox and Safari this worker is not a nicety, it is the only unbounded // way to write a download to disk: the File System Access API does not exist // there, and OPFS is capped at 10% of the volume's size (measured on Firefox // 154: 389,233,459 bytes on a 3,892,334,592-byte volume, refused to the byte), // which a film can exceed. Everything below exists to make sure this path is // available when it is needed, because there is nothing underneath it. // // How long to wait for this page to become *controlled*. Generous on purpose: // the cost of waiting is a spinner, and the cost of giving up is a download // this browser then cannot do at all. const SW_CONTROL_BUDGET_MS = 15000; // How long to wait for the worker to confirm it answered the iframe. const SW_SERVED_BUDGET_MS = 15000; // A transient miss gets a second go with a fresh id and a fresh iframe. const SW_ATTEMPTS = 2; // How often the page pokes the worker while a download is being written. // Firefox terminates a service worker that has had no event for roughly thirty // seconds, and a streaming response does not count as activity — so a download // that takes longer than that lost its reader half way through. Ten seconds // leaves a wide margin and costs one empty message. const SW_KEEPALIVE_MS = 10000; // And the ping stops on its own once nothing has been written for this long. // Well past any real gap between chunks, and short enough that an abandoned // target does not ping for ever. Bounded because the alternative is a timer // whose lifetime depends on every caller remembering to close its sink. const SW_KEEPALIVE_IDLE_MS = 120000; // How long to spend waking the worker, and then confirming it holds the stream, // before starting the navigation that has to find it. // // Both are answered in milliseconds when the worker is alive. They exist for // when it is not: `pending` lives in the worker's memory, and one with nothing // to do is terminated within tens of seconds — which a long upload spends // without giving it a single event. A stream handed to a worker in that state // is lost, and the iframe then wakes it with nothing to find, which is a 404 // from the hub and fifteen seconds of silence per attempt. const SW_WAKE_BUDGET_MS = 3000; // Holds a *successful* controller, or an in-flight attempt. Never a failure — // see serviceWorker(). The previous version cached the rejected/null result // for the life of the page, so one slow first click (a cold worker, a busy // phone) left the tab unable to stream anything ever again, with no way back // but a reload nobody knew to do. let _swPromise = null; let _lastFailure = ''; /** Why the streamed path last declined, for a message worth reading. */ export function lastStreamFailure() { return _lastFailure; } export const STREAMS_VIA_SW = typeof window !== 'undefined' && 'serviceWorker' in navigator && typeof TransformStream === 'function' && window.isSecureContext; /** Resolves with the controller, or null once `budgetMs` is spent. */ function _awaitControl(budgetMs) { if (navigator.serviceWorker.controller) { return Promise.resolve(navigator.serviceWorker.controller); } return new Promise((resolve) => { let timer = 0; const done = () => { clearTimeout(timer); navigator.serviceWorker.removeEventListener('controllerchange', done); resolve(navigator.serviceWorker.controller || null); }; navigator.serviceWorker.addEventListener('controllerchange', done); timer = setTimeout(done, budgetMs); }); } /** * The promise's value, or `TIMED_OUT` once `ms` is spent. * * A rejection is still a rejection — the caller reports those — and the timer * is cleared either way, so nothing is left running behind a fast answer. */ const TIMED_OUT = Symbol('timed out'); function _within(promise, ms) { return new Promise((resolve, reject) => { const timer = setTimeout(() => resolve(TIMED_OUT), ms); promise.then((v) => { clearTimeout(timer); resolve(v); }, (err) => { clearTimeout(timer); reject(err); }); }); } async function _claimController(budgetMs) { // Every wait in here is inside one budget. Neither of the first two used to // have any deadline at all, and `_swPromise` is shared, so a single one of // them left every download on the page waiting on the same promise for ever // — four rows stuck at "preparing", with nothing in the node's journal // because no transfer had been asked for yet. const deadline = Date.now() + budgetMs; const left = () => Math.max(0, deadline - Date.now()); let reg = await _within( navigator.serviceWorker.register(SW_PATH, { scope: '/' }), left()); if (reg === TIMED_OUT) { _lastFailure = `the worker did not register within ${budgetMs / 1000}s`; return null; } // `register()` resolves as soon as the registration object exists, with // nothing but an *installing* worker; `ready` is what waits for an active // one. A worker that never finishes installing leaves `ready` pending // indefinitely — measured on Firefox 154: an install handler that rejects // leaves `ready` unsettled past ten seconds while `register()` returns in // seven milliseconds. let ready = await _within(navigator.serviceWorker.ready, left()); if (ready === TIMED_OUT && !reg.active) { // A registration stuck with nothing but an installing worker does not heal // on its own: every later visit finds the same registration and waits on // the same `ready`. Left alone it is permanent, and it costs Firefox the // only unbounded way it has to write a download to disk — so the stuck // registration is thrown away and asked for once more, with its own budget, // rather than reported and lived with. console.warn('[MeshBay] the download worker never became active; ' + 'discarding the registration and asking again'); try { await _within(reg.unregister(), budgetMs); } catch (err) { console.warn('[MeshBay] could not discard it:', err.message); } const again = await _within( navigator.serviceWorker.register(SW_PATH, { scope: '/' }), budgetMs); if (again === TIMED_OUT) { _lastFailure = `the worker did not register within ${budgetMs / 1000}s`; return null; } reg = again; ready = await _within(navigator.serviceWorker.ready, budgetMs); if (ready === TIMED_OUT && !reg.active) { _lastFailure = `the worker did not become active within ${budgetMs / 1000}s` + ', even after its registration was discarded'; return null; } } // Past here `ready` may still be waiting on a *newer* worker that cannot // install while an older one is perfectly able to serve. An active worker is // all this path needs, so a stuck `ready` is not on its own a reason to give // up a capability Firefox has nothing else to offer for. // // Being active is not being in control, either. An uncontrolled page's // requests never reach the fetch handler, so the worker would take our stream // and never be asked for it — the download then freezes after exactly one // chunk, which is how that was found. // // Ask for the claim *before* waiting, not after. A page that is uncontrolled // while an active worker exists will not be claimed on its own — a document // fetched by a hard reload is exactly that shape — so the whole control // budget is spent waiting for something that is not coming, and it was: about // thirty seconds during which somebody clicks download and watches four rows // hang. Asking first costs one message and makes the common case immediate. if (!navigator.serviceWorker.controller && reg.active) { try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ } } const controller = await _awaitControl(left()); if (controller) return controller; // Active but not controlling after the whole budget. `sw.js` calls // `clients.claim()` on activate, so this is rare; when it happens the page // was loaded before any worker existed and the claim was missed. Ask the // active worker to claim again rather than declare the path unavailable. // This wait is deliberately outside the budget above: giving up here would // cost Firefox the only unbounded way it has to write a download to disk. if (reg.active) { try { reg.active.postMessage({ type: 'mbdl-claim' }); } catch { /* gone */ } return await _awaitControl(2000); } return null; } /** * Register the worker and get this page controlled, now. * * Called at application start, not at the first download. Registration used to * happen inside the first click, so that click paid install, activate and claim * while somebody watched a button do nothing — and if the claim did not land * inside the budget, the download fell through to a path that cannot hold a * film. By the time anyone clicks anything, this has long since finished. * * Fire-and-forget by design: nothing waits on it, and a failure here is not * fatal because `serviceWorker()` will simply try again. */ export function primeServiceWorker() { if (!STREAMS_VIA_SW) return; _priming = _repairIfBypassed() .then(() => serviceWorker()) .catch(() => {}); } // Resolved once the check below has run. A download that starts while it is // still in flight waits for it rather than racing it: on a page that turns out // to be unservable the click would otherwise spend thirty seconds failing on a // path that is about to be repaired. let _priming = null; // Set for the life of this tab, so the repair below happens at most once and // can never become a reload loop. const REPAIRED_KEY = 'meshbay.sw-repaired'; /** * Was this document loaded with the service worker bypassed? * * A document fetched by a **hard** reload — Ctrl+F5, Ctrl+Shift+R — is loaded * with the worker bypassed. It can still be claimed afterwards, so * `navigator.serviceWorker.controller` comes back and every control check * passes; but the navigations it starts keep missing the worker, and the hidden * iframe a streamed download needs *is* a navigation. On Firefox and Safari * that is the only way to write a file too large to hold in memory, so every * download fails for the life of that page. * * Measured on Chrome, at document start, before anything registers: * * first visit controller false, registration false * ordinary reload controller true, registration true * hard reload controller false, registration true * * So being uncontrolled while an active registration already exists names the * case exactly, and costs nothing to ask. * * The first version of this asked by *performing a download* — a four-byte * stream through a hidden iframe — which was both unreliable and expensive: * Chrome rations downloads a page starts without a user gesture to about three, * so the test competed with the person's real downloads for that budget and its * answer depended on how many had been spent. It reloaded a healthy page on * every first visit, taking the group's WebRTC session down with it. */ const _controlledAtLoad = STREAMS_VIA_SW && Boolean(navigator.serviceWorker.controller); // Started here, at module load, because `register()` would make the answer // true whatever it was. const _registeredAtLoad = STREAMS_VIA_SW ? navigator.serviceWorker.getRegistration('/') .then((reg) => Boolean(reg && reg.active)).catch(() => false) : Promise.resolve(false); /** An ordinary reload puts the document back under the worker, so do that once. */ async function _repairIfBypassed() { if (_controlledAtLoad) return; // Uncontrolled with nothing registered is a first visit, not a bypass: the // worker is being installed right now and the page is fine after it claims. if (!await _registeredAtLoad) return; let repaired = false; try { repaired = sessionStorage.getItem(REPAIRED_KEY) === '1'; } catch { /* blocked */ } if (repaired) return; console.warn('[MeshBay] this page was loaded with the download worker ' + 'bypassed (a hard reload does that) — reloading once to put it ' + 'back under the worker\u2019s control'); try { sessionStorage.setItem(REPAIRED_KEY, '1'); } catch { /* blocked */ } location.reload(); } async function serviceWorker(controlMs = SW_CONTROL_BUDGET_MS) { if (!STREAMS_VIA_SW) { _lastFailure = 'no service worker support in this browser'; return null; } if (navigator.serviceWorker.controller) return navigator.serviceWorker.controller; if (!_swPromise) { _swPromise = _claimController(controlMs).catch((err) => { _lastFailure = 'service worker registration failed: ' + err.message; console.warn('[MeshBay]', _lastFailure); return null; }); } const controller = await _swPromise; if (!controller) { // Not remembered. The next attempt starts from scratch, which is the whole // point: these failures are transient far more often than they are final. _swPromise = null; if (!_lastFailure) _lastFailure = 'the page did not come under the worker’s control'; } return controller; } /** * 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, { controlMs = SW_CONTROL_BUDGET_MS, servedMs = SW_SERVED_BUDGET_MS, attempts = SW_ATTEMPTS, } = {}) { if (_priming) { try { await _priming; } catch { /* reported already */ } } for (let attempt = 1; attempt <= attempts; attempt++) { const target = await _attemptStreamedDownload( filename, size, attempt, controlMs, servedMs); if (target) return target; // A miss is usually the worker having been asleep or the navigation losing // a race, not this browser being unable. Falling through on the first miss // is what sent large downloads to the in-memory floor. if (attempt < attempts) { console.warn(`[MeshBay] streamed download attempt ${attempt} missed ` + `(${_lastFailure}); retrying`); } } return null; } /** * Get the worker running, and know that it is. * * `mbdl-ping` exists already — the page sends it every ten seconds *while* * writing, because a streaming response does not count as activity and Firefox * kills an idle worker mid-download. Nothing sent one before *starting* a * download, which is the case that fails after a long upload has left the * worker with nothing to do for minutes. * * Never fatal: a worker that does not answer may still be perfectly able to * serve, and the caller finds that out the honest way. */ async function _wake(worker) { const chan = new MessageChannel(); const pong = new Promise((resolve) => { chan.port1.onmessage = () => resolve(true); }); try { worker.postMessage({ type: 'mbdl-ping' }, [chan.port2]); } catch { return false; } const awake = await Promise.race([ pong, new Promise((r) => setTimeout(() => r(false), SW_WAKE_BUDGET_MS)), ]); try { chan.port1.close(); } catch { /* already gone */ } return awake; } async function _attemptStreamedDownload(filename, size, attempt, controlMs, servedMs) { const worker = await serviceWorker(controlMs); 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(); let markReady = null; const held = new Promise((resolve) => { markReady = resolve; }); const serving = new Promise((resolve) => { chan.port1.onmessage = (e) => { if (!e.data) return; // The worker says it has the stream. Waiting for this is what stops the // navigation racing a worker that was asleep when we posted. if (e.data.type === 'mbdl-ready') markReady(true); if (e.data.type === 'mbdl-serving') resolve(true); }; }); // Wake it first, and wait for the answer. A worker that has been idle through // a long upload is terminated, and a message posted to it in that state is // lost — silently, which is the whole difficulty. await _wake(worker); 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. This one is // final rather than transient — a browser does not grow the capability // between two attempts — so it is reported as such. _lastFailure = 'this browser cannot transfer a stream to the worker: ' + err.message; console.warn('[MeshBay]', _lastFailure); return null; } // Confirmed, not assumed. A worker that predates this sends no answer, and // then navigating anyway is exactly what this code did before. await Promise.race([ held, new Promise((r) => setTimeout(r, SW_WAKE_BUDGET_MS)), ]); const frame = document.createElement('iframe'); frame.hidden = true; frame.src = `${PREFIX_PATH}${id}`; document.body.appendChild(frame); const answered = await Promise.race([ serving, new Promise((r) => setTimeout(() => r(false), servedMs)), ]); if (!answered) { // Some browsers refuse a download started from a hidden iframe, and an // uncontrolled page never reaches the worker at all. Tear this attempt // down completely — the stream, the port and the frame — so a retry starts // clean rather than leaving a half-open sink behind. _lastFailure = `the worker did not answer the download within ` + `${servedMs / 1000}s (attempt ${attempt})`; console.warn('[MeshBay]', _lastFailure); frame.remove(); try { chan.port1.close(); } catch { /* already gone */ } try { await writable.abort('not served'); } catch { /* already gone */ } return null; } _lastFailure = ''; // Every few seconds for as long as this download is being written. Well // inside the ~30 s Firefox allows an idle worker, and cheap: one postMessage // with no payload. Cleared by close() and abort() below, so a finished // download leaves no timer behind. // Self-limiting, and that is not belt-and-braces: a target can be opened and // then never written to — a transfer cancelled while it waits for a slot // never runs, so nothing calls close() or abort() — and an interval nobody // clears pings for the life of the page. It also kept the Node test process // alive for ever, which is the same defect wearing a louder symptom (the // MessagePort above did exactly this a few hours earlier). let lastWrite = Date.now(); const keepAlive = setInterval(() => { if (Date.now() - lastWrite > SW_KEEPALIVE_IDLE_MS) { clearInterval(keepAlive); return; } try { worker.postMessage({ type: 'mbdl-ping' }); } catch { /* gone */ } }, SW_KEEPALIVE_MS); // The port has delivered the one message it exists for. Closing it matters: // an open MessagePort is a live handle, and one was leaked per download for // the life of the page. (It is also what hung the Node harness in // test_streamed_download_reliability.py — there the leak is a process that // never exits, which is the same defect wearing a louder symptom.) try { chan.port1.close(); } catch { /* already gone */ } const writer = writable.getWriter(); return { name: filename, // **Not pausable, and this is not a limitation of our code.** The browser // is already writing an HTTP response into its own download folder: not // writing to the stream stalls that download where we cannot see it or // resume it, and an idle worker is terminated within seconds, taking the // stream with it. A pause button here would restart from zero, which is // worse than not offering one. Offering Firefox and Safari a resumable // target means a resumable sink of our own, which is a larger piece of work // than the button it would enable. pausable: false, writable: { write: (bytes) => { lastWrite = Date.now(); return writer.write(bytes); }, close: async () => { clearInterval(keepAlive); await writer.close(); setTimeout(() => frame.remove(), 2000); }, abort: async (reason) => { clearInterval(keepAlive); try { await writer.abort(reason); } catch { /* already gone */ } frame.remove(); }, }, }; }