diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/file-utils.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/file-utils.js | 57 |
1 files changed, 46 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js index ef074dd..99d9f9a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -118,7 +118,10 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, filename, { auto: downloads.getMode() === 'auto' }); // Null means the person dismissed the dialog, which is not an error and // must not start a transfer. - return native || false; + // + // The desktop sink is an open file stream in the main process: not + // writing to it for a while costs nothing and loses nothing. + return native ? { ...native, pausable: true } : false; } catch (err) { console.warn('[MeshBay] native save failed:', platform.bridgeMessage(err)); return false; @@ -179,7 +182,8 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, const handle = await window.showSaveFilePicker({ suggestedName: filename, ...pickerOpts, }); - return { writable: await handle.createWritable(), name: handle.name || filename }; + return { writable: await handle.createWritable(), + name: handle.name || filename, pausable: true }; } catch (err) { if (err.name === 'AbortError') return false; // "Must be handling a user gesture to show a file picker." @@ -350,9 +354,10 @@ async function _fetchChunkResilient(transport, fileId, index, tr = '') { } async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk, - writable, signal, tr = '') { - const results = writable ? null : new Array(totalChunks); - let nextSend = 0, nextRecv = 0; + writable, signal, tr = '', fromChunk = 0, + results = null) { + if (!writable && !results) results = new Array(totalChunks); + let nextSend = fromChunk, nextRecv = fromChunk; const inflight = new Array(totalChunks); const fire = () => { @@ -369,6 +374,21 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk err.name = 'AbortError'; throw err; } + // Between two chunks, never inside one. Everything written so far is a + // whole number of chunks, which is what makes resuming exact rather than + // approximate — `fromChunk` is a position, not an estimate, and a resumed + // file is never appended to at an offset nobody checked. + // + // The chunks already in flight past this point are abandoned and asked for + // again on resume: at most one pipeline window of duplicated traffic, in + // exchange for not having to hold a half-received window across a pause of + // unknown length. + if (signal && signal.paused) { + signal.resumeFrom = nextRecv; + const err = new Error('Paused'); + err.name = 'PausedError'; + throw err; + } const chunkMsg = await inflight[nextRecv]; // One shape, and a refusal for anything else. There used to be two fallbacks // below this: a base64 `ct_b64` chunk, which was the real wire format until @@ -409,6 +429,9 @@ async function downloadEntry(transfers, transport, gek, entry) { const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); const openRef = { url: null }; let target = null; + // The in-memory fallback's accumulator, held out here so a pause does not + // discard what has already been decrypted. + const memoryChunks = new Array(totalChunks); transfers.start({ kind: 'download', name: entry.name, total: entry.size, transport, @@ -421,7 +444,10 @@ async function downloadEntry(transfers, transport, gek, entry) { target = await _openTargetInTurn(entry.name, entry.size); // Dismissed: nothing was started, so nothing is left on screen. if (target === false) return false; - return target ? { name: target.name } : true; + // `pausable` travels with the target, because only the target knows. The + // in-memory fallback (a null target) is just an array and pauses fine. + return target ? { name: target.name, pausable: !!target.pausable } + : { pausable: true }; }, // After the target, never before: a granted slot has to be taken up within @@ -434,24 +460,33 @@ async function downloadEntry(transfers, transport, gek, entry) { open: () => (target && target.open) ? target.open() : (openRef.url ? window.open(openRef.url, '_blank') : undefined), - run: async ({ signal, onProgress, lease }) => { - let done = 0; + // Kept across a pause: the chunks collected so far on the in-memory path. + // A resumed run fills in from where it stopped rather than starting a + // second array and throwing the first away. + run: async ({ signal, onProgress, lease, from = 0 }) => { + let done = from * CHUNK_SIZE; const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; if (target) { try { await pipelinedDownload(transport, gek, entry.id, totalChunks, onChunk, target.writable, signal, - lease && lease.tr); + lease && lease.tr, from); await target.writable.close(); } catch (err) { - await target.writable.abort().catch(() => {}); + // A pause is not a failure, and the target must survive it: aborting + // here would delete the `.part` (Electron) or the file just created + // in the granted folder, and resuming would then have nothing to + // continue. Only a real end tears the target down. + if (err.name !== 'PausedError') { + await target.writable.abort().catch(() => {}); + } throw err; } } else { const chunks = await pipelinedDownload( transport, gek, entry.id, totalChunks, onChunk, null, signal, - lease && lease.tr); + lease && lease.tr, from, memoryChunks); const blob = new Blob(chunks); _saveBlob(blob, entry.name); openRef.url = URL.createObjectURL(blob); |