diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/file-utils.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/file-utils.js | 207 |
1 files changed, 207 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js new file mode 100644 index 0000000..4541290 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -0,0 +1,207 @@ +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', '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, +}; |