diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-23 15:15:35 +0200 |
| commit | 9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch) | |
| tree | b13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/file-utils.js | |
| parent | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff) | |
| download | meshbay-9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83.tar.gz | |
feat(hub): split the group UI into a pluggable "applications" architecture
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
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, +}; |