From 4066c754a613deb965472853fe69727d68be593e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sat, 15 Aug 2026 15:48:36 +0200 Subject: fix(downloads): automatic really is automatic, and a selection downloads all of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in what shipped last, and both were mine. Automatic mode still opened Save As, because with no folder granted the code fell through to the file picker — while the documentation said it would use the browser's own download folder. It does that now. Over 512 MB it still asks, since getting there means holding the file in memory and a tab will not survive a 40 GB blob; Settings is where to stop it asking again. Selecting two files downloaded one. They were started without awaiting, so each asked the browser for a save dialog at once, and a browser allows exactly one — the rest were rejected and the errors went nowhere. They are awaited one at a time now, which serializes the dialogs and not the transfers: each call returns as soon as its transfer is registered. Then the adjustments. The transfers widget offers Open on a finished download that went into a granted folder — the bytes go to a new tab, and that is the whole of what a page can do: no browser lets one start a desktop application or show a file manager, so the folder half of that request cannot be built and the guide says so. The Files toolbar was four controls of three different heights in a row. It is three groups now — what you can add, where you are, what you can do with what is here — on one baseline, with icons from the set and a gap between the dots and the word Actions. Chat comes first among the tabs and is the one you land on. The three Discover entries in the sidebar have icons. And a link in a chat message becomes a link: built as an element and never as markup, http and https only, so `javascript:` is not one message away from running here. Co-Authored-By: Claude Opus 5 --- docs/USERGUIDE.md | 11 ++ packages/meshbay-hub/src/meshbay_hub/static/app.js | 169 +++++++++++++++------ .../src/meshbay_hub/static/downloads.js | 29 +++- .../meshbay-hub/src/meshbay_hub/static/i18n.js | 1 + .../meshbay-hub/src/meshbay_hub/static/style.css | 83 +++++++++- .../src/meshbay_hub/static/transfers.js | 20 ++- packages/meshbay-hub/tests/test_downloads.py | 13 ++ packages/meshbay-hub/tests/test_spa_ordering.py | 30 ++++ 8 files changed, 293 insertions(+), 63 deletions(-) diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index 1bc3045..fea02fd 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -548,6 +548,17 @@ one and something else in another before choosing an action. - **Ask every time** opens a Save As dialog per file, which is right for one file and wrong for a selection of twenty. +With no folder chosen, automatic still does not put a dialog in your way: the file +goes to the browser's own download folder. The exception is a download over 512 MB, +which cannot be held in memory to get there — that one asks where to put it, and +choosing a folder in Settings stops it asking again. + +A finished download offers **Open** in the transfers widget when it went into a +folder you granted: the file is handed to a new tab and the browser decides what +to do with it. That is the whole of what a web page can do here — it cannot start +a desktop application, and it cannot show you a file manager. No browser offers an +API for either, deliberately. + A web page cannot be given a filesystem path, and cannot read one either: there is no `~/Downloads` to configure, on any operating system, and nothing changes here on Windows for the same reason. What a browser grants is access to a folder diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index b84b7b1..c8fb694 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -261,6 +261,13 @@ const ICON_PATHS = { 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'], + search: ['M11 4.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13', 'M15.8 15.8L20.5 20.5'], + dots: ['M12 5.6h.01', 'M12 12h.01', 'M12 18.4h.01'], + checkbox: ['M5.5 4h13a1.5 1.5 0 0 1 1.5 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 18.5v-13A1.5 1.5 0 0 1 5.5 4z'], + home: ['M4 11.2L12 4.5l8 6.7', 'M6.2 9.8V19a1 1 0 0 0 1 1h9.6a1 1 0 0 0 1-1V9.8'], + 'folder-plus': ['M3.5 6.6a1 1 0 0 1 1-1h4.2l2 2.4h7.8a1 1 0 0 1 1 1v9.4a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1z', + 'M12 11.4v5', 'M9.5 13.9h5'], + plus: ['M12 5v14', 'M5 12h14'], 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'], @@ -418,6 +425,11 @@ function TransferWidget() { : it.status === 'cancelled' ? t('transfers.cancelled') : it.error || t('transfers.failed')} + ${it.canOpen && html` + + `} `} @@ -475,11 +487,11 @@ function Sidebar({ groups, route, menuOpen, role }) { ${tab === 'files' && html`
- - +
+ + +
+ - setFilter(e.target.value)} /> - - ${selecting && html` -
- - ${actionsOpen && html` -
${actionItems}
- `} + +
+ - `} + + ${selecting && html` +
+ + ${actionsOpen && html`
${actionItems}
`} +
+ `} +
@@ -2067,6 +2107,34 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, // ── Chat Panel ────────────────────────────────────────────────────────── +/** + * Message text with its links made clickable. + * + * Only http and https, and built as elements rather than markup: a message is + * something another member wrote, so it must never become HTML. `javascript:` + * and `data:` are not matched at all, and the anchors carry noopener so the new + * tab cannot reach back into this one. + */ +const URL_RE = /\bhttps?:\/\/[^\s<>"']+/gi; + +function linkify(text) { + const out = []; + let last = 0; + for (const m of String(text).matchAll(URL_RE)) { + if (m.index > last) out.push(text.slice(last, m.index)); + // Trailing punctuation is almost never part of the address. + let url = m[0]; + let tail = ''; + while (/[.,;:!?)\]]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); } + out.push(html`${url}`); + if (tail) out.push(tail); + last = m.index + m[0].length; + } + if (last < text.length) out.push(text.slice(last)); + return out; +} + function formatTime(ts) { const d = new Date(ts * 1000); const now = new Date(); @@ -2258,7 +2326,10 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
${formatSize(att.size)}
` : html` - ${m.payload} + + ${linkify(parsed && typeof parsed.text === 'string' + ? parsed.text : m.payload)} + `} ${formatTime(m.timestamp)} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 9c6ced1..a71f289 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -133,11 +133,11 @@ export async function freeName(name, exists) { } /** - * Where this download should be written. + * Where this download should be written, if a folder has been granted. * - * Returns a writable stream, or null meaning "there is nowhere to stream to — - * collect it and hand the browser a blob". The caller opens the picker itself - * in "ask" mode, because that one has to happen inside the click. + * Returns `{writable, name, open}` or null. Null does not mean failure: it + * means there is no granted folder, and the caller decides between handing the + * browser a blob and asking for a Save As. */ export async function openTarget(filename) { if (!SUPPORTED || getMode() === 'ask') return null; @@ -155,5 +155,24 @@ export async function openTarget(filename) { }; const name = await freeName(filename, exists); const handle = await dir.getFileHandle(name, { create: true }); - return { writable: await handle.createWritable(), name }; + return { + writable: await handle.createWritable(), + name, + // 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); + }, + }; } + +/** + * Below this, a download with no granted folder is collected in memory and + * handed to the browser, which saves it without asking. Above it that would + * mean holding gigabytes in a tab, so it is worth one Save As dialog instead. + */ +export const BLOB_LIMIT = 512 * 1024 * 1024; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 25fbd77..a2d13c5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -298,6 +298,7 @@ const en = { 'transfers.title': 'Transfers', 'transfers.clear': 'Clear finished', 'transfers.cancel': 'Cancel', + 'transfers.open': 'Open', 'transfers.done': 'Finished', 'transfers.cancelled': 'Cancelled', 'transfers.failed': 'Failed', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2296b2d..851c07e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -546,14 +546,77 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } /* ── File toolbar ────────────────────────────────────────────────────────── */ +/* One bar, three groups: what you can add on the left, where you are in the + middle, what you can do with what is here on the right. It used to be four + controls of three different heights sitting in a row. */ .file-toolbar { display: flex; align-items: center; - justify-content: space-between; - gap: 12px; + gap: 10px; margin-bottom: 12px; + padding: 8px; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: 8px; flex-wrap: wrap; } +.toolbar-group { display: flex; align-items: center; gap: 6px; } +.toolbar-group.right { margin-left: auto; } + +.tb-btn { + display: inline-flex; + align-items: center; + gap: 6px; /* the icon and the word are not the same word */ + height: 32px; + padding: 0 11px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-surface); + color: var(--text); + font-size: 0.83em; + white-space: nowrap; + cursor: pointer; + transition: border-color 0.12s, color 0.12s, background 0.12s; +} +.tb-btn:hover { border-color: var(--accent); color: var(--accent); } +.tb-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.tb-btn:disabled:hover { border-color: var(--border); color: var(--text); } +.tb-btn .icon { width: 15px; height: 15px; } +.tb-btn.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text); +} +.tb-btn.primary:hover { background: var(--accent-hover); color: var(--accent-text); } +.tb-btn.active { + background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface)); + border-color: var(--accent); + color: var(--accent); +} + +/* The filter is a field, not a button, and should not pretend otherwise. */ +.tb-search { + display: flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-surface); + color: var(--text-dim); +} +.tb-search:focus-within { border-color: var(--border-focus); } +.tb-search .icon { width: 14px; height: 14px; flex-shrink: 0; } +.tb-search input { + border: none; + background: none; + outline: none; + padding: 0; + width: 150px; + font-size: 0.83em; + color: var(--text); +} .breadcrumbs { display: flex; @@ -564,6 +627,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } flex-wrap: wrap; } .crumb { + display: inline-flex; + align-items: center; cursor: pointer; color: var(--accent); padding: 2px 4px; @@ -572,11 +637,6 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .crumb:hover { background: var(--bg-raised); text-decoration: none; } .crumb-sep { color: var(--text-dim); } -.file-search { - max-width: 220px; - padding: 6px 10px !important; - font-size: 0.85em !important; -} /* ── File table ──────────────────────────────────────────────────────────── */ @@ -1190,6 +1250,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .settings-choice strong { display: block; font-size: 0.88em; color: var(--text); } .settings-choice .settings-hint { display: block; margin-top: 2px; } +/* A pasted address can be longer than the bubble; breaking it is better than + a message that scrolls sideways. */ +.chat-link { + color: var(--accent); + text-decoration: underline; + word-break: break-all; +} +.chat-bubble-own .chat-link { color: inherit; } + /* ── Transfers widget ────────────────────────────────────────────────────── */ .transfer-wrap { position: relative; display: flex; align-items: center; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index 1ad1ec5..fa34c67 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -56,6 +56,9 @@ export class TransferStore { error: it.error || '', speed: this._speed(it), 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. + canOpen: it.status === 'done' && typeof it.open === 'function', })); } @@ -79,10 +82,10 @@ export class TransferStore { * `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 }) { + start({ kind, name, total = 0, transport = null, run, open = null }) { const item = { id: _nextId++, - kind, name, total, transport, + kind, name, total, transport, open, done: 0, status: 'running', error: '', @@ -128,6 +131,19 @@ export class TransferStore { return item.id; } + /** + * Hand a finished download to the browser to display. + * + * As close to "open it" as a web page gets: the bytes go to a new tab and the + * browser decides what to do with them. A page cannot start a desktop + * application, and cannot show a file manager — there is no API for either, + * in any browser, by design. + */ + open(id) { + const item = this._items.find(it => it.id === id); + if (item && typeof item.open === 'function') return item.open(); + } + cancel(id) { const item = this._items.find(it => it.id === id); if (!item || item.status !== 'running') return; diff --git a/packages/meshbay-hub/tests/test_downloads.py b/packages/meshbay-hub/tests/test_downloads.py index cc4fdd1..cd0cded 100644 --- a/packages/meshbay-hub/tests/test_downloads.py +++ b/packages/meshbay-hub/tests/test_downloads.py @@ -95,3 +95,16 @@ say(await M.freeName('clip.mp4', async n => n === 'clip.mp4')); def test_a_browser_without_the_api_reports_it(tmp_path): """`SUPPORTED` decides whether Settings offers a choice or an explanation.""" assert _run("say(M.SUPPORTED);", tmp_path) == [False] + + +def test_the_open_action_reads_the_file_back(tmp_path): + """ + "Open" is the browser being handed the bytes, not a desktop application + being started — no web page can do the second, and none can show a file + manager either. It is only offered for a file written into a granted folder, + since that is the one a page can read back. + """ + src = DOWNLOADS.read_text() + target = src[src.index("export async function openTarget"):] + assert "getFile()" in target and "window.open(" in target + assert "revokeObjectURL" in target, "the blob URL must not be leaked" diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py index 04c5159..79d7c9a 100644 --- a/packages/meshbay-hub/tests/test_spa_ordering.py +++ b/packages/meshbay-hub/tests/test_spa_ordering.py @@ -199,3 +199,33 @@ def test_the_files_panel_no_longer_carries_its_own_progress_bars(): 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" + + +# ── Downloading a selection ───────────────────────────────────────────────── + +def test_a_multi_file_download_waits_for_each_picker(): + """ + A browser allows one file picker at a time. Firing every download at once + meant the first opened a dialog and the rest were rejected — two files + selected, one file downloaded. + """ + app = APP.read_text() + block = app[app.index("${selectedFiles.length > 0 && html`"):] + block = block[:block.index("`}")] + assert "await downloadFile(e)" in block, ( + "downloads are fired without awaiting again; only the first will ask " + "for a save location and the others will be rejected") + + +def test_links_in_chat_are_built_as_elements_not_markup(): + """ + A message is something another member wrote. It becomes an anchor element, + never HTML, and only for http(s) — otherwise javascript: would be one + message away from running here. + """ + app = APP.read_text() + fn = app[app.index("function linkify("):] + fn = fn[:fn.index("\nfunction ", 1)] + assert "innerHTML" not in fn and "dangerouslySetInnerHTML" not in fn + assert 'rel="noopener noreferrer"' in fn + assert "https?" in app[app.index("const URL_RE"):app.index("function linkify(")] -- cgit v1.2.3