diff options
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"] |