import * as downloads from './downloads.js'; import * as platform from './platform.js'; const FILE_ICONS = { video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}', document: '\u{1F4C4}', archive: '\u{1F4E6}', other: '\u{1F4CE}', }; function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; } function formatDate(ts) { return new Date(ts * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } const PREVIEWABLE_TEXT = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; function canPreview(e) { return ['image', 'video', 'audio', 'document'].includes(e.type) || PREVIEWABLE_TEXT.test(e.name); } const CHUNK_SIZE = 1024 * 1024; // Segments allowed in flight while there is room to put them. This is a window, // topped up as segments land, and not a debt released in one go: accumulating a // credit per append and handing the lot over when the buffer finally had room // sent 6 MB in a burst, overshot the target by a minute of film, and then said // nothing for the next forty-six seconds. Measured in Chrome against real // fragmented MP4. A stream that arrives in gulps has no margin for a network // that hesitates, and looks like a hang while it is quiet. const PIPELINE_WINDOW = 8; /** * Open somewhere to write, honouring the user's download setting. * * Returns a target ({writable, name}), null for "no stream available — collect * it and hand the browser a blob", or false for "the person dismissed the * dialog", which is not an error and must not start a transfer. */ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, swSize = size) { // On a desktop build this is the whole answer, and it comes first. // // The two browser paths below are both unavailable there — `showDirectoryPicker` // does not exist, and Chromium refuses a service worker on a custom scheme — // so without this the chain fell all the way through to its floor, which // collects the file in the page and hands the browser a blob. A gigabyte of // film meant a gigabyte of RAM, and a Save As dialog at the *end*. if (platform.capabilities.nativeSave) { try { const native = await platform.nativeSave( filename, { auto: downloads.getMode() === 'auto' }); // Null means the person dismissed the dialog, which is not an error and // must not start a transfer. return native || false; } catch (err) { console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err)); return false; } } try { const target = await downloads.openTarget(filename); if (target) return target; } catch (err) { console.warn('[MeshBay] download folder unusable:', err.message); } // No granted folder. A service worker can still hand the browser a stream to // write, which is how this works at all in Firefox: the alternative there is // to collect gigabytes in a tab. It goes to the browser's own download // folder, without a dialog, which is what "save automatically" meant. if (downloads.getMode() === 'auto') { const streamed = await downloads.openStreamedDownload(filename, swSize); if (streamed) return streamed; // Nothing to stream to: small enough for memory, and no dialog. if (size < downloads.BLOB_LIMIT) return null; } if (!window.showSaveFilePicker) return null; try { const handle = await window.showSaveFilePicker({ suggestedName: filename, ...pickerOpts, }); return { writable: await handle.createWritable(), name: handle.name || filename }; } catch (err) { if (err.name === 'AbortError') return false; throw err; } } /** The download of last resort, for browsers with no way to stream to disk. */ function _saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function _b64ToU8(b64) { const bin = atob(b64); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr; } async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable, signal) { const results = writable ? null : new Array(totalChunks); let nextSend = 0, nextRecv = 0; const inflight = new Array(totalChunks); const fire = () => { while (nextSend < totalChunks && nextSend - nextRecv < PIPELINE_WINDOW) { inflight[nextSend] = transport.fetchChunk(fileId, nextSend); nextSend++; } }; fire(); while (nextRecv < totalChunks) { if (signal && signal.aborted) { const err = new Error('Cancelled'); err.name = 'AbortError'; throw err; } const chunkMsg = await inflight[nextRecv]; let plaintext; if (gekKey && chunkMsg.ct) { plaintext = await window.MeshBayCrypto.decryptChunkBin( gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct); } else if (gekKey && chunkMsg.ct_b64) { plaintext = await window.MeshBayCrypto.decryptChunk( gekKey, fileId, nextRecv, chunkMsg.nonce_b64, chunkMsg.ct_b64); } else { plaintext = _b64ToU8(chunkMsg.ct_b64 || chunkMsg.data_b64); } if (writable) { await writable.write(plaintext); } else { results[nextRecv] = plaintext; } nextRecv++; fire(); if (onChunk) onChunk(plaintext.byteLength, nextRecv, totalChunks); } return results; } /** * Download one file through the transfers widget: picks a target, streams * and decrypts it, and falls back to a blob when there is nowhere to stream * to. Shared by the Files table/toolbar and the video/preview modals' own * download button — both just want "get this entry to disk". */ async function downloadEntry(transfers, transport, gek, entry) { const target = await _openDownloadTarget(entry.name, entry.size); if (target === false) return; // the picker was dismissed const openRef = { url: null }; transfers.start({ kind: 'download', name: (target && target.name) || entry.name, total: entry.size, transport, open: target ? (target.open || null) : () => { if (openRef.url) window.open(openRef.url, '_blank'); }, run: async ({ signal, onProgress }) => { const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); let done = 0; const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; if (target) { try { await pipelinedDownload(transport, gek, entry.id, totalChunks, onChunk, target.writable, signal); await target.writable.close(); } catch (err) { await target.writable.abort().catch(() => {}); throw err; } } else { const chunks = await pipelinedDownload( transport, gek, entry.id, totalChunks, onChunk, null, signal); const blob = new Blob(chunks); _saveBlob(blob, entry.name); openRef.url = URL.createObjectURL(blob); } }, }); } export { FILE_ICONS, formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE, _openDownloadTarget, _saveBlob, _b64ToU8, pipelinedDownload, downloadEntry, };