From 1a495f5ed3f8a55222d406152c833882264dc377 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 8 Sep 2026 22:54:16 +0200 Subject: feat(hub): client-side transfer leases and the transfers panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 5 and 6 of ~/next/improve-downloads.md. The node has handed out slots since step 2 and nothing asked for one; now the client does, and the panel shows what is happening. `transport.openTransfer()` returns a Lease: `acquire()` resolves when the node grants, `release()` gives it back exactly once, and nothing else in the client speaks to the node about slots. Whether a node hands out slots is read from the handshake ack rather than guessed from a timeout — "no answer yet" and "this node will never answer" are indistinguishable in time, and guessing wrong either stalls every download or defeats the cap. Two things exist only because a queue can lie: a watchdog re-asks when a pushed grant does not arrive (the node is idempotent on `tr`, so asking again is free), and a grant for a transfer the page has forgotten is handed straight back rather than held until the node's deadline. The slot is asked for **after** there is somewhere to write, and that ordering is load-bearing: opening a target takes thirty seconds of streamed-download timeouts, or as long as somebody leaves a Save As dialog open, and a grant not taken up in time is revoked. Moving it earlier looked better and broke three downloads into one. Pinned by a test. The panel groups by state — running, waiting, finished — rather than re-sorting a flat list, so a row moves only when its own state does. The ETA is withheld until the speed window holds real measurement: a figure from the first two chunks swings between four seconds and an hour, and people plan around the first number they see. One live region announces state changes and not progress. Three silent paths closed on the way: a download refused for want of a user gesture (a browser grants one file picker per gesture, and downloading three files is one gesture) now falls back to the streamed path, which needs none; a click with no connection says so instead of doing nothing at all; and a queued transfer counts as busy, so a transport is never closed under one that is waiting for a grant that could then never arrive. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST --- packages/meshbay-hub/src/meshbay_hub/static/app.js | 173 ++++++++++++----- .../src/meshbay_hub/static/file-utils.js | 96 ++++++++-- .../src/meshbay_hub/static/files-app.js | 14 +- .../src/meshbay_hub/static/group-page.js | 7 +- .../src/meshbay_hub/static/locales/de.js | 12 ++ .../src/meshbay_hub/static/locales/en.js | 12 ++ .../src/meshbay_hub/static/locales/es.js | 12 ++ .../src/meshbay_hub/static/locales/fr.js | 12 ++ .../src/meshbay_hub/static/locales/it.js | 12 ++ .../src/meshbay_hub/static/locales/ja.js | 12 ++ .../src/meshbay_hub/static/locales/nl.js | 12 ++ .../src/meshbay_hub/static/locales/pl.js | 12 ++ .../src/meshbay_hub/static/locales/pt-BR.js | 12 ++ .../src/meshbay_hub/static/locales/zh-CN.js | 12 ++ .../meshbay-hub/src/meshbay_hub/static/style.css | 64 +++++++ .../src/meshbay_hub/static/transfers.js | 105 +++++++++- .../src/meshbay_hub/static/transport.js | 213 ++++++++++++++++++++- packages/meshbay-hub/tests/test_layout_measured.py | 119 +++++++++++- packages/meshbay-hub/tests/test_memory_ceiling.py | 36 +++- packages/meshbay-hub/tests/test_transfers.py | 213 +++++++++++++++++++++ packages/meshbay-hub/tests/test_zip_size_limit.py | 12 +- 21 files changed, 1090 insertions(+), 82 deletions(-) (limited to 'packages') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 9d64292..ff066cf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -4,7 +4,7 @@ import { } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; -import { transfers, formatSpeed } from './transfers.js'; +import { transfers, formatSpeed, etaSeconds } from './transfers.js'; import * as platform from './platform.js'; import * as downloads from './downloads.js'; import { Icon } from './icon.js'; @@ -157,69 +157,67 @@ function TransferWidget() { }, [open]); const running = items.filter(i => i.status === 'running'); + const waiting = items.filter(i => i.status === 'queued'); + const finished = items.filter( + i => i.status !== 'running' && i.status !== 'queued'); + const active = running.length + waiting.length; + + // Grouped, and in this order: what is moving, what is waiting, what is over. + // Re-sorting the flat list on every emit made rows jump under the pointer + // each time a neighbour finished — the group is what changes, not the + // position within it, so a row only moves when its own state does. + const groups = [ + ['running', running], + ['waiting', waiting], + ['finished', finished], + ].filter(([, rows]) => rows.length); + if (!items.length) return null; return html`
+ ${/* One live region for the panel, announcing what changed state rather + than every progress tick — a reader that says "62%… 63%… 64%" for a + four-gigabyte film is a reader nobody leaves on. */''} + + ${t('transfers.summary', { running: running.length, waiting: waiting.length })} + ${open && html` -
+
- ${t('transfers.title')} - + ${t('transfers.title')} + ${active > 0 && html` + + ${t('transfers.summary', { + running: running.length, waiting: waiting.length })} + + `} + ${finished.length > 0 && html` + + `}
- ${items.map(it => html` -
-
- - <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} /> - - ${it.canOpen - ? html` { e.preventDefault(); transfers.open(it.id); }}>${it.name}` - : html`${it.name}`} - ${it.status === 'running' && html` - - `} -
- ${it.status === 'running' - ? html` -
-
-
-
- ${formatSize(it.done)}${it.total - ? ' / ' + formatSize(it.total) : ''} - ${formatSpeed(it.speed)} -
- ` - : html` -
- - ${it.status === 'done' ? t('transfers.done') - : it.status === 'cancelled' ? t('transfers.cancelled') - : it.error || t('transfers.failed')} - - ${it.canOpen && html` - - `} -
- `} + ${groups.map(([label, rows]) => html` +
+ ${groups.length > 1 && html` +
+ ${t('transfers.group_' + label, { n: rows.length })} +
+ `} + ${rows.map(it => html`<${TransferRow} it=${it} key=${it.id} />`)}
`)}
@@ -228,6 +226,79 @@ function TransferWidget() { `; } +/** One row. Split out so the panel above reads as a layout and this as a state + * machine — they change for different reasons. */ +function TransferRow({ it }) { + const eta = etaSeconds(it); + return html` +
+
+ + ${it.canOpen + ? html` { e.preventDefault(); transfers.open(it.id); }}>${it.name}` + : html`${it.name}`} + ${(it.status === 'running' || it.status === 'queued') && html` + + `} +
+ ${it.status === 'queued' + ? html` +
+
+ ${it.queuedByOwnLimit + ? t('transfers.waiting_own_slots') + : t('transfers.waiting_node', { n: it.ahead })} + ${formatSize(it.total)} +
+ ` + : it.status === 'running' + ? html` +
+
+
+
+ ${formatSize(it.done)}${it.total + ? ' / ' + formatSize(it.total) : ''} + ${[formatSpeed(it.speed), + it.settled && eta !== null ? formatEta(eta) : ''] + .filter(Boolean).join(' · ')} +
+ ` + : html` +
+ + ${it.status === 'done' ? t('transfers.done') + : it.status === 'cancelled' ? t('transfers.cancelled') + : it.error || t('transfers.failed')} + + ${it.canOpen && html` + + `} +
+ `} +
+ `; +} + +/** "4 min left". Coarse on purpose: a per-second countdown on a transfer whose + * speed varies is a number that is wrong most of the time and looks precise. */ +function formatEta(seconds) { + if (seconds < 60) return t('transfers.eta_seconds', { n: Math.ceil(seconds) }); + if (seconds < 3600) return t('transfers.eta_minutes', { n: Math.round(seconds / 60) }); + return t('transfers.eta_hours', { n: Math.round(seconds / 360) / 10 }); +} + // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index a942fd5..61002d6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -164,6 +164,25 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, return { writable: await handle.createWritable(), name: handle.name || filename }; } catch (err) { if (err.name === 'AbortError') return false; + // "Must be handling a user gesture to show a file picker." + // + // A browser grants one picker per gesture, and downloading three files is + // one gesture. So the second and third throw this, and the person sees a + // failed transfer with a message from Chrome about gestures, for having + // done something entirely reasonable. + // + // The streamed path needs no gesture at all, which makes it the right + // answer here rather than a consolation: the file still lands on disk, in + // the browser's own download folder, written as it arrives. Only the choice + // of folder is lost, and it was already lost — there was no picker to make + // it in. + if (err.name === 'SecurityError' || /user gesture/i.test(err.message || '')) { + console.warn('[MeshBay] no gesture left for a save dialog; streaming ' + + 'this one to the download folder instead'); + const streamed = await downloads.openStreamedDownload(filename, swSize); + if (streamed) return streamed; + if (size <= MEMORY_CEILING) return _memoryFloor(); + } throw err; } } @@ -193,17 +212,47 @@ function _saveBlob(blob, filename) { const CHUNK_RETRY_ATTEMPTS = 6; const CHUNK_RETRY_DELAY_MS = 1500; +// How long one megabyte may take to reach the disk before we call it stuck. +// +// Every other await on this path is bounded and says so when it expires: +// `_sendAndWait` logs a Response timeout, `_fetchChunkResilient` retries and +// then throws. `writable.write()` was the exception — a sink that stops +// consuming (a service-worker stream the browser has stopped reading, a file +// handle that has gone away) leaves it pending for ever. It never rejects, so +// there is no error, no log and no failed transfer: the progress bar simply +// stops, the console stays empty, and the node is perfectly healthy the whole +// time, which is what made this invisible. +// +// Generous on purpose. A megabyte takes milliseconds on any working sink; a +// minute means the sink is gone, not slow. +const WRITE_STALL_MS = 60000; + +/** `writable.write`, but it fails instead of hanging for ever. */ +async function _writeOrStall(writable, bytes, at) { + let timer = 0; + const stalled = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error( + t('group.download_write_stalled', { seconds: WRITE_STALL_MS / 1000 }) + + ` (chunk ${at})`)), WRITE_STALL_MS); + }); + try { + await Promise.race([writable.write(bytes), stalled]); + } finally { + clearTimeout(timer); + } +} + function _isRetryableTransportError(err) { return err.name === 'TransportLostError' || err.message === 'Response timeout' || (err.message || '').startsWith('DataChannel not open'); } -async function _fetchChunkResilient(transport, fileId, index) { +async function _fetchChunkResilient(transport, fileId, index, tr = '') { let lastErr; for (let attempt = 0; attempt < CHUNK_RETRY_ATTEMPTS; attempt++) { try { - return await transport.fetchChunk(fileId, index); + return await transport.fetchChunk(fileId, index, tr); } catch (err) { if (!_isRetryableTransportError(err)) throw err; lastErr = err; @@ -216,14 +265,14 @@ async function _fetchChunkResilient(transport, fileId, index) { } async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, - writable, signal) { + writable, signal, tr = '') { 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] = _fetchChunkResilient(transport, fileId, nextSend); + inflight[nextSend] = _fetchChunkResilient(transport, fileId, nextSend, tr); nextSend++; } }; @@ -254,7 +303,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk const plaintext = await window.MeshBayCrypto.decryptChunkBin( gekKey, fileId, nextRecv, chunkMsg.nonce, chunkMsg.ct); if (writable) { - await writable.write(plaintext); + await _writeOrStall(writable, plaintext, nextRecv); } else { results[nextRecv] = plaintext; } @@ -272,6 +321,20 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk * download button — both just want "get this entry to disk". */ async function downloadEntry(transfers, transport, gek, entry) { + // The target FIRST, then the slot — and that order is load-bearing. + // + // Asking for the slot first looks better (the widget could draw a row while + // the target is being chosen) and is wrong: a granted slot has to be taken up + // within the node's acceptance deadline, and opening a target can take thirty + // seconds of streamed-download timeouts, or as long as somebody leaves a Save + // As dialog open. The node then revokes the grant and passes it to the next + // in the queue — `transfer: reclaimed … (not_taken_up)` in its log — and this + // download starts fetching under a `tr` that is no longer granted. + // + // Measured, not reasoned: three downloads started, one arrived, and the + // node's log named the reason. Do not move this again without moving the + // deadline, and the deadline exists so a client that dies between asking and + // starting does not hold a slot nobody can use. let target; try { target = await _openDownloadTarget(entry.name, entry.size); @@ -289,21 +352,25 @@ async function downloadEntry(transfers, transport, gek, entry) { if (target === false) return; // the picker was dismissed const openRef = { url: null }; + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); transfers.start({ kind: 'download', name: (target && target.name) || entry.name, total: entry.size, transport, + // Asked for here, once there is somewhere to write: see the note above. + lease: transport.openTransfer({ + kind: 'download', bytes: entry.size, chunks: totalChunks }), 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); + run: async ({ signal, onProgress, lease }) => { 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); + onChunk, target.writable, signal, + lease && lease.tr); await target.writable.close(); } catch (err) { await target.writable.abort().catch(() => {}); @@ -311,7 +378,8 @@ async function downloadEntry(transfers, transport, gek, entry) { } } else { const chunks = await pipelinedDownload( - transport, gek, entry.id, totalChunks, onChunk, null, signal); + transport, gek, entry.id, totalChunks, onChunk, null, signal, + lease && lease.tr); const blob = new Blob(chunks); _saveBlob(blob, entry.name); openRef.url = URL.createObjectURL(blob); @@ -391,10 +459,15 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE transfers.start({ kind: 'download', name: (target && target.name) || suggested, total: totalBytes, transport, + // **One** lease for the archive, not one per file. Dozens of leases for a + // folder would deadlock against the member's own cap: the job cannot finish + // until it holds them all, and it can never hold more than two. + lease: transport.openTransfer({ + kind: 'download', bytes: totalBytes, chunks: files.length }), open: target ? (target.open || null) : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, - run: async ({ signal, onProgress }) => { + run: async ({ signal, onProgress, lease }) => { const writable = target ? target.writable : null; const parts = writable ? null : []; let written = 0; @@ -414,7 +487,8 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE 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); + { write: (plaintext) => zip.write(plaintext) }, signal, + lease && lease.tr); await zip.end(); } await zip.finish(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 36c5af3..fb9d801 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -66,7 +66,15 @@ function FilesPanel({ transport = transportRef.current; gek = gekRef.current; } - if (!transport || !transport.connected) return; + // Never a silent return. A click that produces nothing at all — no + // transfer, no icon, no message — is indistinguishable from a broken + // button, and it is what a download looks like whenever the WebRTC + // connection is not up: on a screen lock, mid-reconnect, or after the node + // restarted. Say so instead. + if (!transport || !transport.connected) { + setError(t('group.download_offline')); + return; + } await downloadEntry(transfers, transport, gek, entry); }, [getTransport]); @@ -86,13 +94,15 @@ function FilesPanel({ for (const file of files) { transfers.start({ kind: 'upload', name: file.name, total: file.size, transport, - run: async ({ signal, onProgress }) => { + lease: transport.openTransfer({ kind: 'upload', bytes: file.size }), + run: async ({ signal, onProgress, lease }) => { await transport.uploadFile(file, { // Bytes the node acknowledged, not bytes read locally. onProgress: (sent) => onProgress(sent, file.size), signal, root: uploadRoot, dir: uploadDir, + tr: lease && lease.tr, }); // The node re-indexes on a filesystem event, so there is nothing to // wait on but the clock. Refreshing here means the file appears in diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 6f41426..c6748f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -553,7 +553,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // toolbar actions call the same shared helper from files-app.js, since // only a single open file/video is ever in play here. const transport = transportRef.current; - if (!transport || !transport.connected) return; + if (!transport || !transport.connected) { + // Same reasoning as files-app.js's downloadFile: a click that does + // nothing at all is worse than a refusal. + setError(t('group.download_offline')); + return; + } await downloadEntry(transfers, transport, gekRef.current, entry); }, []); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 8ed1ef0..f5c0c78 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -162,6 +162,8 @@ export default { 'group.mkdir': 'Neuer Ordner', 'group.mkdir_prompt': 'Name des neuen Ordners', 'group.mkdir_offline': 'Nicht mit dem Node verbunden.', + 'group.download_offline': 'Keine Verbindung zum Node — der Download kann nicht starten. Die Verbindung wird automatisch wiederhergestellt; versuchen Sie es gleich erneut.', + 'group.download_write_stalled': 'Die Datei wird nicht mehr auf die Festplatte geschrieben ({seconds} s ohne Fortschritt). Der Download wurde abgebrochen statt hängen gelassen; versuchen Sie es erneut.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -576,6 +578,16 @@ export default { 'transfers.open': 'Öffnen', 'transfers.done': 'Abgeschlossen', 'transfers.cancelled': 'Abgebrochen', + 'transfers.waiting_own_slots': 'Wartet — Ihre Plätze sind belegt', + 'transfers.waiting_node': 'Wartet — {n} davor', + 'transfers.summary': '{running} laufend · {waiting} wartend', + 'transfers.group_running': 'Laufend', + 'transfers.group_waiting': 'Wartend', + 'transfers.group_finished': 'Abgeschlossen', + 'transfers.cancel_one': '{name} abbrechen', + 'transfers.eta_seconds': 'noch {n} s', + 'transfers.eta_minutes': 'noch {n} Min.', + 'transfers.eta_hours': 'noch {n} Std.', 'transfers.failed': 'Fehlgeschlagen', 'group.select': 'Auswählen', 'group.select_done': 'Fertig', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 285ff7a..e3ed844 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -162,6 +162,8 @@ export default { 'group.mkdir': 'New folder', 'group.mkdir_prompt': 'New folder name', 'group.mkdir_offline': 'Not connected to the node.', + 'group.download_offline': 'Not connected to the node — the download cannot start. It reconnects on its own; try again in a moment.', + 'group.download_write_stalled': 'The file stopped being written to disk ({seconds}s with no progress). The download was stopped rather than left hanging; try it again.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -692,6 +694,16 @@ export default { 'transfers.open': 'Open', 'transfers.done': 'Finished', 'transfers.cancelled': 'Cancelled', + 'transfers.waiting_own_slots': 'Waiting — your slots are busy', + 'transfers.waiting_node': 'Waiting — {n} ahead', + 'transfers.summary': '{running} running · {waiting} waiting', + 'transfers.group_running': 'Running', + 'transfers.group_waiting': 'Waiting', + 'transfers.group_finished': 'Finished', + 'transfers.cancel_one': 'Cancel {name}', + 'transfers.eta_seconds': '{n}s left', + 'transfers.eta_minutes': '{n} min left', + 'transfers.eta_hours': '{n} h left', 'transfers.failed': 'Failed', 'group.select': 'Select', 'group.select_done': 'Done', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index f74c763..c509599 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -160,6 +160,8 @@ export default { 'group.mkdir': 'Nueva carpeta', 'group.mkdir_prompt': 'Nombre de la nueva carpeta', 'group.mkdir_offline': 'Sin conexión con el nodo.', + 'group.download_offline': 'Sin conexión con el nodo — la descarga no puede empezar. Se reconecta sola; inténtelo de nuevo en un momento.', + 'group.download_write_stalled': 'El archivo dejó de escribirse en el disco ({seconds} s sin avance). La descarga se detuvo en lugar de quedarse colgada; inténtelo de nuevo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -572,6 +574,16 @@ export default { 'transfers.open': 'Abrir', 'transfers.done': 'Terminada', 'transfers.cancelled': 'Cancelada', + 'transfers.waiting_own_slots': 'En espera — sus espacios están ocupados', + 'transfers.waiting_node': 'En espera — {n} por delante', + 'transfers.summary': '{running} en curso · {waiting} en espera', + 'transfers.group_running': 'En curso', + 'transfers.group_waiting': 'En espera', + 'transfers.group_finished': 'Finalizados', + 'transfers.cancel_one': 'Cancelar {name}', + 'transfers.eta_seconds': 'quedan {n} s', + 'transfers.eta_minutes': 'quedan {n} min', + 'transfers.eta_hours': 'quedan {n} h', 'transfers.failed': 'Fallida', 'group.select': 'Seleccionar', 'group.select_done': 'Listo', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 0c5103f..49a1071 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -161,6 +161,8 @@ export default { 'group.mkdir': 'Nouveau dossier', 'group.mkdir_prompt': 'Nom du nouveau dossier', 'group.mkdir_offline': 'Non connecté au nœud.', + 'group.download_offline': 'Pas de connexion au node — le téléchargement ne peut pas démarrer. La reconnexion est automatique, réessayez dans un instant.', + 'group.download_write_stalled': 'L\'écriture du fichier sur le disque s\'est arrêtée ({seconds} s sans progression). Le téléchargement a été interrompu plutôt que laissé en suspens ; réessayez.', 'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud', 'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.', 'device.add_btn': 'Obtenir un code de liaison', @@ -575,6 +577,16 @@ export default { 'transfers.open': 'Ouvrir', 'transfers.done': 'Terminé', 'transfers.cancelled': 'Annulé', + 'transfers.waiting_own_slots': 'En attente — vos slots sont occupés', + 'transfers.waiting_node': 'En attente — {n} devant', + 'transfers.summary': '{running} en cours · {waiting} en attente', + 'transfers.group_running': 'En cours', + 'transfers.group_waiting': 'En attente', + 'transfers.group_finished': 'Terminés', + 'transfers.cancel_one': 'Annuler {name}', + 'transfers.eta_seconds': '{n} s restantes', + 'transfers.eta_minutes': '{n} min restantes', + 'transfers.eta_hours': '{n} h restantes', 'transfers.failed': 'Échec', 'group.select': 'Sélectionner', 'group.select_done': 'Terminé', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index ebac541..8f1ef1e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -161,6 +161,8 @@ export default { 'group.mkdir': 'Nuova cartella', 'group.mkdir_prompt': 'Nome della nuova cartella', 'group.mkdir_offline': 'Non connesso al nodo.', + 'group.download_offline': 'Nessuna connessione al nodo — il download non può iniziare. La riconnessione è automatica, riprovi tra poco.', + 'group.download_write_stalled': 'Il file ha smesso di essere scritto su disco ({seconds} s senza progressi). Il download è stato interrotto invece di restare bloccato; riprovi.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -575,6 +577,16 @@ export default { 'transfers.open': 'Apri', 'transfers.done': 'Completato', 'transfers.cancelled': 'Annullato', + 'transfers.waiting_own_slots': 'In attesa — i suoi posti sono occupati', + 'transfers.waiting_node': 'In attesa — {n} prima', + 'transfers.summary': '{running} in corso · {waiting} in attesa', + 'transfers.group_running': 'In corso', + 'transfers.group_waiting': 'In attesa', + 'transfers.group_finished': 'Completati', + 'transfers.cancel_one': 'Annulla {name}', + 'transfers.eta_seconds': '{n} s rimanenti', + 'transfers.eta_minutes': '{n} min rimanenti', + 'transfers.eta_hours': '{n} h rimanenti', 'transfers.failed': 'Non riuscito', 'group.select': 'Seleziona', 'group.select_done': 'Fine', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 1b49ddb..7ea9789 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -159,6 +159,8 @@ export default { 'group.mkdir': '新しいフォルダー', 'group.mkdir_prompt': '新しいフォルダー名', 'group.mkdir_offline': 'ノードに接続していません。', + 'group.download_offline': 'ノードに接続していません — ダウンロードを開始できません。再接続は自動で行われます。少し待って再試行してください。', + 'group.download_write_stalled': 'ファイルのディスクへの書き込みが止まりました({seconds} 秒間進みません)。ぶら下がったままにせず中止しました。もう一度お試しください。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -567,6 +569,16 @@ export default { 'transfers.open': '開く', 'transfers.done': '完了', 'transfers.cancelled': 'キャンセル済み', + 'transfers.waiting_own_slots': '待機中 — 自分の枠がすべて使用中です', + 'transfers.waiting_node': '待機中 — 前に {n} 件', + 'transfers.summary': '実行中 {running} · 待機中 {waiting}', + 'transfers.group_running': '実行中', + 'transfers.group_waiting': '待機中', + 'transfers.group_finished': '完了', + 'transfers.cancel_one': '{name} をキャンセル', + 'transfers.eta_seconds': '残り {n} 秒', + 'transfers.eta_minutes': '残り {n} 分', + 'transfers.eta_hours': '残り {n} 時間', 'transfers.failed': '失敗', 'group.select': '選択', 'group.select_done': '完了', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 7cf4cd4..74cd269 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -162,6 +162,8 @@ export default { 'group.mkdir': 'Nieuwe map', 'group.mkdir_prompt': 'Naam van de nieuwe map', 'group.mkdir_offline': 'Niet verbonden met de node.', + 'group.download_offline': 'Geen verbinding met de node — de download kan niet starten. Er wordt automatisch opnieuw verbonden; probeer het zo weer.', + 'group.download_write_stalled': 'Het bestand wordt niet meer naar schijf geschreven ({seconds} s zonder voortgang). De download is gestopt in plaats van te blijven hangen; probeer het opnieuw.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -576,6 +578,16 @@ export default { 'transfers.open': 'Openen', 'transfers.done': 'Voltooid', 'transfers.cancelled': 'Geannuleerd', + 'transfers.waiting_own_slots': 'Wacht — uw plaatsen zijn bezet', + 'transfers.waiting_node': 'Wacht — {n} ervoor', + 'transfers.summary': '{running} bezig · {waiting} wachtend', + 'transfers.group_running': 'Bezig', + 'transfers.group_waiting': 'Wachtend', + 'transfers.group_finished': 'Voltooid', + 'transfers.cancel_one': '{name} annuleren', + 'transfers.eta_seconds': 'nog {n} s', + 'transfers.eta_minutes': 'nog {n} min', + 'transfers.eta_hours': 'nog {n} u', 'transfers.failed': 'Mislukt', 'group.select': 'Selecteren', 'group.select_done': 'Klaar', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 6edffd5..584e5f2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -165,6 +165,8 @@ export default { 'group.mkdir': 'Nowy folder', 'group.mkdir_prompt': 'Nazwa nowego folderu', 'group.mkdir_offline': 'Brak połączenia z węzłem.', + 'group.download_offline': 'Brak połączenia z węzłem — pobieranie nie może się rozpocząć. Połączenie wróci samo; proszę spróbować za chwilę.', + 'group.download_write_stalled': 'Plik przestał być zapisywany na dysk ({seconds} s bez postępu). Pobieranie zostało przerwane, zamiast wisieć w nieskończoność; proszę spróbować ponownie.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -588,6 +590,16 @@ export default { 'transfers.open': 'Otwórz', 'transfers.done': 'Zakończony', 'transfers.cancelled': 'Anulowany', + 'transfers.waiting_own_slots': 'Oczekiwanie — Twoje miejsca są zajęte', + 'transfers.waiting_node': 'Oczekiwanie — {n} przed', + 'transfers.summary': '{running} w toku · {waiting} oczekuje', + 'transfers.group_running': 'W toku', + 'transfers.group_waiting': 'Oczekuje', + 'transfers.group_finished': 'Zakończone', + 'transfers.cancel_one': 'Anuluj {name}', + 'transfers.eta_seconds': 'pozostało {n} s', + 'transfers.eta_minutes': 'pozostało {n} min', + 'transfers.eta_hours': 'pozostało {n} godz.', 'transfers.failed': 'Nie powiódł się', 'group.select': 'Zaznacz', 'group.select_done': 'Gotowe', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 3a6fa22..b034117 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -162,6 +162,8 @@ export default { 'group.mkdir': 'Nova pasta', 'group.mkdir_prompt': 'Nome da nova pasta', 'group.mkdir_offline': 'Sem conexão com o nó.', + 'group.download_offline': 'Sem conexão com o nó — o download não pode começar. Ele reconecta sozinho; tente de novo em instantes.', + 'group.download_write_stalled': 'O arquivo parou de ser gravado no disco ({seconds}s sem progresso). O download foi interrompido em vez de ficar travado; tente de novo.', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -574,6 +576,16 @@ export default { 'transfers.open': 'Abrir', 'transfers.done': 'Concluída', 'transfers.cancelled': 'Cancelada', + 'transfers.waiting_own_slots': 'Aguardando — seus espaços estão ocupados', + 'transfers.waiting_node': 'Aguardando — {n} na frente', + 'transfers.summary': '{running} em andamento · {waiting} aguardando', + 'transfers.group_running': 'Em andamento', + 'transfers.group_waiting': 'Aguardando', + 'transfers.group_finished': 'Concluídos', + 'transfers.cancel_one': 'Cancelar {name}', + 'transfers.eta_seconds': 'faltam {n} s', + 'transfers.eta_minutes': 'faltam {n} min', + 'transfers.eta_hours': 'faltam {n} h', 'transfers.failed': 'Falhou', 'group.select': 'Selecionar', 'group.select_done': 'Concluir', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index f43ef72..ffe2cb0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -158,6 +158,8 @@ export default { 'group.mkdir': '新建文件夹', 'group.mkdir_prompt': '新文件夹名称', 'group.mkdir_offline': '未连接到节点。', + 'group.download_offline': '未连接到节点 — 无法开始下载。连接会自动恢复,请稍后重试。', + 'group.download_write_stalled': '文件停止写入磁盘({seconds} 秒无进展)。已中止下载而不是让它一直卡住,请重试。', 'device.add_title': 'This browser is not linked to this node yet', 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', 'device.add_btn': 'Get a linking code', @@ -555,6 +557,16 @@ export default { 'transfers.open': '打开', 'transfers.done': '已完成', 'transfers.cancelled': '已取消', + 'transfers.waiting_own_slots': '等待中 — 您的通道已占满', + 'transfers.waiting_node': '等待中 — 前面还有 {n} 个', + 'transfers.summary': '进行中 {running} · 等待中 {waiting}', + 'transfers.group_running': '进行中', + 'transfers.group_waiting': '等待中', + 'transfers.group_finished': '已完成', + 'transfers.cancel_one': '取消 {name}', + 'transfers.eta_seconds': '剩余 {n} 秒', + 'transfers.eta_minutes': '剩余 {n} 分钟', + 'transfers.eta_hours': '剩余 {n} 小时', 'transfers.failed': '失败', 'group.select': '选择', 'group.select_done': '完成', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index dfed44b..22fcd3f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -4243,3 +4243,67 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } color: var(--warn); margin-bottom: 2px; } + +/* A transfer waiting for a slot. Deliberately not a progress bar at 0%: it is + not stalled and nothing is wrong, and a bar that never moves is exactly how a + queue comes to look like a hang. The stripes say "not yet", not "broken". */ +.dl-progress.dl-waiting { + background-color: var(--bg-raised); + background-image: repeating-linear-gradient( + 45deg, + var(--accent-bg) 0 8px, + transparent 8px 16px); + animation: dl-waiting-slide 1.1s linear infinite; +} +@keyframes dl-waiting-slide { + from { background-position: 0 0; } + to { background-position: 22.6px 0; } +} +/* The state has to survive without the motion: someone who asked for less of it + still needs to see that this row is waiting rather than stopped. */ +@media (prefers-reduced-motion: reduce) { + .dl-progress.dl-waiting { animation: none; } +} + +/* ── Transfers panel (grouped) ───────────────────────────────────────────── */ + +.transfer-head-title { font-weight: 600; } +.transfer-head-summary { + margin-left: auto; + margin-right: .5rem; + font-size: .85em; + color: var(--text-dim); + /* Never wraps to a second line: it is the one part of the header that grows + with what is happening, and a header that changes height as transfers come + and go pushes every row under it. */ + white-space: nowrap; +} +.transfer-group + .transfer-group { border-top: 1px solid var(--border); } +.transfer-group-head { + padding: .35rem .6rem .2rem; + font-size: .78em; + text-transform: uppercase; + letter-spacing: .04em; + color: var(--text-dim); +} +/* Finished rows recede rather than disappear: somebody who just downloaded + three files wants to see that all three are there. */ +.transfer-item.transfer-done .transfer-name, +.transfer-item.transfer-cancelled .transfer-name { color: var(--text-dim); } + +/* Announced, not shown. The transfers panel needs a live region that says what + changed state without drawing anything — used with aria-live, so it must + stay in the accessibility tree: `display: none` would remove it from there + too and announce nothing at all, which is the usual way this is got wrong. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index fa34c67..46f75a2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -22,6 +22,12 @@ const SPEED_WINDOW_MS = 5000; +function _abortError() { + const err = new Error('Cancelled'); + err.name = 'AbortError'; + return err; +} + let _nextId = 1; export class TransferStore { @@ -53,8 +59,18 @@ export class TransferStore { total: it.total, done: it.done, status: it.status, + // How many are in front of this one, and whose limit is holding it up: + // "your own two slots are busy" and "the node is full" are different + // situations and the person can act on only one of them. + ahead: it.ahead || 0, + queuedByOwnLimit: Boolean( + it.lease && it.lease.cap && it.lease.used >= it.lease.cap), error: it.error || '', speed: this._speed(it), + // The ETA is drawn only once the window holds a few seconds of real + // measurement -- see etaSeconds. + settled: it.samples.length > 2 + && (it.samples[it.samples.length - 1].t - it.samples[0].t) >= 3000, percent: it.total ? Math.min(100, Math.round(it.done / it.total * 100)) : 0, // Only for a file written into a folder the browser granted us: that is // the one case where the page can read its own download back. @@ -66,6 +82,12 @@ export class TransferStore { return this._items.filter(it => it.status === 'running').length; } + /** Running or waiting for a slot — what the nav badge counts. */ + get pending() { + return this._items.filter( + it => it.status === 'running' || it.status === 'queued').length; + } + _speed(it) { // Over a window rather than since the start: a transfer that stalls should // read as slow immediately, not as its own historical average. @@ -82,12 +104,18 @@ export class TransferStore { * `run` receives `{ signal, onProgress }`. It must poll `signal.aborted` — a * cancel that only sets a flag nobody reads is a button that lies. */ - start({ kind, name, total = 0, transport = null, run, open = null }) { + start({ kind, name, total = 0, transport = null, run, open = null, + lease = null }) { const item = { id: _nextId++, - kind, name, total, transport, open, + kind, name, total, transport, open, lease, done: 0, - status: 'running', + // A transfer that has to wait for a slot starts as 'queued', not + // 'running'. Two different things are true of it — nothing is moving, and + // nothing is wrong — and a status that conflates them is what makes a + // queue look like a hang. + status: lease && lease.state !== 'granted' ? 'queued' : 'running', + ahead: (lease && lease.ahead) || 0, error: '', samples: [{ t: this._now(), done: 0 }], signal: { aborted: false }, @@ -113,8 +141,39 @@ export class TransferStore { this._maybeRelease(item.transport); }; + // The slot is given back in a `finally` around everything, so it survives + // a throw, a cancel and a return alike. A slot not returned is a member who + // cannot start another transfer until the node times it out. + const finished = () => { + if (item.lease) item.lease.release( + item.signal.aborted ? 'cancelled' : 'done'); + }; + + // Installed here and not inside the promise chain below. A state push that + // arrived before the first microtask ran was simply dropped, so a transfer + // could sit at the position it was given when it was created and never + // appear to move — the widget showing "3 ahead" for ever while the node + // quietly worked through the queue. Nothing about that looks wrong from + // either side, which is why it needs a test rather than a reading. + if (item.lease) { + item.lease._onState = (lease) => { + if (item.status !== 'queued' && item.status !== 'running') return; + item.ahead = lease.ahead; + item.status = lease.state === 'granted' ? 'running' : 'queued'; + this._emit(); + }; + } + const promise = Promise.resolve() - .then(() => run({ signal: item.signal, onProgress })) + .then(async () => { + if (item.lease) { + await item.lease.acquire(); + if (item.signal.aborted) throw _abortError(); + item.status = 'running'; + this._emit(); + } + return run({ signal: item.signal, onProgress, lease: item.lease }); + }) .then(() => { if (item.signal.aborted) finish('cancelled'); else { @@ -125,7 +184,8 @@ export class TransferStore { .catch(err => { if (item.signal.aborted || err.name === 'AbortError') finish('cancelled'); else finish('failed', err.message || String(err)); - }); + }) + .finally(finished); item.promise = promise; return item.id; @@ -146,8 +206,12 @@ export class TransferStore { cancel(id) { const item = this._items.find(it => it.id === id); - if (!item || item.status !== 'running') return; + // 'queued' too: a transfer waiting for a slot is exactly the one somebody + // is most likely to give up on, and its queue entry has to go with it or + // the node grants a slot to a transfer that will never use it. + if (!item || (item.status !== 'running' && item.status !== 'queued')) return; item.signal.aborted = true; + if (item.lease) item.lease.release('cancelled'); // Marked at once. The work stops when it next looks, but a cancelled // transfer should not keep reporting progress in the meantime. item.status = 'cancelled'; @@ -157,19 +221,24 @@ export class TransferStore { cancelAll() { for (const it of this._items) { - if (it.status === 'running') this.cancel(it.id); + if (it.status === 'running' || it.status === 'queued') this.cancel(it.id); } } - /** Drop everything finished, keeping what is still running. */ + /** Drop everything finished, keeping what is still running or waiting. */ clearFinished() { - this._items = this._items.filter(it => it.status === 'running'); + this._items = this._items.filter( + it => it.status === 'running' || it.status === 'queued'); this._emit(); } _busy(transport) { + // Queued counts as busy: a transport closed while a transfer waits for a + // slot can never be granted one, and the transfer would sit at "waiting" + // for ever with nothing left to answer it. return this._items.some( - it => it.transport === transport && it.status === 'running'); + it => it.transport === transport + && (it.status === 'running' || it.status === 'queued')); } /** @@ -211,6 +280,22 @@ export class TransferStore { export const transfers = new TransferStore(); +/** + * Seconds left, or null when saying nothing is the honest answer. + * + * Withheld until the speed window has real samples in it: a figure computed + * from the first two chunks of a transfer swings between "4 seconds" and "an + * hour" and back, and a number that behaves like that is worse than a blank — + * people read the first one they see and plan around it. + */ +export function etaSeconds(item) { + if (item.status !== 'running' || !item.total || !item.speed) return null; + const left = item.total - item.done; + if (left <= 0) return null; + const secs = left / item.speed; + return Number.isFinite(secs) ? secs : null; +} + /** Human-readable rate, for a widget that updates several times a second. */ export function formatSpeed(bytesPerSecond) { if (!bytesPerSecond || bytesPerSecond < 1) return ''; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index c4a24c5..54a1302 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -283,6 +283,129 @@ const JOIN_REFUSALS = { group_mismatch: 'The node refused a request naming a different group.', }; +/** + * One transfer's slot on the node, from this side. + * + * The contract the transfer store depends on: `acquire()` resolves when the + * node has granted the slot (immediately on a node that hands out none), and + * `release(reason)` gives it back exactly once. Nothing else in the client + * speaks to the node about slots. + * + * Two things here exist only because a queue can lie, and both are the + * difference between "waiting" and "waiting for ever": + * + * - **the watchdog.** A grant is pushed, not polled, so a lost push leaves + * this side waiting on a node that believes it has started. Re-asking is + * free — the node is idempotent on `tr` — and it is the only thing that + * recovers a message that did not arrive. + * - **release is idempotent and unconditional.** A slot given back twice + * costs nothing; one never given back is a member who cannot transfer + * again until a timeout the node runs on its own. + */ +const LEASE_WATCHDOG_MS = 60000; + +class Lease { + constructor(transport, tr, kind, bytes, chunks, onState) { + this.transport = transport; + this.tr = tr; + this.kind = kind; + this.bytes = bytes; + this.chunks = chunks; + this.state = 'opening'; + this.ahead = 0; + this.closed = false; + this._onState = onState; + this._granted = null; + this._watchdog = 0; + this._wait = new Promise((resolve) => { this._granted = resolve; }); + } + + /** No slots on this node: behave as though one was granted at once. */ + _skip() { + this.state = 'granted'; + this._granted(); + } + + _request() { + // A closed channel is not a failure here, and must not throw: the transport + // reconnects on its own, `_reopenTransfers` re-asks for every live lease + // when it does, and the watchdog below asks again meanwhile. + // + // This is the same tolerance `_fetchChunkResilient` already gives a chunk + // request — and before leases existed, a chunk request was the first thing + // to touch the channel, so a download started on a briefly dead connection + // simply retried. Asking for a slot first made `_send` the first contact + // and threw "DataChannel not open (state: closed)" out of `downloadEntry`, + // where nothing catches it: a download that used to recover became an + // error with no row in the widget to show it. Found by downloading a file + // right after a connection dropped. + try { + this.transport._send({ + type: 'transfer_open', v: '0.1', tr: this.tr, kind: this.kind, + bytes: this.bytes, chunks: this.chunks, + }); + } catch (err) { + console.warn('[MeshBay] could not ask for a slot yet:', err.message); + } + this._arm(); + } + + _arm() { + clearTimeout(this._watchdog); + if (this.closed || this.state === 'granted') return; + this._watchdog = setTimeout(() => { + if (this.closed || this.state === 'granted') return; + console.warn('[MeshBay] no answer for transfer', this.tr.slice(0, 8), + '- asking again'); + this._request(); + }, LEASE_WATCHDOG_MS); + } + + _apply(msg) { + if (this.closed) return; + this.state = msg.state; + this.ahead = msg.ahead || 0; + this.used = msg.used; + this.cap = msg.cap; + if (msg.state === 'granted') { + clearTimeout(this._watchdog); + this._granted(); + } else if (msg.state === 'closed') { + // The node ended it: reclaimed as idle, or revoked. Not an error here — + // whoever is running the transfer finds out through its own failure — but + // the slot is gone and asking again is the only way back. + clearTimeout(this._watchdog); + } else { + this._arm(); + } + if (this._onState) { + try { this._onState(this); } catch (e) { + console.error('[MeshBay] lease state handler threw:', e); + } + } + } + + /** Resolves once the node has granted the slot. */ + acquire() { return this._wait; } + + /** + * Give the slot back. Safe to call twice, and safe on a dead transport: a + * lease that is not released is a member who cannot start another transfer + * until the node times it out, so this must never be conditional on anything. + */ + release(reason = 'done') { + if (this.closed) return; + this.closed = true; + clearTimeout(this._watchdog); + this.transport._leases.delete(this.tr); + if (!this.transport.supportsTransferSlots) return; + try { + this.transport._send({ type: 'transfer_close', v: '0.1', tr: this.tr, + reason }); + } catch { /* the connection is gone, and so is the lease with it */ } + } +} + class MeshBayTransport { constructor(hubUrl, accessToken) { this._hubUrl = hubUrl; @@ -315,6 +438,13 @@ class MeshBayTransport { // Names, not ids: the "already being uploaded" guard is about the file the // caller passed, and two `uploadFile` calls for one file draw two ids. this._inFlightUploads = new Set(); + // tr → Lease. A transfer's slot on the node, from the client's side. + this._leases = new Map(); + // Set from the handshake ack: a node that answers with `transfer_limits` + // speaks transfer slots. Used instead of a timeout, because "no answer + // yet" and "this node will never answer" are indistinguishable in time and + // guessing wrong either stalls every download or defeats the cap. + this._transferLimits = null; // Set once close() runs — stops the automatic reconnect from firing on a // connection the caller tore down on purpose (leaving the group, page // unload), which would otherwise race back in right as everything else @@ -376,6 +506,20 @@ class MeshBayTransport { /** The MNP version the connected node declared, or '' before a handshake. */ get nodeVersion() { return this._nodeVersion || ''; } + /** + * Whether this node hands out transfer slots. + * + * Read from the handshake ack rather than from the MNP version: the caps + * shipped before the version bump that will make leases compulsory, so for + * now a node either answers with `transfer_limits` or it predates all of + * this. A node that does not is asked for nothing and enforces nothing — + * every download behaves exactly as it did. + */ + get supportsTransferSlots() { return this._transferLimits !== null; } + + /** This member's own caps in this group, or null when the node said nothing. */ + get transferLimits() { return this._transferLimits; } + /** * Whether the node speaks the per-root and per-app operations MNP 1.1 added: * `root_update`/`root_eject`/`root_plug`, `app_directories`, @@ -880,6 +1024,7 @@ class MeshBayTransport { delete ack.nonce; delete ack.ct; Object.assign(ack, config); + this._transferLimits = ack.transfer_limits || null; // Tell the node which of this account's devices is on this connection. // Deliberately after the ack, and gated on the node's own version rather @@ -1016,6 +1161,10 @@ class MeshBayTransport { } trace('reconnect_ok', { attempt: this._reconnectAttempts }); console.log('[MeshBay] Reconnected after', this._reconnectAttempts, 'attempt(s)'); + // Before the caller's own hook: a transfer that resumes mid-chunk must + // have asked for its slot back first, or its next `file_req` carries a + // `tr` the node has never heard of. + this._reopenTransfers(); if (this._onReconnected) { try { this._onReconnected(); } catch (e) { console.error('[MeshBay] onReconnected handler threw:', e); @@ -1100,12 +1249,52 @@ class MeshBayTransport { return msg; } - async fetchChunk(fileId, chunkIndex) { + // ── Transfer slots ───────────────────────────────────────────────────────── + + /** + * Ask the node for a slot, and wait until it says yes. + * + * `tr` is drawn here, not by the node — 16 random bytes, exactly like + * `upload_id` — which is what makes re-opening after a reconnect idempotent + * rather than a second charge against the member's cap. + * + * On a node that predates transfer slots this resolves at once and costs + * nothing: there is no cap to respect and no message that would be + * understood. + */ + openTransfer({ kind = 'download', bytes = 0, chunks = 0, onState = null } = {}) { + const tr = _hex(crypto.getRandomValues(new Uint8Array(16))); + const lease = new Lease(this, tr, kind, bytes, chunks, onState); + if (!this.supportsTransferSlots) { + lease._skip(); + return lease; + } + this._leases.set(tr, lease); + lease._request(); + return lease; + } + + /** Re-ask for every live lease. Called after a reconnect. */ + _reopenTransfers() { + if (!this.supportsTransferSlots) return; + for (const lease of this._leases.values()) { + // The node lost the lease with the session, so this is a fresh request + // for the same `tr` — which the node treats as the same transfer rather + // than a second one. + if (!lease.closed) lease._request(); + } + } + + async fetchChunk(fileId, chunkIndex, tr = '') { const msg = await this._sendAndWait({ type: 'file_req', v: '0.1', file_id: fileId, chunk_index: chunkIndex, + // Present only when this download holds a slot. The node does not require + // it yet; carrying it is what lets the node see the transfer is alive and + // not reclaim its slot as idle. + ...(tr ? { tr } : {}), }); if (msg.type === 'error') throw new Error(msg.detail); return msg; @@ -2189,7 +2378,8 @@ class MeshBayTransport { * folder on screen to name. Omitting both leaves the node to pick, which it * only does for a client old enough to have had one destination. */ - async uploadFile(file, { chunkSize, onProgress, signal, root, dir } = {}) { + async uploadFile(file, { chunkSize, onProgress, signal, root, dir, + tr = '' } = {}) { // The same file twice at once would confuse the node, which keys its own // upload state by name — and would race for the same destination. The guard // is by name for that reason, even though the map below is keyed by id. @@ -2273,6 +2463,7 @@ class MeshBayTransport { upload_id: uploadId, chunk_index: i, total_chunks: total, + ...(tr ? { tr } : {}), ...sealed, }); } @@ -2992,6 +3183,24 @@ class MeshBayTransport { // "arrived" — and every message after that is one slot off too. Found // live: a group mid-scan corrupted its own handshake and chat history // this way, arriving roughly every 2s for as long as scanning ran. + // Routed by `tr`, and only by `tr`. A grant arrives unsolicited, minutes + // after the request that produced it, so falling through to "the oldest + // pending request" would hand a chat send or a handshake somebody else's + // slot — the class of defect `req_id` was introduced for. + if (msg.type === 'transfer_state') { + const lease = this._leases.get(msg.tr); + if (lease) lease._apply(msg); + else if (msg.state === 'granted') { + // A grant for a transfer this page has forgotten (a reload, a cancel + // that raced the grant). Handing it back at once matters: otherwise the + // node holds it until the 30 s acceptance deadline, and everyone behind + // it waits for nothing. + this._send({ type: 'transfer_close', v: '0.1', tr: msg.tr, + reason: 'cancelled' }); + } + return; + } + if (msg.type === 'index_progress') { if (this._onIndexProgress) { this._onIndexProgress({ diff --git a/packages/meshbay-hub/tests/test_layout_measured.py b/packages/meshbay-hub/tests/test_layout_measured.py index 91f1ed0..a71b6b9 100644 --- a/packages/meshbay-hub/tests/test_layout_measured.py +++ b/packages/meshbay-hub/tests/test_layout_measured.py @@ -54,7 +54,7 @@ NAV = textwrap.dedent("""
↓ - S03E01. Salt and Sea, Fire and Blood.mp4 + Some Saga S03E01 - A Long Enough Title.mp4
@@ -144,3 +144,120 @@ def test_the_page_does_not_scroll_sideways(measured, width): r = measured[str(width)] assert r["docScrollW"] <= r["viewport"]["w"], ( f"the document scrolls to {r['docScrollW']} px on a {width} px screen") + + +# ── The grouped panel (§8.2) ──────────────────────────────────────────────── +# +# The panel gained groups, a header summary and a waiting row. Every one of +# those can push something off a 320 px screen, and none of it can be seen by +# reading the stylesheet: what decides where the panel lands is the button it +# hangs from, which is not at the right edge. That is the defect this file was +# written for, and it comes back with any change to the header's width. + +GROUPED = textwrap.dedent(""" + +""") + +GROUPED_SELECTORS = [".transfer-panel", ".transfer-head", ".transfer-head-summary", + ".transfer-group-head", ".transfer-name", + ".transfer-item.transfer-queued .dl-progress"] + + +@pytest.fixture(scope="module") +def grouped(tmp_path_factory): + fragment = tmp_path_factory.mktemp("grouped") / "fragment.html" + fragment.write_text(GROUPED) + proc = subprocess.run( + ["python3", str(HARNESS), ",".join(str(w) for w in WIDTHS), + str(fragment), *GROUPED_SELECTORS], + capture_output=True, text=True, timeout=180) + assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}" + out = json.loads(proc.stdout) + assert "error" not in out, f"no measurement: {out}" + return out + + +def test_the_grouped_panel_stays_on_a_phone_screen(grouped): + for width in WIDTHS: + box = grouped[str(width)]["boxes"][".transfer-panel"] + assert box["offLeft"] == 0, ( + f"at {width} px the panel hangs {box['offLeft']} px off the left — " + "which is where the file names are") + assert box["offRight"] == 0, ( + f"at {width} px the panel hangs {box['offRight']} px off the right") + + +def test_the_file_name_is_on_screen_in_every_group(grouped): + for width in WIDTHS: + box = grouped[str(width)]["boxes"][".transfer-name"] + assert box["offLeft"] == 0 and box["offRight"] == 0, ( + f"at {width} px a file name is cut off: {box}") + assert box["width"] > 40, "the name column collapsed to nothing" + + +def test_the_header_summary_does_not_push_the_header_taller(grouped): + """It is the one part of the header that grows with what is happening. If + it wraps, the header changes height as transfers come and go and every row + below it moves — on the narrowest screen, repeatedly.""" + for width in WIDTHS: + head = grouped[str(width)]["boxes"][".transfer-head"] + summary = grouped[str(width)]["boxes"][".transfer-head-summary"] + assert head["height"] <= 48, ( + f"at {width} px the header is {head['height']} px tall — it wrapped") + assert summary["height"] <= 24, ( + f"at {width} px the summary wrapped to {summary['height']} px") + + +def test_the_waiting_bar_is_as_wide_as_a_progress_bar(grouped): + """A waiting row has no inner fill element — the stripes are on the track + itself. Getting that wrong renders a zero-width bar, which reads as a + transfer stuck at 0% rather than one that has not started.""" + for width in WIDTHS: + bar = grouped[str(width)]["boxes"][ + ".transfer-item.transfer-queued .dl-progress"] + assert bar["width"] > 100, ( + f"at {width} px the waiting bar is {bar['width']} px wide") + assert bar["height"] >= 3, "the waiting bar has no height" diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py index 9966e48..8430627 100644 --- a/packages/meshbay-hub/tests/test_memory_ceiling.py +++ b/packages/meshbay-hub/tests/test_memory_ceiling.py @@ -92,9 +92,15 @@ const downloads = {{ }}; globalThis.window = {{}}; if ({json.dumps(picker)}) {{ - window.showSaveFilePicker = async () => ({{ - name: 'p', createWritable: async () => ({{}}), - }}); + window.showSaveFilePicker = async () => {{ + if ({json.dumps(picker)} === 'no-gesture') {{ + const e = new Error("Failed to execute 'showSaveFilePicker' on 'Window': " + + "Must be handling a user gesture to show a file picker."); + e.name = 'SecurityError'; + throw e; + }} + return {{ name: 'p', createWritable: async () => ({{}}) }}; + }}; }} {target_fn} @@ -207,3 +213,27 @@ def test_the_guard_is_what_the_preview_uses_too(target_fn): "the preview modal must refuse an oversized entry before fetching it") assert not re.search(r"100\s*\*\s*1024\s*\*\s*1024", files_app), ( "the ceiling is defined once, in file-utils.js") + + +def test_a_lost_gesture_streams_instead_of_failing(target_fn, tmp_path): + """ + A browser grants one file picker per user gesture, and downloading three + files is one gesture — so the second and third throw "Must be handling a + user gesture". The person sees a failed transfer, with a message from Chrome + about gestures, for having done something entirely reasonable. + + The streamed path needs no gesture, so it is the right answer rather than a + consolation: the file lands on disk either way, and the only thing lost is + the choice of folder, which there was no picker to make anyway. + """ + out = _run(target_fn, tmp_path, size=20 * GB, + picker="no-gesture", streamed=True, mode="ask") + assert out == {"kind": "stream", "name": "s"}, out + + +def test_a_lost_gesture_with_nothing_to_stream_to_still_refuses(target_fn, tmp_path): + """And the ceiling still holds underneath: no gesture and no stream is not + a reason to put twenty gigabytes in the page.""" + out = _run(target_fn, tmp_path, size=20 * GB, + picker="no-gesture", streamed=False, mode="ask") + assert out["kind"] == "refused", out diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py index 3316615..e805afc 100644 --- a/packages/meshbay-hub/tests/test_transfers.py +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -222,3 +222,216 @@ def test_a_folder_name_carries_no_trailing_slash(): assert "dir-row" in row, "the anchor no longer lands on the directory row" assert "${d}/" not in row, "the folder name is rendered with a trailing slash" assert "${d}" in row + + +# ── Transfer slots, client side ───────────────────────────────────────────── +# +# A queue can lie in two directions, and both are worse than no queue: a +# transfer that shows "waiting" on a node that already granted it, and a slot +# the page holds after it has stopped using it. Everything below is one of +# those two. + +def _lease_stub(): + """A Lease as the store sees it, driveable from the test.""" + return """ +class L { + constructor() { + this.state = 'queued'; this.ahead = 2; this.closed = false; + this.released = []; this.tr = 'tr1'; + this._wait = new Promise(r => { this._go = r; }); + } + acquire() { return this._wait; } + release(reason) { if (!this.closed) { this.closed = true; this.released.push(reason); } } + grant() { this.state = 'granted'; if (this._onState) this._onState(this); this._go(); } + push(state, ahead) { this.state = state; this.ahead = ahead; if (this._onState) this._onState(this); } +} +""" + + +def test_a_transfer_waiting_for_a_slot_is_queued_not_running(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran'); } }); + say(t.list()[0].status, t.list()[0].ahead); + await new Promise(r => setTimeout(r, 0)); + say('still:' + t.list()[0].status); + """, tmp_path) + assert out[:2] == ["queued", 2] + assert "ran" not in out, "the work started before the slot was granted" + assert out[-1] == "still:queued" + + +def test_the_grant_starts_the_work(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran:' + t.list()[0].status); } }); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('after:' + t.list()[0].status); + """, tmp_path) + assert out[0] == "ran:running" + assert out[1] == "after:done" + + +def test_the_slot_comes_back_however_the_transfer_ends(tmp_path): + """A slot not returned is a member who cannot transfer again until the node + times it out — so this must hold for a throw as much as for a success.""" + out = _run(_lease_stub() + """ + for (const mode of ['ok', 'throw']) { + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { if (mode === 'throw') throw new Error('x'); } }); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say(mode + ':' + lease.released.join(',') + ':' + t.list()[0].status); + } + """, tmp_path) + assert out == ["ok:done:done", "throw:done:failed"] + + +def test_cancelling_while_queued_gives_the_slot_back(tmp_path): + """The transfer somebody is most likely to give up on is the one that has + not started. Its queue entry has to go, or the node grants a slot to a + transfer that will never use it.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + const id = t.start({ kind: 'download', name: 'f', total: 10, lease, + run: async () => { say('ran'); } }); + t.cancel(id); + say(t.list()[0].status, lease.released.join(',')); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('ran?', out.includes('ran')); + """, tmp_path) + assert out[0] == "cancelled" + assert out[1] == "cancelled" + assert out[-1] is False, "a cancelled transfer ran anyway once granted" + + +def test_a_queue_position_update_reaches_the_view(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + const seen = []; + t.subscribe(items => seen.push(items[0].ahead)); + t.start({ kind: 'download', name: 'f', total: 10, lease, run: async () => {} }); + lease.push('queued', 1); + lease.push('queued', 0); + say(seen.join('>')); + """, tmp_path) + assert out[0].endswith("1>0"), "the widget never learns it is moving up" + + +def test_a_transport_with_a_queued_transfer_is_not_closed(tmp_path): + """Closing it would leave the transfer waiting for a grant that can never + arrive — waiting for ever, with nothing left to answer.""" + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + let closed = false; + const transport = { close() { closed = true; } }; + t.start({ kind: 'download', name: 'f', total: 10, transport, lease, + run: async () => {} }); + t.releaseWhenIdle(transport); + say('closed while queued:', closed); + lease.grant(); + await new Promise(r => setTimeout(r, 10)); + say('closed after:', closed); + """, tmp_path) + assert out[1] is False + assert out[3] is True + + +def test_clearing_finished_keeps_what_is_waiting(tmp_path): + out = _run(_lease_stub() + """ + const t = new TransferStore(); + const lease = new L(); + t.start({ kind: 'download', name: 'waiting', total: 1, lease, run: async () => {} }); + t.start({ kind: 'download', name: 'done', total: 1, run: async () => {} }); + await new Promise(r => setTimeout(r, 10)); + t.clearFinished(); + say(t.list().map(i => i.name + ':' + i.status).join(',')); + """, tmp_path) + assert out[0] == "waiting:queued" + + +def test_asking_for_a_slot_on_a_dead_channel_does_not_throw(tmp_path): + """ + The transport reconnects on its own and re-asks for every live lease when it + does, so a closed channel at the moment a transfer starts is a wait, not a + failure. `_fetchChunkResilient` has always treated it that way — and before + leases existed a chunk request was the first thing to touch the channel, so + a download begun on a briefly dead connection simply retried. + + Asking for a slot first made `_send` the first contact. It threw + "DataChannel not open (state: closed)" straight out of `downloadEntry`, + where nothing catches it: a download that used to recover became an error + with no row in the widget to show it. Found live, by downloading a file + just after a connection dropped. + """ + module = tmp_path / "transport_lease.mjs" + # The real Lease, lifted out as text — the class is not exported, and a + # second copy of it here would agree with whatever it was copied from. + src = (STATIC / "transport.js").read_text() + # From the constant the class depends on, not from the class: lifting only + # the class left LEASE_WATCHDOG_MS undefined, which the class reads the + # first time it arms its watchdog. + start = src.index("const LEASE_WATCHDOG_MS") + end = src.index("\nclass MeshBayTransport") + module.write_text(src[start:end] + "\nexport { Lease };\n") + + script = tmp_path / "case.mjs" + script.write_text(f""" +import {{ Lease }} from '{module.as_posix()}'; +const out = []; +const transport = {{ + supportsTransferSlots: true, + _leases: new Map(), + _send() {{ throw new Error('DataChannel not open (state: closed)'); }}, +}}; +let threw = null; +const lease = new Lease(transport, 'tr1', 'download', 10, 1, null); +try {{ lease._request(); }} catch (e) {{ threw = e.message; }} +out.push(threw); +// And releasing one must be just as safe: a lease not released is a member who +// cannot start another transfer until the node times it out. +try {{ lease.release('cancelled'); out.push('release ok'); }} +catch (e) {{ out.push('release threw: ' + e.message); }} +clearTimeout(lease._watchdog); +console.log(JSON.stringify(out)); +""") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out[0] is None, f"asking for a slot threw: {out[0]}" + assert out[1] == "release ok" + + +def test_the_slot_is_asked_for_after_there_is_somewhere_to_write(): + """ + A granted slot has to be taken up within the node's acceptance deadline, so + it must not be asked for until the download can actually start. + + Asking first reads better — the widget could draw a row while the target is + being chosen — and is wrong: opening a target takes thirty seconds of + streamed-download timeouts, or as long as somebody leaves a Save As dialog + open. The node revokes the grant, passes it to the next in the queue + (`transfer: reclaimed … (not_taken_up)` in its log), and the download then + fetches under a `tr` that is no longer granted. Three downloads started, one + arrived. + + Source-reading, because the ordering is the whole property and it has no + behaviour of its own to drive: what matters is which call comes first. + """ + src = (STATIC / "file-utils.js").read_text() + fn = src[src.index("async function downloadEntry"):] + fn = fn[:fn.index("\n}\n")] + assert fn.index("_openDownloadTarget") < fn.index("openTransfer"), ( + "downloadEntry asks for a transfer slot before it has anywhere to " + "write — the grant expires before the download can use it") diff --git a/packages/meshbay-hub/tests/test_zip_size_limit.py b/packages/meshbay-hub/tests/test_zip_size_limit.py index 44ad59e..e970c55 100644 --- a/packages/meshbay-hub/tests/test_zip_size_limit.py +++ b/packages/meshbay-hub/tests/test_zip_size_limit.py @@ -81,7 +81,17 @@ if ({picker_js}) {{ const M = await import('{(sandbox / "file-utils.js").as_posix()}'); const transfers = {{ start: () => {{ out.started += 1; }} }}; -const transport = {{ connected: true }}; +// A transport hands out transfer slots now (transfers.py's leases). The stub +// grants at once, which is what a node with no caps does: what this file is +// about is the archive limit, not the queue. +const transport = {{ + connected: true, + openTransfer: () => ({{ + tr: 'stub', state: 'granted', ahead: 0, + acquire: () => Promise.resolve(), + release: () => {{}}, + }}), +}}; // One file, in the folder itself — entriesUnder keys on `path`. const entries = [{{ id: 'f1', name: 'big.bin', path: 'album', size: {total_bytes}, added_at: 0 }}]; -- cgit v1.2.3