import * as downloads from './downloads.js'; import * as platform from './platform.js'; import { t } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.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); } }, }); } /** * Download a directory as a zip, written straight to disk. * * An archive of a group directory is routinely tens of gigabytes, so it is * never held anywhere: each file is fetched chunk by chunk, decrypted, and * handed to the zip writer, which hands it to the file the browser opened. * Peak memory is one chunk plus one small record per file. * * Without the File System Access API there is nowhere to stream to, and the * only alternative is to build the whole thing in memory — so that path is * offered but says what it costs first. * * Lifted out of files-app.js (docs/photos.md §3) so photos-app.js's own * "zip this album" button calls the same implementation rather than a * second one — nothing here is Files-specific once `entries`/`transport`/ * `gek`/`setError` are passed in, the same shared-context shape every app * already receives (apps.md §2). */ async function downloadDirectory(transfers, transport, gek, entries, dir, { setError }) { if (!transport || !transport.connected) return; const files = entriesUnder(entries, dir); if (!files.length) { setError(t('group.zip_empty')); return; } const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); const suggested = (dir.split('/').pop() || 'files') + '.zip'; // totalBytes decides how this is delivered, but it is not the archive's // size — headers and the central directory come on top — so it is not // announced as a Content-Length that the download would then miss. const target = await _openDownloadTarget(suggested, totalBytes, { types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], }, 0); if (target === false) return; if (!target && !confirm(t('group.zip_no_stream', { size: formatSize(totalBytes), name: suggested, }))) { return; } const zipOpenRef = { url: null }; transfers.start({ kind: 'download', name: (target && target.name) || suggested, total: totalBytes, transport, open: target ? (target.open || null) : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, run: async ({ signal, onProgress }) => { const writable = target ? target.writable : null; const parts = writable ? null : []; let written = 0; try { const zip = new ZipStream(async (bytes) => { if (writable) await writable.write(bytes); else parts.push(bytes.slice()); }); for (const { entry, name } of files) { await zip.begin(name, entry.size, new Date((entry.added_at || 0) * 1000)); // A zero-byte file has no chunk to ask for; the header and an empty // descriptor are the whole entry. const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); if (totalChunks > 0) await pipelinedDownload( transport, gek, entry.id, totalChunks, (bytes) => { written += bytes; onProgress(written, totalBytes); }, // pipelinedDownload writes in order, which the archive needs. { write: (plaintext) => zip.write(plaintext) }, signal); await zip.end(); } await zip.finish(); if (writable) await writable.close(); else { const blob = new Blob(parts, { type: 'application/zip' }); _saveBlob(blob, suggested); zipOpenRef.url = URL.createObjectURL(blob); } } catch (err) { if (writable) await writable.abort().catch(() => {}); throw err; } }, }); } export { FILE_ICONS, formatSize, formatDate, PREVIEWABLE_TEXT, canPreview, CHUNK_SIZE, _openDownloadTarget, _saveBlob, _b64ToU8, pipelinedDownload, downloadEntry, downloadDirectory, };