diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 13:13:08 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 13:13:08 +0200 |
| commit | 41e2b79cb1bc9d188853aeff5a55cd2237268587 (patch) | |
| tree | be19e833e633d23cd42068ce40b8ab9baabed532 /packages | |
| parent | 8cd7e467ebec987f66c4fe93a8d87dfbc57304d2 (diff) | |
| download | meshbay-41e2b79cb1bc9d188853aeff5a55cd2237268587.tar.gz | |
feat(files): transfers that outlive the page, and selection instead of per-row menus
Downloads and uploads were state inside GroupPage. Leaving a group
unmounted the component, its cleanup closed the DataChannel, and a
half-written file was all you had — which is also why only one thing
could be in flight at a time.
They live in a module-level store now. A group page hands its transport
over on the way out rather than closing it, and the last transfer using
it closes it; signing out is the one thing that cancels everything,
because those transfers are moving data on a token about to stop being
ours. The store is plain JavaScript with no browser globals, so
test_transfers.py runs it under Node and pins the parts that are timing
and lifetime rather than markup: that a cancel stops the work instead of
greying out a row, that a stalled transfer reads as stalled rather than
reporting its own historical average, and that a released transport is
closed by the last transfer and not before.
The widget by the bell shows each transfer with its rate and a cancel
button, so the Files panel no longer carries progress bars — you can
watch a 40 GB archive from the chat, or from another group.
Selection replaces the per-row menu: a Select toggle puts checkboxes on
files and folders, and ⋮ Actions acts on what is ticked. Ticks survive
walking into another folder, so a selection can span directories.
Downloads start together and run together. Videos offer Play only — View
did the same thing, which is the sort of duplication that makes people
wonder what the difference is.
Uploads had to become parallel-safe for any of this to mean anything:
their acks were matched by arrival order, so two at once credited each
other's progress. The node names the file in every ack, so they are keyed
by name now — with the same file twice refused, since the node keys its
own upload state that way too.
Two mistakes worth recording. The selection column went into the body
rows and not the header, because that edit matched nothing and I had not
made it assert; the columns were misaligned until a screenshot showed it.
And the Actions menu opened leftwards from a button at the right edge of
the toolbar, half of it off-screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 564 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/i18n.js | 15 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 101 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transfers.js | 203 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 41 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_spa_ordering.py | 32 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_transfers.py | 169 |
7 files changed, 864 insertions, 261 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 995e7c7..03b76d5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -4,6 +4,7 @@ import { } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; +import { transfers, formatSpeed } from './transfers.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -255,6 +256,10 @@ const ICON_PATHS = { 'M3.4 6.6L12 13.4l8.6-6.8'], door: ['M13.5 3.5H6a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h7.5', 'M10.5 12H21', 'M17.8 8.8L21 12l-3.2 3.2'], + download: ['M12 3.5v12', 'M7.5 11l4.5 4.5 4.5-4.5', 'M4.5 20h15'], + upload: ['M12 20.5v-12', 'M7.5 13l4.5-4.5 4.5 4.5', 'M4.5 4h15'], + transfer: ['M6.5 3.5v11', 'M3.5 11l3 3.5 3-3.5', + 'M17.5 20.5v-11', 'M14.5 13l3-3.5 3 3.5'], clip: ['M20.5 11.8l-8.4 8.4a5.4 5.4 0 0 1-7.6-7.6l8.8-8.8a3.6 3.6 0 0 1 5.1 5.1l-8.8 8.8a1.8 1.8 0 0 1-2.5-2.5l8.1-8.1'], pencil: ['M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3', 'M14.5 6.5l3 3'], @@ -341,6 +346,87 @@ function UserMenu({ user, theme, onThemeChange, onLogout }) { `; } +// ── Transfers widget ───────────────────────────────────────────────────────── + +function TransferWidget() { + const [items, setItems] = useState(() => transfers.list()); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => transfers.subscribe(setItems), []); + + useEffect(() => { + if (!open) return; + const close = (e) => { + if (ref.current && !ref.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('click', close); + return () => document.removeEventListener('click', close); + }, [open]); + + const running = items.filter(i => i.status === 'running'); + if (!items.length) return null; + + return html` + <div class="transfer-wrap" ref=${ref}> + <button class="nav-notif transfer-btn" 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> + `} + </button> + ${open && html` + <div class="transfer-panel"> + <div class="transfer-head"> + ${t('transfers.title')} + <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> + <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> + </div> + `} + </div> + `)} + </div> + `} + </div> + `; +} + // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }) { @@ -354,6 +440,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount } <a class="nav-brand" href="#/">MeshBay</a> </div> <div class="nav-right"> + ${user && html`<${TransferWidget} />`} ${user && html` <a class="nav-notif" href="#/" title=${t('notif.title')}> <${Icon} name="bell" />${unreadCount > 0 @@ -832,10 +919,31 @@ function formatDate(ts) { // ── Group Page ────────────────────────────────────────────────────────────── +const PREVIEWABLE_TEXT = + /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; + +function canPreview(e) { + return ['image', 'video', 'document'].includes(e.type) + || PREVIEWABLE_TEXT.test(e.name); +} + const CHUNK_SIZE = 1024 * 1024; const PIPELINE_WINDOW = 8; -async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, writable) { +/** The download of last resort, for browsers with no way to stream to disk. */ +function _saveBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, + writable, signal) { const results = writable ? null : new Array(totalChunks); let nextSend = 0, nextRecv = 0; const inflight = new Array(totalChunks); @@ -849,6 +957,11 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk fire(); while (nextRecv < totalChunks) { + if (signal && signal.aborted) { + const err = new Error('Cancelled'); + err.name = 'AbortError'; + throw err; + } const chunkMsg = await inflight[nextRecv]; let plaintext; if (gekKey && chunkMsg.ct) { @@ -878,6 +991,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const [entries, setEntries] = useState([]); const [error, setError] = useState(''); + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState(() => new Set()); + const [actionsOpen, setActionsOpen] = useState(false); const [editingDesc, setEditingDesc] = useState(false); const [descDraft, setDescDraft] = useState(''); const [savingDesc, setSavingDesc] = useState(false); @@ -885,16 +1001,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const [sortAsc, setSortAsc] = useState(true); const [filter, setFilter] = useState(''); const [currentPath, setCurrentPath] = useState(''); - const [dlState, setDlState] = useState(null); const [videoEntry, setVideoEntry] = useState(null); const [previewEntry, setPreviewEntry] = useState(null); const [tab, setTab] = useState('files'); - const [uploading, setUploading] = useState(false); - const [ulState, setUlState] = useState(null); // Directories are not index entries, so a new empty one needs a nudge // to appear in the breadcrumb listing. const [nodeDirs, setNodeDirs] = useState([]); - const [menuOpen, setMenuOpen] = useState(null); + const [isNodeAdmin, setIsNodeAdmin] = useState(false); // Paired ≠ operator account. `is_node_admin` says the hub account owning this // node is the one connecting; this says the node pinned *this browser's* key @@ -922,11 +1035,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, }, [codeInput]); useEffect(() => { - if (menuOpen === null) return; - const close = () => setMenuOpen(null); + if (!actionsOpen) return; + const close = () => setActionsOpen(false); document.addEventListener('click', close); return () => document.removeEventListener('click', close); - }, [menuOpen]); + }, [actionsOpen]); // One place that takes an index from the node and puts it everywhere it has to // go. Deleting a file used to refresh the table and leave the cache alone, so @@ -1042,8 +1155,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, return () => { cancelled = true; if (transportRef.current) { - transportRef.current.onIndexSync = null; - transportRef.current.close(); + // Handed over rather than closed: a download running when you leave the + // group keeps its connection, and the last transfer using it closes it. + transfers.releaseWhenIdle(transportRef.current); transportRef.current = null; } }; @@ -1056,77 +1170,71 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const downloadFile = useCallback(async (entry) => { const transport = transportRef.current; if (!transport || !transport.connected) return; + const gek = gekRef.current; - setDlState({ fileId: entry.id, name: entry.name, progress: 0, total: entry.size }); + // The file picker has to be opened here, in the click, before anything is + // handed to the store: a browser only grants one from a user gesture. + let handle = null; + if (window.showSaveFilePicker) { + try { + handle = await window.showSaveFilePicker({ suggestedName: entry.name }); + } catch (err) { + if (err.name === 'AbortError') return; + throw err; + } + } - try { - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - let downloaded = 0; - const onProgress = (bytes) => { - downloaded += bytes; - setDlState(prev => ({ ...prev, progress: downloaded })); - }; + transfers.start({ + kind: 'download', name: entry.name, total: entry.size, transport, + run: async ({ signal, onProgress }) => { + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + let done = 0; + const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; - if (window.showSaveFilePicker) { - const handle = await window.showSaveFilePicker({ suggestedName: entry.name }); - const writable = await handle.createWritable(); - try { - await pipelinedDownload( - transport, gekRef.current, entry.id, totalChunks, onProgress, writable); - await writable.close(); - } catch (err) { - await writable.abort(); - throw err; + if (handle) { + const writable = await handle.createWritable(); + try { + await pipelinedDownload(transport, gek, entry.id, totalChunks, + onChunk, writable, signal); + await writable.close(); + } catch (err) { + await writable.abort().catch(() => {}); + throw err; + } + } else { + const chunks = await pipelinedDownload( + transport, gek, entry.id, totalChunks, onChunk, null, signal); + _saveBlob(new Blob(chunks), entry.name); } - } else { - const chunks = await pipelinedDownload( - transport, gekRef.current, entry.id, totalChunks, onProgress); - const blob = new Blob(chunks); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = entry.name; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - setDlState(null); - } catch (err) { - setDlState(null); - if (err.name === 'AbortError') return; - setError(t('group.dl_failed', { err: err.message })); - } + }, + }); }, []); - const uploadFile = useCallback(async (e) => { - const file = e.target.files?.[0]; - if (!file) return; + const uploadFile = useCallback((e) => { + const files = [...(e.target.files || [])]; e.target.value = ''; const transport = transportRef.current; - if (!transport || !transport.connected) return; - setUploading(true); + if (!files.length || !transport || !transport.connected) return; setError(''); - setUlState({ name: file.name, sent: 0, total: file.size, indexing: false }); - try { - await transport.uploadFile(file, { - // Bytes actually acknowledged by the node, not bytes read locally. - onProgress: (sent) => setUlState(prev => prev && { ...prev, sent }), + + for (const file of files) { + transfers.start({ + kind: 'upload', name: file.name, total: file.size, transport, + run: async ({ signal, onProgress }) => { + await transport.uploadFile(file, { + // Bytes the node acknowledged, not bytes read locally. + onProgress: (sent) => onProgress(sent, file.size), + signal, + }); + // 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 + // the list without anyone reloading. + await new Promise(r => setTimeout(r, 2500)); + if (transport.connected) applyIndex(await transport.fetchIndex()); + }, }); - // The node re-indexes on a filesystem event; there is nothing to poll, so - // say what is happening instead of showing a finished bar and no file. - setUlState(prev => prev && { ...prev, indexing: true }); - await new Promise(r => setTimeout(r, 2500)); - const indexMsg = await transport.fetchIndex(); - if (indexMsg.entries) setEntries(indexMsg.entries); - if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); - } catch (err) { - setError(err.message); - } finally { - setUploading(false); - setUlState(null); } - }, []); + }, [applyIndex]); const makeDirectory = useCallback(async () => { const transport = transportRef.current; @@ -1183,66 +1291,59 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); const suggested = (dir.split('/').pop() || 'files') + '.zip'; - let writable = null; + let handle = null; if (window.showSaveFilePicker) { - const handle = await window.showSaveFilePicker({ - suggestedName: suggested, - types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], - }); - writable = await handle.createWritable(); + try { + handle = await window.showSaveFilePicker({ + suggestedName: suggested, + types: [{ description: 'ZIP archive', + accept: { 'application/zip': ['.zip'] } }], + }); + } catch (err) { + if (err.name === 'AbortError') return; + throw err; + } } else if (!confirm(t('group.zip_no_stream', { size: formatSize(totalBytes), name: suggested, }))) { return; } + const gek = gekRef.current; - setDlState({ name: suggested, progress: 0, total: totalBytes }); - const parts = writable ? null : []; - let written = 0; - - try { - const zip = new ZipStream(async (bytes) => { - if (writable) await writable.write(bytes); - else parts.push(bytes.slice()); - }); - - for (const { entry, name } of files) { - await zip.begin(name, entry.size, - new Date((entry.added_at || 0) * 1000)); - // A zero-byte file has no chunk to ask for; the header and an empty - // descriptor are the whole entry. - const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); - if (totalChunks > 0) await pipelinedDownload( - transport, gekRef.current, entry.id, totalChunks, - (bytes) => { - written += bytes; - setDlState(prev => prev && { ...prev, progress: written }); - }, - // pipelinedDownload writes in order, which is what the archive needs. - { write: (plaintext) => zip.write(plaintext) }); - await zip.end(); - } - await zip.finish(); + transfers.start({ + kind: 'download', name: suggested, total: totalBytes, transport, + run: async ({ signal, onProgress }) => { + const writable = handle ? await handle.createWritable() : null; + const parts = writable ? null : []; + let written = 0; + try { + const zip = new ZipStream(async (bytes) => { + if (writable) await writable.write(bytes); + else parts.push(bytes.slice()); + }); - if (writable) { - await writable.close(); - } else { - const url = URL.createObjectURL(new Blob(parts, { type: 'application/zip' })); - const a = document.createElement('a'); - a.href = url; - a.download = suggested; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - setDlState(null); - } catch (err) { - setDlState(null); - if (writable) await writable.abort().catch(() => {}); - if (err.name === 'AbortError') return; - setError(t('group.dl_failed', { err: err.message })); - } + for (const { entry, name } of files) { + await zip.begin(name, entry.size, + new Date((entry.added_at || 0) * 1000)); + // A zero-byte file has no chunk to ask for; the header and an empty + // descriptor are the whole entry. + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + if (totalChunks > 0) await pipelinedDownload( + 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); + await zip.end(); + } + await zip.finish(); + if (writable) await writable.close(); + else _saveBlob(new Blob(parts, { type: 'application/zip' }), suggested); + } catch (err) { + if (writable) await writable.abort().catch(() => {}); + throw err; + } + }, + }); }, [entries]); const deleteDirectory = useCallback(async (dir) => { @@ -1343,6 +1444,74 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const breadcrumbs = currentPath ? currentPath.split('/') : []; + // Selection is keyed globally — file ids, and 'dir:' plus a full path — so + // walking into another folder keeps what was already ticked. + const dirKey = (name) => 'dir:' + (currentPath ? currentPath + '/' + name : name); + const selectedFiles = entries.filter(e => selected.has(e.id)); + const selectedDirs = [...selected] + .filter(k => typeof k === 'string' && k.startsWith('dir:')) + .map(k => k.slice(4)); + const toggle = (key) => setSelected(prev => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + + const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0 + ? selectedFiles[0] : null; + const deletableFiles = selectedFiles.filter( + e => isNodeAdmin || (userId && e.uploader_id === userId)); + const run = (fn) => { setActionsOpen(false); setSelecting(false); + setSelected(new Set()); fn(); }; + + const actionItems = html` + ${onlyFile && onlyFile.type === 'video' && html` + <button onClick=${() => run(() => setVideoEntry(onlyFile))}> + <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')} + </button> + `} + ${onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile) && html` + <button onClick=${() => run(() => setPreviewEntry(onlyFile))}> + <span class="fmi">${'\u{1F441}'}</span> ${t('group.view')} + </button> + `} + ${selectedFiles.length > 0 && html` + <button onClick=${() => run(() => { + // Started together and left to run together: each is its own transfer, + // and the widget by the bell is where they are watched from now. + for (const e of selectedFiles) downloadFile(e); + })}> + <span class="fmi">${'\u{2B07}'}</span> + ${t('group.download_n', { n: selectedFiles.length })} + </button> + `} + ${selectedDirs.length > 0 && html` + <button onClick=${() => run(() => { + for (const d of selectedDirs) downloadDirectory(d); + })}> + <span class="fmi">${'\u{2B07}'}</span> + ${t('group.download_zip_n', { n: selectedDirs.length })} + </button> + `} + ${status === 'connected' && (deletableFiles.length > 0 + || (operatorPaired && selectedDirs.length > 0)) && html` + <button class="danger" onClick=${() => { + const names = [...deletableFiles.map(e => e.name), + ...(operatorPaired ? selectedDirs : [])]; + if (!confirm(t('group.delete_n_confirm', { n: names.length, + names: names.join(', ') }))) return; + run(() => { + for (const e of deletableFiles) deleteFile(e); + if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d); + }); + }}> + <span class="fmi">${'\u{1F5D1}'}</span> + ${t('group.delete_n', { n: deletableFiles.length + + (operatorPaired ? selectedDirs.length : 0) })} + </button> + `} + `; + return html` <div> <div class="group-header"> @@ -1409,30 +1578,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, </div> </form> `} - ${ulState && html` - <div class="dl-bar"> - <span class="spinner"></span> - <span class="dl-name">${ulState.name}</span> - <div class="dl-progress"> - <div class="dl-fill" style=${`width:${ulState.total - ? Math.round(ulState.sent / ulState.total * 100) : 0}%`}></div> - </div> - <span class="dl-pct"> - ${ulState.indexing - ? t('group.upload_indexing') - : `${ulState.total ? Math.round(ulState.sent / ulState.total * 100) : 0}%`} - </span> - </div> - `} - ${dlState && html` - <div class="dl-bar"> - <span class="dl-name">${dlState.name}</span> - <div class="dl-progress"> - <div class="dl-fill" style="width:${Math.round(dlState.progress / dlState.total * 100)}%"></div> - </div> - <span class="dl-pct">${formatSize(dlState.progress)} / ${formatSize(dlState.total)}</span> - </div> - `} ${status === 'connected' && html` <div class="group-tabs"> <button class="group-tab ${tab === 'files' ? 'active' : ''}" @@ -1446,12 +1591,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, ${tab === 'files' && html` <div class="file-toolbar"> <label class="admin-btn upload-btn" style="cursor:pointer;margin-right:8px"> - ${uploading ? t('group.uploading') : t('group.upload')} - <input type="file" style="display:none" onChange=${uploadFile} - disabled=${uploading} /> + ${t('group.upload')} + <input type="file" multiple style="display:none" + onChange=${uploadFile} /> </label> - <button class="admin-btn" style="margin-right:8px" onClick=${makeDirectory} - disabled=${uploading}>${t('group.mkdir')}</button> + <button class="admin-btn" style="margin-right:8px" + onClick=${makeDirectory}>${t('group.mkdir')}</button> <div class="breadcrumbs"> <a class="crumb" onClick=${() => setCurrentPath('')}>/</a> ${breadcrumbs.map((seg, i) => { @@ -1464,10 +1609,31 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, </div> <input type="text" class="file-search" placeholder="${t('group.filter')}" value=${filter} onInput=${e => setFilter(e.target.value)} /> + <button class="admin-btn ${selecting ? 'active' : ''}" + style="margin-left:8px" + onClick=${() => { + setSelecting(v => !v); + setSelected(new Set()); + setActionsOpen(false); + }}> + ${selecting ? t('group.select_done') : t('group.select')} + </button> + ${selecting && html` + <div class="actions-wrap"> + <button class="admin-btn" disabled=${selected.size === 0} + onClick=${(ev) => { ev.stopPropagation(); setActionsOpen(o => !o); }}> + ${'\u{22EE}'} ${t('group.actions', { n: selected.size })} + </button> + ${actionsOpen && html` + <div class="file-menu">${actionItems}</div> + `} + </div> + `} </div> <table class="file-table"> <thead> <tr> + ${selecting && html`<th class="sel-cell"></th>`} <th></th> <th class="sortable" onClick=${() => toggleSort('name')}> ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} @@ -1481,7 +1647,6 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, <th class="sortable th-date" onClick=${() => toggleSort('date')}> ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''} </th> - <th></th> </tr> </thead> <tbody> @@ -1491,52 +1656,34 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); return html` <tr class="file-row dir-row" key=${full} onClick=${() => - setCurrentPath(full)}> + selecting ? toggle(dirKey(d)) : setCurrentPath(full)}> + ${selecting && html` + <td class="sel-cell"> + <input type="checkbox" checked=${selected.has(dirKey(d))} + onClick=${(ev) => ev.stopPropagation()} + onChange=${() => toggle(dirKey(d))} /> + </td> + `} <td>\u{1F4C1}</td> <td>${d}/</td> <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td> <td class="td-type"></td> <td class="td-date"></td> - <td class="file-actions-cell"> - <button class="file-menu-btn" onClick=${(ev) => { - ev.stopPropagation(); - setMenuOpen(menuOpen === 'dir:' + full ? null : 'dir:' + full); - }}>${'\u{22EE}'}</button> - ${menuOpen === 'dir:' + full && html` - <div class="file-menu"> - ${inside.length > 0 && html` - <button onClick=${(ev) => { - ev.stopPropagation(); - setMenuOpen(null); - downloadDirectory(full); - }}> - <span class="fmi">${'\u{2B07}'}</span> - ${t('group.download_zip', { n: inside.length })} - </button> - `} - ${operatorPaired && status === 'connected' && html` - <button class="danger" onClick=${(ev) => { - ev.stopPropagation(); - setMenuOpen(null); - if (inside.length) { setError(t('group.rmdir_not_empty')); return; } - if (confirm(t('group.rmdir_confirm', { name: d }))) { - deleteDirectory(full); - } - }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.rmdir')}</button> - `} - </div> - `} - </td> </tr> `; })} - ${sorted.map(e => { - const canPreview = ['image', 'video', 'document'].includes(e.type) - || e.name.match(/\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i); - return html` - <tr class="file-row" key=${e.id}> + ${sorted.map(e => html` + <tr class="file-row" key=${e.id} + onClick=${() => selecting && toggle(e.id)}> + ${selecting && html` + <td class="sel-cell"> + <input type="checkbox" checked=${selected.has(e.id)} + onClick=${(ev) => ev.stopPropagation()} + onChange=${() => toggle(e.id)} /> + </td> + `} <td>${FILE_ICONS[e.type] || FILE_ICONS.other}</td> <td class="file-name"> - ${canPreview + ${!selecting && canPreview(e) ? html`<a class="file-link" onClick=${() => { if (e.type === 'video') setVideoEntry(e); else setPreviewEntry(e); @@ -1547,42 +1694,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, <td class="file-size">${formatSize(e.size)}</td> <td class="file-type td-type">${e.type}</td> <td class="file-date td-date">${formatDate(e.added_at)}</td> - <td class="file-actions-cell"> - <button class="file-menu-btn" onClick=${(ev) => { - ev.stopPropagation(); - setMenuOpen(menuOpen === e.id ? null : e.id); - }}>${'\u{22EE}'}</button> - ${menuOpen === e.id && html` - <div class="file-menu"> - ${canPreview && html` - <button onClick=${() => { - setMenuOpen(null); - if (e.type === 'video') setVideoEntry(e); - else setPreviewEntry(e); - }}><span class="fmi">${'\u{1F441}'}</span> ${t('group.view')}</button> - `} - <button onClick=${() => { setMenuOpen(null); downloadFile(e); }}> - <span class="fmi">${'\u{2B07}'}</span> ${t('group.download')} - </button> - ${e.type === 'video' && html` - <button onClick=${() => { setMenuOpen(null); setVideoEntry(e); }}> - <span class="fmi">${'\u{25B6}'}</span> ${t('group.play')} - </button> - `} - ${(isNodeAdmin || (userId && e.uploader_id === userId)) && status === 'connected' && html` - <button class="danger" onClick=${() => { - setMenuOpen(null); - if (confirm(t('group.delete_confirm', { name: e.name }))) deleteFile(e); - }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.delete')}</button> - `} - </div> - `} - </td> </tr> - `; - })} + `)} ${sorted.length === 0 && subdirs.length === 0 && html` - <tr><td colspan="6" class="file-empty"> + <tr><td colspan=${selecting ? 6 : 5} class="file-empty"> ${filter ? t('group.empty_filter') : t('group.empty_dir')} </td></tr> `} @@ -3142,6 +3257,9 @@ function App() { saveAuth(u); }, logout: () => { + // Navigating away leaves transfers running; signing out does not. They + // are moving data on tokens that are about to stop being ours. + transfers.reset(); setUser(null); saveAuth(null); setGroups([]); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 14a6c78..f43981b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -85,13 +85,11 @@ const en = { 'group.empty_dir': 'This directory is empty', 'group.download': 'Download', 'group.play': 'Play', - 'group.dl_failed': 'Download failed: {err}', 'group.offline_title': 'No nodes are currently online for this group.', 'group.offline_hint': 'Files will appear when a node hosting this group connects.', 'group.upload': 'Upload', 'group.mkdir': 'New folder', 'group.mkdir_prompt': 'Name of the new folder:', - 'group.uploading': 'Uploading...', 'group.view': 'View', 'group.delete': 'Delete', 'group.delete_confirm': 'Delete {name}?', @@ -278,6 +276,19 @@ const en = { + 'you normally talk. It works once, and it never passes through the hub.', 'members.invite_code_hint': 'They enter it the first time they open this group. ' + 'You do not need to be online then.', + 'transfers.title': 'Transfers', + 'transfers.clear': 'Clear finished', + 'transfers.cancel': 'Cancel', + 'transfers.done': 'Finished', + 'transfers.cancelled': 'Cancelled', + 'transfers.failed': 'Failed', + 'group.select': 'Select', + 'group.select_done': 'Done', + 'group.actions': 'Actions ({n})', + 'group.download_n': 'Download ({n})', + 'group.download_zip_n': 'Download folders as zip ({n})', + 'group.delete_n': 'Delete ({n})', + 'group.delete_n_confirm': 'Delete {n} item(s)? {names}', 'group.download_zip': 'Download as zip ({n} files)', 'group.zip_empty': 'That folder has nothing in it to download.', 'group.zip_no_stream': 'This browser cannot write a download straight to disk, ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index f83b433..24e8843 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -807,26 +807,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .dl-btn:hover { background: var(--bg-raised); border-color: var(--accent); } .dl-btn:disabled { opacity: 0.3; cursor: not-allowed; } -.dl-bar { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: var(--bg-surface); - border: 1px solid var(--border); - border-radius: 8px; - margin-bottom: 12px; - font-size: 0.85em; -} -.dl-name { - flex-shrink: 0; - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 500; -} .dl-progress { flex: 1; @@ -843,12 +824,6 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } transition: width 0.2s; } -.dl-pct { - flex-shrink: 0; - color: var(--text-secondary); - font-size: 0.85em; - white-space: nowrap; -} /* ── Settings page ───────────────────────────────────────────────────────── */ @@ -1204,6 +1179,82 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .group-desc-edit textarea:focus { outline: none; border-color: var(--border-focus); } .group-desc-edit div { display: flex; gap: 6px; margin-top: 4px; } +/* ── Transfers widget ────────────────────────────────────────────────────── */ + +.transfer-wrap { position: relative; display: flex; align-items: center; } +.transfer-btn { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; +} +.transfer-panel { + position: absolute; + top: calc(100% + 10px); + right: -8px; + width: 330px; + max-height: 60vh; + overflow-y: auto; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow-lg); + z-index: 60; + padding: 6px; +} +.transfer-head { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 600; + font-size: 0.85em; + color: var(--text); + padding: 4px 6px 6px; + border-bottom: 1px solid var(--border); +} +.transfer-item { padding: 8px 6px; border-bottom: 1px solid var(--border); } +.transfer-item:last-child { border-bottom: none; } +.transfer-line { display: flex; align-items: center; gap: 6px; } +.transfer-kind { color: var(--text-dim); display: flex; } +.transfer-name { + flex: 1; + font-size: 0.82em; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.transfer-cancel { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + padding: 0 2px; + display: flex; +} +.transfer-cancel:hover { color: var(--error); } +.transfer-meta { + display: flex; + justify-content: space-between; + font-size: 0.72em; + color: var(--text-dim); + margin-top: 3px; +} +.transfer-failed { color: var(--error); } + +/* ── File selection ──────────────────────────────────────────────────────── */ + +.sel-cell { width: 28px; text-align: center; } +.sel-cell input { cursor: pointer; } +.actions-wrap { position: relative; display: inline-flex; margin-left: 8px; } +/* Anchored to the right edge: the button sits at the end of the toolbar, so a + menu opening leftwards is a menu half off the screen. */ +.actions-wrap .file-menu { top: calc(100% + 4px); right: 0; left: auto; } +.actions-wrap .file-menu button { white-space: nowrap; } +.admin-btn.active { border-color: var(--accent); color: var(--accent); } + .admin-role-select { padding: 3px 6px; border: 1px solid var(--border); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js new file mode 100644 index 0000000..1ad1ec5 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -0,0 +1,203 @@ +/** + * Transfers that outlive the page that started them. + * + * Downloads and uploads used to be state inside GroupPage, which meant leaving + * a group killed them — the component unmounted, its effect closed the + * DataChannel, and a half-written file was all you had. They live here instead: + * a module-level store that nothing unmounts, with the group page as one of + * several possible views onto it. + * + * Two consequences worth stating, because they are the reason this exists: + * + * - The transport cannot be closed just because a page went away. A group + * page hands its transport over with `releaseWhenIdle()`, and the last + * transfer using it closes it. + * - Signing out is different from navigating. It cancels everything and + * closes what it was using, because the tokens those transfers are running + * on are about to stop being ours. + * + * No browser globals: exercised under Node by + * packages/meshbay-hub/tests/test_transfers.py. + */ + +const SPEED_WINDOW_MS = 5000; + +let _nextId = 1; + +export class TransferStore { + constructor(now = () => Date.now()) { + this._now = now; + this._items = []; + this._subs = new Set(); + this._releasing = new Set(); + } + + subscribe(fn) { + this._subs.add(fn); + return () => this._subs.delete(fn); + } + + _emit() { + for (const fn of this._subs) fn(this.list()); + } + + /** + * What a view needs to render, as plain data — never the internals, so a + * render cannot accidentally hold a transport alive. + */ + list() { + return this._items.map(it => ({ + id: it.id, + kind: it.kind, + name: it.name, + total: it.total, + done: it.done, + status: it.status, + error: it.error || '', + speed: this._speed(it), + percent: it.total ? Math.min(100, Math.round(it.done / it.total * 100)) : 0, + })); + } + + get active() { + return this._items.filter(it => it.status === 'running').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. + const s = it.samples; + if (s.length < 2) return 0; + const dt = (s[s.length - 1].t - s[0].t) / 1000; + if (dt <= 0) return 0; + return (s[s.length - 1].done - s[0].done) / dt; + } + + /** + * Start a transfer. + * + * `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 }) { + const item = { + id: _nextId++, + kind, name, total, transport, + done: 0, + status: 'running', + error: '', + samples: [{ t: this._now(), done: 0 }], + signal: { aborted: false }, + }; + this._items.push(item); + this._emit(); + + const onProgress = (done, total) => { + item.done = done; + if (total) item.total = total; + const t = this._now(); + item.samples.push({ t, done }); + while (item.samples.length > 2 && t - item.samples[0].t > SPEED_WINDOW_MS) { + item.samples.shift(); + } + this._emit(); + }; + + const finish = (status, error = '') => { + item.status = status; + item.error = error; + this._emit(); + this._maybeRelease(item.transport); + }; + + const promise = Promise.resolve() + .then(() => run({ signal: item.signal, onProgress })) + .then(() => { + if (item.signal.aborted) finish('cancelled'); + else { + if (item.total) item.done = item.total; + finish('done'); + } + }) + .catch(err => { + if (item.signal.aborted || err.name === 'AbortError') finish('cancelled'); + else finish('failed', err.message || String(err)); + }); + + item.promise = promise; + return item.id; + } + + cancel(id) { + const item = this._items.find(it => it.id === id); + if (!item || item.status !== 'running') return; + item.signal.aborted = true; + // 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'; + this._emit(); + this._maybeRelease(item.transport); + } + + cancelAll() { + for (const it of this._items) { + if (it.status === 'running') this.cancel(it.id); + } + } + + /** Drop everything finished, keeping what is still running. */ + clearFinished() { + this._items = this._items.filter(it => it.status === 'running'); + this._emit(); + } + + _busy(transport) { + return this._items.some( + it => it.transport === transport && it.status === 'running'); + } + + /** + * The group page is going away. Close its transport once nothing is using it, + * which may be now or may be in twenty minutes. + */ + releaseWhenIdle(transport) { + if (!transport) return; + this._releasing.add(transport); + this._maybeRelease(transport); + } + + _maybeRelease(transport) { + if (!transport || !this._releasing.has(transport)) return; + if (this._busy(transport)) return; + this._releasing.delete(transport); + try { + transport.onIndexSync = null; + transport.close(); + } catch { /* already gone */ } + } + + /** Signing out: stop everything and let go of every transport. */ + reset() { + this.cancelAll(); + for (const transport of [...this._releasing]) { + this._releasing.delete(transport); + try { transport.close(); } catch { /* already gone */ } + } + for (const it of this._items) { + if (it.transport) { + try { it.transport.close(); } catch { /* already gone */ } + } + } + this._items = []; + this._emit(); + } +} + +export const transfers = new TransferStore(); + +/** Human-readable rate, for a widget that updates several times a second. */ +export function formatSpeed(bytesPerSecond) { + if (!bytesPerSecond || bytesPerSecond < 1) return ''; + if (bytesPerSecond < 1024 * 1024) return `${Math.round(bytesPerSecond / 1024)} KB/s`; + return `${(bytesPerSecond / (1024 * 1024)).toFixed(1)} MB/s`; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 326aa8e..24c08b9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -42,6 +42,12 @@ const UPLOAD_CHUNK_SIZE = 48 * 1024; const UPLOAD_WINDOW = 32; const UPLOAD_BUFFER_HIGH = 1024 * 1024; +function _aborted() { + const err = new Error('Cancelled'); + err.name = 'AbortError'; + return err; +} + const JOIN_REFUSALS = { code_required: 'This node does not know this browser yet. Ask the node operator ' + 'for a pairing code (meshbay-node operator pair).', @@ -73,7 +79,10 @@ class MeshBayTransport { this._onStreamData = null; this._onStreamEnd = null; this._onIndexSync = null; - this._onUploadAck = null; + // filename → the uploader waiting on it. Keyed rather than FIFO because + // several uploads may be in flight at once and their acks interleave; the + // node names the file in every one. + this._uploaders = new Map(); } get connected() { return this._connected; } @@ -533,10 +542,12 @@ class MeshBayTransport { * free one rather than replacing anything. The ack says which, and that is what * this returns. */ - async uploadFile(file, { chunkSize, onProgress } = {}) { - // One at a time: the acks are matched by arrival, so two uploads sharing the - // channel would credit each other's progress and finish at the wrong moment. - if (this._onUploadAck) throw new Error('Another upload is already running'); + async uploadFile(file, { chunkSize, onProgress, signal } = {}) { + // 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. + if (this._uploaders.has(file.name)) { + throw new Error(`${file.name} is already being uploaded`); + } const size = chunkSize || UPLOAD_CHUNK_SIZE; const total = Math.max(1, Math.ceil(file.size / size)); let acked = 0; @@ -544,7 +555,7 @@ class MeshBayTransport { let failure = null; const acks = []; - this._onUploadAck = (msg) => { + this._uploaders.set(file.name, (msg) => { if (msg.type === 'error') { failure = new Error(msg.detail || 'Upload refused'); } else if (msg.stored_as) { @@ -554,15 +565,17 @@ class MeshBayTransport { if (onProgress) onProgress(Math.min(file.size, acked * size), file.size); const waiter = acks.shift(); if (waiter) waiter(); - }; + }); const nextAck = () => new Promise(r => acks.push(r)); try { for (let i = 0; i < total; i++) { + if (signal && signal.aborted) throw _aborted(); // Backpressure: without it the whole file lands in the browser's send // buffer in seconds and the progress bar becomes a work of fiction. while (this._channel && this._channel.bufferedAmount > UPLOAD_BUFFER_HIGH) { + if (signal && signal.aborted) throw _aborted(); await new Promise(r => setTimeout(r, 20)); } while (i - acked >= UPLOAD_WINDOW) { @@ -587,7 +600,7 @@ class MeshBayTransport { if (failure) throw failure; } } finally { - this._onUploadAck = null; + this._uploaders.delete(file.name); } return stored || {}; } @@ -772,9 +785,15 @@ class MeshBayTransport { // While an upload is in flight the acks are its own, and there are many of // them: they must not be handed to whatever request happens to be oldest in // the pending map. - if (this._onUploadAck - && (msg.type === 'file_upload_ack' || msg.type === 'error')) { - this._onUploadAck(msg); + if (msg.type === 'file_upload_ack' && this._uploaders.has(msg.filename)) { + this._uploaders.get(msg.filename)(msg); + return; + } + // An error carries no filename. With one upload running it is that + // upload's; with several there is no way to tell, so they all hear it and + // stop — which is the safe reading of an error on a shared channel. + if (msg.type === 'error' && this._uploaders.size) { + for (const handler of [...this._uploaders.values()]) handler(msg); return; } if (msg.type === 'chat_msg' && this._onChat) { diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index 1329b74..04c5159 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -167,3 +167,35 @@ def test_no_caller_waits_for_one_chunk_at_a_time(): app = APP.read_text() assert "uploadChunk(" not in app, ( "a per-chunk await is back in the SPA; use transport.uploadFile()") + + +# ── Transfers outlive the page ────────────────────────────────────────────── +# +# Downloads and uploads used to be state inside GroupPage, so leaving a group +# unmounted the component, its cleanup closed the DataChannel, and a half-written +# file was all you had. The store in transfers.js owns them now; these check the +# two ends of that, since neither shows up in any Python test. + +def test_leaving_a_group_hands_the_transport_over_rather_than_closing_it(): + app = APP.read_text() + cleanup = app[app.index(" return () => {\n cancelled = true;"):] + cleanup = cleanup[:cleanup.index("\n }, [groupId")] + assert "releaseWhenIdle" in cleanup, ( + "the group page closes its transport directly again — a running " + "download would die with the page") + assert ".close()" not in cleanup + + +def test_signing_out_stops_them(): + app = APP.read_text() + logout = app[app.index(" logout: () => {"):] + logout = logout[:logout.index("navigate('/login')")] + assert "transfers.reset()" in logout, ( + "logout must cancel transfers: they run on tokens that stop being ours") + + +def test_the_files_panel_no_longer_carries_its_own_progress_bars(): + """They moved next to the bell, where they stay visible across the app.""" + app = APP.read_text() + for gone in ("setUlState", "setDlState", "dl-bar"): + assert gone not in app, f"{gone} survived the move to the transfer widget" diff --git a/packages/meshbay-hub/tests/test_transfers.py b/packages/meshbay-hub/tests/test_transfers.py new file mode 100644 index 0000000..08743f6 --- /dev/null +++ b/packages/meshbay-hub/tests/test_transfers.py @@ -0,0 +1,169 @@ +""" +The transfer store, which is what keeps a download alive after you leave a group. + +Run under Node, because the behaviour worth pinning is timing and lifetime: +that a cancel actually stops the work rather than only greying out a row, that a +stalled transfer reads as slow instead of reporting its historical average, and +that a transport handed over by a departing page is closed by the last transfer +using it — not before, and not never. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +TRANSFERS = STATIC / "transfers.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not TRANSFERS.exists(), + reason="node or the SPA sources are not available") + + +def _run(body, tmp_path): + module = tmp_path / "transfers.mjs" + module.write_text(TRANSFERS.read_text()) + script = tmp_path / "case.mjs" + script.write_text( + f"import {{ TransferStore, formatSpeed }} from '{module.as_posix()}';\n" + "const out = [];\n" + "const say = (...a) => out.push(...a);\n" + f"{body}\n" + "console.log(JSON.stringify(out));\n") + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_a_cancel_actually_stops_the_work(tmp_path): + """ + The flag has to be read by the thing doing the work. A store that only + marks a row cancelled gives you a button that lies. + """ + result = _run(""" +const store = new TransferStore(); +let chunksWritten = 0; +const id = store.start({ + kind: 'download', name: 'big.mp4', total: 100, + run: async ({ signal, onProgress }) => { + for (let i = 0; i < 100; i++) { + if (signal.aborted) throw Object.assign(new Error('stop'), { name: 'AbortError' }); + chunksWritten++; + onProgress(i + 1, 100); + await new Promise(r => setTimeout(r, 1)); + } + }, +}); +await new Promise(r => setTimeout(r, 20)); +store.cancel(id); +await new Promise(r => setTimeout(r, 40)); +say(chunksWritten < 100, store.list()[0].status, store.active); +""", tmp_path) + assert result[0] is True, "the work ran to completion despite being cancelled" + assert result[1] == "cancelled" + assert result[2] == 0 + + +def test_speed_is_measured_over_a_window_not_since_the_start(tmp_path): + """A transfer that stalls should read as slow now, not as its own average.""" + result = _run(""" +let clock = 0; +const store = new TransferStore(() => clock); +let report; +store.start({ + kind: 'download', name: 'x', total: 100_000_000, + run: async ({ onProgress }) => { report = onProgress; await new Promise(() => {}); }, +}); +await new Promise(r => setTimeout(r, 1)); // start() defers run() by a tick +// A fast megabyte a second, for six seconds. +for (let i = 1; i <= 6; i++) { clock = i * 1000; report(i * 1_000_000, 100_000_000); } +say(Math.round(store.list()[0].speed)); +// Then it stalls: the clock moves, the bytes do not. +for (let i = 7; i <= 12; i++) { clock = i * 1000; report(6_000_000, 100_000_000); } +say(Math.round(store.list()[0].speed)); +""", tmp_path) + assert 900_000 <= result[0] <= 1_100_000, f"expected ~1 MB/s, got {result[0]}" + assert result[1] == 0, f"a stalled transfer still reports {result[1]} B/s" + + +def test_a_transport_is_closed_by_the_last_transfer_that_needed_it(tmp_path): + """ + Leaving a group page must not kill a running download, and must not leak + the connection either. + """ + result = _run(""" +const store = new TransferStore(); +let closed = 0; +const transport = { close: () => { closed++; }, onIndexSync: () => {} }; +let done1, done2; +store.start({ kind: 'download', name: 'a', transport, + run: () => new Promise(r => { done1 = r; }) }); +store.start({ kind: 'download', name: 'b', transport, + run: () => new Promise(r => { done2 = r; }) }); + +store.releaseWhenIdle(transport); // the page goes away +await new Promise(r => setTimeout(r, 5)); +say(closed); // still working: must stay open + +done1(); await new Promise(r => setTimeout(r, 5)); +say(closed); // one left: still open + +done2(); await new Promise(r => setTimeout(r, 5)); +say(closed); // now it can go +""", tmp_path) + assert result == [0, 0, 1], f"close() calls after each step: {result}" + + +def test_an_idle_transport_is_closed_straight_away(tmp_path): + result = _run(""" +const store = new TransferStore(); +let closed = 0; +store.releaseWhenIdle({ close: () => { closed++; } }); +say(closed); +""", tmp_path) + assert result == [1], "leaving a group with nothing running should close it" + + +def test_signing_out_cancels_everything_and_lets_go(tmp_path): + """ + Navigating is not signing out. The second one has to stop transfers running + on tokens that are about to stop being ours. + """ + result = _run(""" +const store = new TransferStore(); +let closed = 0; +const transport = { close: () => { closed++; } }; +store.start({ kind: 'upload', name: 'a', transport, + run: () => new Promise(() => {}) }); +store.reset(); +await new Promise(r => setTimeout(r, 5)); +say(store.list().length, closed, store.active); +""", tmp_path) + assert result == [0, 1, 0] + + +def test_a_failure_is_kept_and_named(tmp_path): + """A transfer that dies silently is one the user retries at random.""" + result = _run(""" +const store = new TransferStore(); +store.start({ kind: 'download', name: 'gone.mp4', + run: async () => { throw new Error('File not found'); } }); +await new Promise(r => setTimeout(r, 5)); +const row = store.list()[0]; +say(row.status, row.error); +store.clearFinished(); +say(store.list().length); +""", tmp_path) + assert result[0] == "failed" + assert result[1] == "File not found" + assert result[2] == 0 + + +def test_the_rate_is_readable(tmp_path): + result = _run(""" +say(formatSpeed(0), formatSpeed(2048), formatSpeed(5 * 1024 * 1024)); +""", tmp_path) + assert result == ["", "2 KB/s", "5.0 MB/s"] |