summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 15:15:35 +0200
commit9f02ee2c09652abf1308bdfa4a3eec4e9ca9ac83 (patch)
treeb13198a79a0965f254c828adba3eb41dd5e9a5b4 /packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
parent8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (diff)
downloadmeshbay-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.js207
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,
+};