diff options
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> |