aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-08 22:54:16 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-08 22:54:16 +0200
commit1a495f5ed3f8a55222d406152c833882264dc377 (patch)
tree0c048544cba200fe5ba939d45edd311b2fc69e59 /packages/meshbay-hub/src/meshbay_hub/static
parent6803447a8a5cc7a612d08bb858394fd7ae1b049c (diff)
downloadmeshbay-1a495f5ed3f8a55222d406152c833882264dc377.tar.gz
feat(hub): client-side transfer leases and the transfers panel
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js173
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js96
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css64
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js105
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js213
17 files changed, 715 insertions, 77 deletions
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`
<div class="transfer-wrap" ref=${ref}>
<button class="nav-notif transfer-btn ${running.length ? 'active' : ''}"
+ aria-label=${t('transfers.title')}
+ aria-expanded=${open ? 'true' : 'false'}
title=${t('transfers.title')}
onClick=${(e) => { e.stopPropagation(); setOpen(o => !o); }}>
<${Icon} name="transfer" />
- ${running.length > 0 && html`
- <span class="notif-badge">${running.length}</span>
+ ${active > 0 && html`
+ <span class="notif-badge">${active}</span>
`}
</button>
+ ${/* 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. */''}
+ <span class="sr-only" aria-live="polite">
+ ${t('transfers.summary', { running: running.length, waiting: waiting.length })}
+ </span>
${open && html`
- <div class="transfer-panel">
+ <div class="transfer-panel" role="group"
+ aria-label=${t('transfers.title')}>
<div class="transfer-head">
- ${t('transfers.title')}
- <button class="btn-secondary"
- onClick=${() => transfers.clearFinished()}>
- ${t('transfers.clear')}
- </button>
+ <span class="transfer-head-title">${t('transfers.title')}</span>
+ ${active > 0 && html`
+ <span class="transfer-head-summary">
+ ${t('transfers.summary', {
+ running: running.length, waiting: waiting.length })}
+ </span>
+ `}
+ ${finished.length > 0 && html`
+ <button class="btn-secondary"
+ onClick=${() => transfers.clearFinished()}>
+ ${t('transfers.clear')}
+ </button>
+ `}
</div>
- ${items.map(it => html`
- <div class="transfer-item" key=${it.id}>
- <div class="transfer-line">
- <span class="transfer-kind">
- <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} />
- </span>
- ${it.canOpen
- ? html`<a class="transfer-name" href="#" title=${it.name}
- onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>`
- : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`}
- ${it.status === 'running' && html`
- <button class="transfer-cancel" title=${t('transfers.cancel')}
- onClick=${() => transfers.cancel(it.id)}>
- <${Icon} name="close" />
- </button>
- `}
- </div>
- ${it.status === 'running'
- ? html`
- <div class="dl-progress">
- <div class="dl-fill" style="width:${it.percent}%"></div>
- </div>
- <div class="transfer-meta">
- <span>${formatSize(it.done)}${it.total
- ? ' / ' + formatSize(it.total) : ''}</span>
- <span>${formatSpeed(it.speed)}</span>
- </div>
- `
- : html`
- <div class="transfer-meta">
- <span class=${it.status === 'failed' ? 'transfer-failed' : ''}>
- ${it.status === 'done' ? t('transfers.done')
- : it.status === 'cancelled' ? t('transfers.cancelled')
- : it.error || t('transfers.failed')}
- </span>
- ${it.canOpen && html`
- <button class="link-btn" onClick=${() => transfers.open(it.id)}>
- ${t('transfers.open')}
- </button>
- `}
- </div>
- `}
+ ${groups.map(([label, rows]) => html`
+ <div class="transfer-group" key=${label}>
+ ${groups.length > 1 && html`
+ <div class="transfer-group-head">
+ ${t('transfers.group_' + label, { n: rows.length })}
+ </div>
+ `}
+ ${rows.map(it => html`<${TransferRow} it=${it} key=${it.id} />`)}
</div>
`)}
</div>
@@ -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`
+ <div class="transfer-item transfer-${it.status}">
+ <div class="transfer-line">
+ <span class="transfer-kind" aria-hidden="true">
+ <${Icon} name=${it.kind === 'upload' ? 'upload' : 'download'} />
+ </span>
+ ${it.canOpen
+ ? html`<a class="transfer-name" href="#" title=${it.name}
+ onClick=${(e) => { e.preventDefault(); transfers.open(it.id); }}>${it.name}</a>`
+ : html`<span class="transfer-name" title=${it.name}>${it.name}</span>`}
+ ${(it.status === 'running' || it.status === 'queued') && html`
+ <button class="transfer-cancel"
+ aria-label=${t('transfers.cancel_one', { name: it.name })}
+ title=${t('transfers.cancel')}
+ onClick=${() => transfers.cancel(it.id)}>
+ <${Icon} name="close" />
+ </button>
+ `}
+ </div>
+ ${it.status === 'queued'
+ ? html`
+ <div class="dl-progress dl-waiting"></div>
+ <div class="transfer-meta">
+ <span>${it.queuedByOwnLimit
+ ? t('transfers.waiting_own_slots')
+ : t('transfers.waiting_node', { n: it.ahead })}</span>
+ <span>${formatSize(it.total)}</span>
+ </div>
+ `
+ : it.status === 'running'
+ ? html`
+ <div class="dl-progress" role="progressbar"
+ aria-valuenow=${it.percent} aria-valuemin="0" aria-valuemax="100">
+ <div class="dl-fill" style="width:${it.percent}%"></div>
+ </div>
+ <div class="transfer-meta">
+ <span>${formatSize(it.done)}${it.total
+ ? ' / ' + formatSize(it.total) : ''}</span>
+ <span>${[formatSpeed(it.speed),
+ it.settled && eta !== null ? formatEta(eta) : '']
+ .filter(Boolean).join(' · ')}</span>
+ </div>
+ `
+ : html`
+ <div class="transfer-meta">
+ <span class=${it.status === 'failed' ? 'transfer-failed' : ''}>
+ ${it.status === 'done' ? t('transfers.done')
+ : it.status === 'cancelled' ? t('transfers.cancelled')
+ : it.error || t('transfers.failed')}
+ </span>
+ ${it.canOpen && html`
+ <button class="link-btn" onClick=${() => transfers.open(it.id)}>
+ ${t('transfers.open')}
+ </button>
+ `}
+ </div>
+ `}
+ </div>
+ `;
+}
+
+/** "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
@@ -377,6 +507,20 @@ class MeshBayTransport {
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`,
* `chat_directory`, `chat_link_preview`.
@@ -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({