summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-09 01:54:10 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-09 01:54:10 +0200
commitd6c4808d9a3ef6bc740cda7892508f15ee9ea030 (patch)
treeb9420bd6cc86ac0a72aef09d5ca0ee7e25a3533f /packages/meshbay-hub/src/meshbay_hub/static
parenta0b070e5fd382bb4a4262637836cb8d4b268fbf7 (diff)
downloadmeshbay-d6c4808d9a3ef6bc740cda7892508f15ee9ea030.tar.gz
fix(spa): one save dialog per batch, not one per file
Selecting four files on Chrome produced a Save As dialog for the first, then one for the second only after that file had finished, while the last two timed out; on a later attempt the three remaining transfers appeared frozen. Two things were going on. Opening the target inside `prepare` had removed the accidental serialisation that `for (…) await downloadFile(e)` used to provide, so `_openTargetInTurn` now queues the openings — but a queue whose head is an unanswered dialog is a head-of-line block, which is what the "freeze" was. The code already recovered from a picker with no gesture behind it by streaming instead, on the `SecurityError` Chrome throws. That branch was never reached: Chrome does not throw, it shows the dialog anyway and waits for a human. So anything that has to wait its turn is now marked `batched`, and a batched opening prefers the streamed path whatever the download mode says. The first file of a batch — the one actually holding the gesture — still gets its dialog, so the preference is honoured where it can be. For the rest there is no gesture left to spend and nothing is lost by streaming: 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 the worker does not answer, a batched download falls back to the dialog rather than failing. Also logs which path led to a dialog. 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 — and the report this fixes needed three test cycles to narrow. The two harnesses that lift `_openDownloadTarget` as text now route console.info to stderr, since they parse stdout as JSON. Measured against the deployed hub in Chrome 152: the streamed path serves the hidden iframe in 2-3 ms on a normal load, after a hard reload (via the `mbdl-claim` recovery already in `_claimController`), and twice in the same document. Hub suite 824 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-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);