aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js58
1 files changed, 54 insertions, 4 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 3475f81..065079b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -98,7 +98,7 @@ class TooLargeForMemoryError extends Error {
* bare `return null` appears in this function.
*/
async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
- swSize = size) {
+ swSize = size, { batched = false } = {}) {
// "Collect it in the page", or a refusal when that would be too much.
const _memoryFloor = () => {
if (size > MEMORY_CEILING) throw new TooLargeForMemoryError(filename, size);
@@ -144,7 +144,18 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
// this block left nothing but the in-memory floor. A preference must not cost
// a capability.
const canPick = typeof window.showSaveFilePicker === 'function';
- if (downloads.getMode() === 'auto' || (size > MEMORY_CEILING && !canPick)) {
+ // `batched` means this is not the first download of a batch, and it makes the
+ // streamed path preferred whatever the mode.
+ //
+ // "Ask where to save" asks per file, which is right for one file and wrong
+ // for four: a browser grants one picker per user gesture, so the second
+ // dialog has no gesture behind it and the third and fourth wait behind a
+ // dialog that waits for a human — reported from Chrome as three downloads
+ // frozen. There is no gesture left to spend, so there is nothing to lose by
+ // streaming instead: the file still lands on disk, in the browser's own
+ // download folder. Only the choice of folder goes, and it was not on offer.
+ if (downloads.getMode() === 'auto' || batched
+ || (size > MEMORY_CEILING && !canPick)) {
const streamed = await downloads.openStreamedDownload(filename, swSize);
if (streamed) return streamed;
// Nothing to stream to: small enough for memory, and no dialog. The old
@@ -157,6 +168,13 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
// No File System Access API — Firefox, Safari. This is the branch that used
// to return null at any size.
if (!canPick) return _memoryFloor();
+ // A dialog is the one outcome nobody can diagnose after the fact: it looks
+ // the same whether it was asked for, or fallen back to because the worker
+ // did not answer. Say which, once per download, so the next report from a
+ // browser we do not have does not need a second round trip.
+ console.info('[MeshBay] asking where to save %s — mode=%s batched=%s stream=%s',
+ filename, downloads.getMode(), batched,
+ downloads.lastStreamFailure() || 'not attempted');
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename, ...pickerOpts,
@@ -187,6 +205,38 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {},
}
}
+// Target openings run one at a time, across every download on the page.
+//
+// A browser shows one file picker at a time and grants one per user gesture, so
+// four downloads asking at once get one dialog and three failures. That used to
+// be prevented by accident: `downloadEntry` awaited the target inline, and
+// files-app.js's `for (const e of selected) await downloadFile(e)` serialised
+// them. Opening the target inside `prepare` — so the row appears at the click
+// instead of tens of seconds later — removed that accident, and four pickers
+// raced. Chrome showed one, prompted for a second, and the rest timed out;
+// Firefox and Electron never noticed, because neither opens a picker at all.
+//
+// So the queue is explicit now, and it is the *targets* that queue, not the
+// rows: every download still appears the moment it is asked for.
+let _targetQueue = Promise.resolve();
+
+let _targetsInFlight = 0;
+
+function _openTargetInTurn(filename, size, pickerOpts, swSize) {
+ // Anything that has to wait its turn is, by definition, not the first of the
+ // batch — so it will not be the one holding the user's gesture.
+ const batched = _targetsInFlight > 0;
+ _targetsInFlight += 1;
+ const mine = _targetQueue
+ .then(() => _openDownloadTarget(filename, size, pickerOpts, swSize,
+ { batched }))
+ .finally(() => { _targetsInFlight -= 1; });
+ // The chain must not break on a rejection, or one refused download stops
+ // every later one from ever opening a target.
+ _targetQueue = mine.catch(() => {});
+ return mine;
+}
+
/** The download of last resort, for browsers with no way to stream to disk. */
function _saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
@@ -333,7 +383,7 @@ async function downloadEntry(transfers, transport, gek, entry) {
// for a person — and doing it before the row meant three clicks produced no
// panel at all and then several rows at once.
prepare: async () => {
- target = await _openDownloadTarget(entry.name, entry.size);
+ 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;
@@ -431,7 +481,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
// Same order as downloadEntry: the row first, then the target, then the
// slot. A folder of forty files is exactly where the wait is longest.
prepare: async () => {
- target = await _openDownloadTarget(suggested, totalBytes, {
+ target = await _openTargetInTurn(suggested, totalBytes, {
types: [{ description: 'ZIP archive',
accept: { 'application/zip': ['.zip'] } }],
}, 0);