diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-15 14:34:08 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-15 14:34:08 +0200 |
| commit | 8528bce49bb637f4e9fb653fff148b7ea45cfdac (patch) | |
| tree | ce34c75cb71fcd2a6526bf72474ca42442f7825d /packages/meshbay-hub/src/meshbay_hub/static/app.js | |
| parent | 41e2b79cb1bc9d188853aeff5a55cd2237268587 (diff) | |
| download | meshbay-8528bce49bb637f4e9fb653fff148b7ea45cfdac.tar.gz | |
feat(settings): choose between Save As and saving into a folder
Downloading a selection of twenty files meant twenty Save As dialogs,
which is the wrong answer for the feature that had just been built.
Settings → Downloads now offers saving automatically, and that is the
default; asking every time stays available for people who want it.
The correction worth recording: a web page cannot be given a filesystem
path and cannot read one either. There is no ~/Downloads to configure and
nothing to type, on any operating system — which is also why none of this
will need changing on Windows. What a browser grants is a handle to a
folder the user picked in a dialog, so that is what the setting keeps:
picked once, stored in IndexedDB, re-confirmed once a session because the
grant comes back as a claim rather than a permission. Where no folder has
been granted, and in Firefox and Safari where none can be, files go to
the browser's own download folder — which on most machines is the folder
that was meant all along.
Automatic saving has one risk a dialog does not: it can silently replace
a file. It does not — a taken name gets a suffix before the extension,
`clip (2).mp4`, so a download folder does not fill up with files the
system no longer recognises. That, and the default, are what
test_downloads.py pins.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 134 |
1 files changed, 102 insertions, 32 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 03b76d5..b84b7b1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -5,6 +5,7 @@ import { import { t, getLocale, setLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers, formatSpeed } from './transfers.js'; +import * as downloads from './downloads.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -930,6 +931,32 @@ function canPreview(e) { const CHUNK_SIZE = 1024 * 1024; const PIPELINE_WINDOW = 8; +/** + * Open somewhere to write, honouring the user's download setting. + * + * Returns a target ({writable, name}), null for "no stream available — collect + * it and hand the browser a blob", or false for "the person dismissed the + * dialog", which is not an error and must not start a transfer. + */ +async function _openDownloadTarget(filename, pickerOpts = {}) { + try { + const target = await downloads.openTarget(filename); + if (target) return target; + } catch (err) { + console.warn('[MeshBay] download folder unusable:', err.message); + } + if (!window.showSaveFilePicker) return null; + try { + const handle = await window.showSaveFilePicker({ + suggestedName: filename, ...pickerOpts, + }); + return { writable: await handle.createWritable(), name: handle.name || filename }; + } catch (err) { + if (err.name === 'AbortError') return false; + throw err; + } +} + /** The download of last resort, for browsers with no way to stream to disk. */ function _saveBlob(blob, filename) { const url = URL.createObjectURL(blob); @@ -1172,33 +1199,26 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, if (!transport || !transport.connected) return; const gek = gekRef.current; - // The file picker has to be opened here, in the click, before anything is - // handed to the store: a browser only grants one from a user gesture. - let handle = null; - if (window.showSaveFilePicker) { - try { - handle = await window.showSaveFilePicker({ suggestedName: entry.name }); - } catch (err) { - if (err.name === 'AbortError') return; - throw err; - } - } + // Both of these have to happen inside the click: a browser grants a file + // picker, and re-grants a folder, only from a user gesture. + const target = await _openDownloadTarget(entry.name); + if (target === false) return; // the picker was dismissed transfers.start({ - kind: 'download', name: entry.name, total: entry.size, transport, + kind: 'download', name: (target && target.name) || entry.name, + total: entry.size, transport, run: async ({ signal, onProgress }) => { const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); let done = 0; const onChunk = (bytes) => { done += bytes; onProgress(done, entry.size); }; - if (handle) { - const writable = await handle.createWritable(); + if (target) { try { await pipelinedDownload(transport, gek, entry.id, totalChunks, - onChunk, writable, signal); - await writable.close(); + onChunk, target.writable, signal); + await target.writable.close(); } catch (err) { - await writable.abort().catch(() => {}); + await target.writable.abort().catch(() => {}); throw err; } } else { @@ -1291,19 +1311,12 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); const suggested = (dir.split('/').pop() || 'files') + '.zip'; - let handle = null; - if (window.showSaveFilePicker) { - try { - handle = await window.showSaveFilePicker({ - suggestedName: suggested, - types: [{ description: 'ZIP archive', - accept: { 'application/zip': ['.zip'] } }], - }); - } catch (err) { - if (err.name === 'AbortError') return; - throw err; - } - } else if (!confirm(t('group.zip_no_stream', { + const target = await _openDownloadTarget(suggested, { + types: [{ description: 'ZIP archive', + accept: { 'application/zip': ['.zip'] } }], + }); + if (target === false) return; + if (!target && !confirm(t('group.zip_no_stream', { size: formatSize(totalBytes), name: suggested, }))) { return; @@ -1311,9 +1324,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const gek = gekRef.current; transfers.start({ - kind: 'download', name: suggested, total: totalBytes, transport, + kind: 'download', name: (target && target.name) || suggested, + total: totalBytes, transport, run: async ({ signal, onProgress }) => { - const writable = handle ? await handle.createWritable() : null; + const writable = target ? target.writable : null; const parts = writable ? null : []; let written = 0; try { @@ -2651,6 +2665,20 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { } }, [muted, user.token]); + const [dlMode, setDlMode] = useState(() => downloads.getMode()); + const [dlDir, setDlDir] = useState(null); + + useEffect(() => { downloads.savedDirectory().then(setDlDir); }, []); + + const pickFolder = useCallback(async () => { + try { + const handle = await downloads.chooseDirectory(); + setDlDir(handle); + } catch (err) { + if (err.name !== 'AbortError') setNodeKeyStatus(err.message); + } + }, []); + const submitNodeKey = useCallback(async () => { const key = nodeKey.trim(); if (!key) return; @@ -2676,6 +2704,48 @@ function SettingsPage({ user, theme, onThemeChange, groups, onLogout }) { <h2>${t('settings.title')}</h2> <div class="settings-section"> + <h3 class="settings-heading">${t('settings.downloads')}</h3> + ${!downloads.SUPPORTED + ? html`<p class="settings-hint">${t('settings.dl_unsupported')}</p>` + : html` + <label class="settings-choice"> + <input type="radio" name="dlmode" checked=${dlMode === 'auto'} + onChange=${() => { downloads.setMode('auto'); setDlMode('auto'); }} /> + <span> + <strong>${t('settings.dl_auto')}</strong> + <span class="settings-hint">${t('settings.dl_auto_hint')}</span> + </span> + </label> + <label class="settings-choice"> + <input type="radio" name="dlmode" checked=${dlMode === 'ask'} + onChange=${() => { downloads.setMode('ask'); setDlMode('ask'); }} /> + <span> + <strong>${t('settings.dl_ask')}</strong> + <span class="settings-hint">${t('settings.dl_ask_hint')}</span> + </span> + </label> + <div class="settings-row" style="margin-top:10px"> + <span class="settings-label"> + ${dlDir ? t('settings.dl_folder', { name: dlDir.name }) + : t('settings.dl_no_folder')} + </span> + <span> + <button class="admin-btn" onClick=${pickFolder}> + ${dlDir ? t('settings.dl_change') : t('settings.dl_choose')} + </button> + ${dlDir && html` + <button class="btn-secondary" onClick=${async () => { + await downloads.forgetDirectory(); + setDlDir(null); + }}>${t('settings.dl_forget')}</button> + `} + </span> + </div> + <p class="settings-hint">${t('settings.dl_path_note')}</p> + `} + </div> + + <div class="settings-section"> <h3 class="settings-heading">${t('settings.profile')}</h3> <div class="settings-row"> <span class="settings-label">${t('settings.username')}</span> |