diff options
Diffstat (limited to 'packages/meshbay-hub/src')
3 files changed, 56 insertions, 20 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 50bca46..0d2b25e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -132,6 +132,42 @@ export async function freeName(name, exists) { return `${stem} (${Date.now()})${ext}`; } +// ── Opening a finished download in a tab ──────────────────────────────────── +// +// A tab opened on a `blob:` URL is a document of **this origin**: it can read +// the session in localStorage and the keys in IndexedDB. So only what a browser +// renders without running anything is opened there, under a type chosen here +// from the name — never the type the browser would guess from the bytes, which +// is how an `.html` or `.svg` a member put on a node would have run as the hub. +// Everything else is already on disk and is opened from there, not from here. +const OPENABLE = { + pdf: 'application/pdf', + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', + webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', + mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', ogg: 'audio/ogg', + oga: 'audio/ogg', opus: 'audio/ogg', flac: 'audio/flac', wav: 'audio/wav', + mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', + mov: 'video/quicktime', + txt: 'text/plain;charset=utf-8', log: 'text/plain;charset=utf-8', + md: 'text/plain;charset=utf-8', csv: 'text/plain;charset=utf-8', +}; + +/** The type a file of this name is opened under, or null: not opened in a tab. */ +export function openableType(name) { + const m = /\.([A-Za-z0-9]+)$/.exec(String(name || '')); + return (m && OPENABLE[m[1].toLowerCase()]) || null; +} + +/** Open `blob` in a tab if its name allows it; false if it does not. */ +export function openInTab(blob, name) { + const type = openableType(name); + if (!type) return false; + const url = URL.createObjectURL(new Blob([blob], { type })); + window.open(url, '_blank', 'noopener'); + setTimeout(() => URL.revokeObjectURL(url), 60000); + return true; +} + /** * Where this download should be written, if a folder has been granted. * @@ -182,14 +218,9 @@ export async function openTarget(filename) { // writing to it, and nothing is lost while nothing is written. pausable: true, // Reading it back is the only way a page can "open" a file it wrote: hand - // the bytes to a tab and let the browser decide what to do with them. No - // web page can start a desktop application, or show a file manager. - open: async () => { - const file = await handle.getFile(); - const url = URL.createObjectURL(file); - window.open(url, '_blank', 'noopener'); - setTimeout(() => URL.revokeObjectURL(url), 60000); - }, + // the bytes to a tab. No web page can start a desktop application, or show + // a file manager — and only a type that runs nothing is offered (above). + open: openableType(name) ? async () => openInTab(await handle.getFile(), name) : null, }; } 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 255c162..aa45a25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js @@ -432,7 +432,7 @@ async function downloadEntry(transfers, transport, gek, entry) { // Saved under a name every platform can write, and the row says so when that // is not the node's (portable-name.js, docs/MESHBAY_DESIGN.md §10). const saveName = portableName(entry.name); - const openRef = { url: null }; + const openRef = { blob: null }; let target = null; // The in-memory fallback's accumulator, held out here so a pause does not // discard what has already been decrypted. @@ -452,8 +452,10 @@ async function downloadEntry(transfers, transport, gek, entry) { if (target === false) return false; // `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 }; + // So does whether it can be opened: the app's own save opens through the + // OS, a page opens only a type that runs nothing (downloads.openInTab). + return target ? { name: target.name, pausable: !!target.pausable, openable: !!target.open } + : { pausable: true, openable: !!downloads.openableType(saveName) }; }, // After the target, never before: a granted slot has to be taken up within @@ -464,7 +466,7 @@ async function downloadEntry(transfers, transport, gek, entry) { kind: 'download', bytes: entry.size, chunks: totalChunks }), open: () => (target && target.open) ? target.open() - : (openRef.url ? window.open(openRef.url, '_blank') : undefined), + : (openRef.blob ? downloads.openInTab(openRef.blob, saveName) : undefined), // 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 @@ -495,7 +497,7 @@ async function downloadEntry(transfers, transport, gek, entry) { lease && lease.tr, from, memoryChunks); const blob = new Blob(chunks); _saveBlob(blob, saveName); - openRef.url = URL.createObjectURL(blob); + openRef.blob = blob; } }, }); @@ -551,7 +553,6 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE // totalBytes decides how this is delivered, but it is not the archive's // size — headers and the central directory come on top — so it is not // announced as a Content-Length that the download would then miss. - const zipOpenRef = { url: null }; let target = null; transfers.start({ @@ -571,7 +572,9 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE }))) { return false; } - return target ? { name: target.name } : true; + // An archive is never opened in a tab (downloads.openInTab); the app's + // own save opens it through the OS. + return target ? { name: target.name, openable: !!target.open } : { openable: false }; }, // **One** lease for the archive, not one per file. Dozens of leases for a @@ -580,8 +583,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE makeLease: () => transport.openTransfer({ kind: 'download', bytes: totalBytes, chunks: files.length }), - open: () => (target && target.open) ? target.open() - : (zipOpenRef.url ? window.open(zipOpenRef.url, '_blank') : undefined), + open: () => ((target && target.open) ? target.open() : undefined), run: async ({ signal, onProgress, lease }) => { const writable = target ? target.writable : null; const parts = writable ? null : []; @@ -611,7 +613,6 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE else { const blob = new Blob(parts, { type: 'application/zip' }); _saveBlob(blob, suggested); - zipOpenRef.url = URL.createObjectURL(blob); } } catch (err) { if (writable) await writable.abort().catch(() => {}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index 0d012ed..7036c4f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -80,8 +80,9 @@ export class TransferStore { settled: it.samples.length > 2 && (it.samples[it.samples.length - 1].t - it.samples[0].t) >= 3000, percent: it.total ? Math.min(100, Math.round(it.done / it.total * 100)) : 0, - // Only for a file written into a folder the browser granted us: that is - // the one case where the page can read its own download back. + // Where the finished file can be read back — a granted folder, the + // in-memory floor, the app's own save — and only for a type that is + // safe to open (downloads.openInTab). canOpen: it.status === 'done' && typeof it.open === 'function', // Whether the target can be stopped and continued. False is the honest // answer for a service-worker stream, and the button is not drawn. @@ -225,6 +226,9 @@ export class TransferStore { // the button is offered where it works and nowhere else — a pause // that silently restarts from zero is worse than no pause. if (ready && ready.pausable) item.pausable = true; + // Likewise whether "Open" can do anything: a page opens in a tab + // only a type that runs nothing (downloads.openInTab). + if (ready && ready.openable === false) item.open = null; item.status = 'running'; this._emit(); } |