aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js134
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js159
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css11
4 files changed, 291 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>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
new file mode 100644
index 0000000..9c6ced1
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -0,0 +1,159 @@
+/**
+ * Where downloads go.
+ *
+ * A web page cannot be told a path. It cannot read "~/Downloads", cannot write
+ * to it, and cannot be configured with "C:\Users\…" either — which is also why
+ * none of this needs to change when someone runs it on Windows. What a browser
+ * grants is a *handle* to a directory the user picked, once, in a dialog. That
+ * handle is what this module keeps.
+ *
+ * Two modes, and the default matters:
+ *
+ * auto (default) — write into the granted folder without asking. Downloading
+ * twelve files puts twelve files there. Without a granted folder, the file
+ * goes wherever the browser puts downloads, which on most machines is the
+ * same folder anyway.
+ * ask — a Save As dialog for every file, which is the right answer for one
+ * file and the wrong one for twelve.
+ *
+ * Firefox and Safari have no File System Access API at all: no folder can be
+ * granted there, and both modes fall back to the browser's own download folder.
+ * The setting says so rather than offering a choice that does nothing.
+ */
+
+const PREF_KEY = 'mb_dl_mode';
+const IDB_NAME = 'meshbay-downloads';
+const IDB_STORE = 'handles';
+const HANDLE_KEY = 'target-dir';
+
+export const SUPPORTED = typeof window !== 'undefined'
+ && typeof window.showDirectoryPicker === 'function';
+
+export function getMode() {
+ const v = localStorage.getItem(PREF_KEY);
+ return v === 'ask' ? 'ask' : 'auto';
+}
+
+export function setMode(mode) {
+ localStorage.setItem(PREF_KEY, mode === 'ask' ? 'ask' : 'auto');
+}
+
+// ── The granted directory ────────────────────────────────────────────────────
+
+function idb() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(IDB_NAME, 1);
+ req.onupgradeneeded = () => {
+ if (!req.result.objectStoreNames.contains(IDB_STORE)) {
+ req.result.createObjectStore(IDB_STORE);
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+async function idbGet(key) {
+ try {
+ const db = await idb();
+ const tx = db.transaction(IDB_STORE, 'readonly');
+ const req = tx.objectStore(IDB_STORE).get(key);
+ const out = await new Promise((r, j) => {
+ req.onsuccess = () => r(req.result); req.onerror = () => j(req.error);
+ });
+ db.close();
+ return out || null;
+ } catch { return null; }
+}
+
+async function idbPut(key, value) {
+ try {
+ const db = await idb();
+ const tx = db.transaction(IDB_STORE, 'readwrite');
+ if (value === null) tx.objectStore(IDB_STORE).delete(key);
+ else tx.objectStore(IDB_STORE).put(value, key);
+ await new Promise((r, j) => { tx.oncomplete = r; tx.onerror = j; });
+ db.close();
+ } catch { /* best effort: the setting simply will not stick */ }
+}
+
+/** The folder handle, if one was ever granted. Says nothing about permission. */
+export async function savedDirectory() {
+ return SUPPORTED ? idbGet(HANDLE_KEY) : null;
+}
+
+/** Ask for a folder. Must be called from a click — browsers require a gesture. */
+export async function chooseDirectory() {
+ const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
+ await idbPut(HANDLE_KEY, handle);
+ return handle;
+}
+
+export async function forgetDirectory() {
+ await idbPut(HANDLE_KEY, null);
+}
+
+/**
+ * Permission survives a reload as a *claim*, not as a grant: the handle comes
+ * back from IndexedDB in state "prompt" and has to be re-authorized once per
+ * session. Doing that quietly during a download click is why this takes no
+ * arguments and returns a boolean instead of throwing.
+ */
+export async function ensurePermission(handle, { prompt = true } = {}) {
+ if (!handle) return false;
+ try {
+ const opts = { mode: 'readwrite' };
+ if (await handle.queryPermission(opts) === 'granted') return true;
+ if (!prompt) return false;
+ return await handle.requestPermission(opts) === 'granted';
+ } catch {
+ return false;
+ }
+}
+
+// ── Names ────────────────────────────────────────────────────────────────────
+
+/**
+ * A name not already taken, as "clip (2).mp4".
+ *
+ * Writing into a folder repeatedly is the whole point of the automatic mode, so
+ * the second copy of a file must not silently replace the first. `exists` is
+ * passed in so this can be tested without a filesystem.
+ */
+export async function freeName(name, exists) {
+ if (!await exists(name)) return name;
+ const dot = name.lastIndexOf('.');
+ const stem = dot > 0 ? name.slice(0, dot) : name;
+ const ext = dot > 0 ? name.slice(dot) : '';
+ for (let n = 2; n < 1000; n++) {
+ const candidate = `${stem} (${n})${ext}`;
+ if (!await exists(candidate)) return candidate;
+ }
+ return `${stem} (${Date.now()})${ext}`;
+}
+
+/**
+ * Where this download should be written.
+ *
+ * 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.
+ */
+export async function openTarget(filename) {
+ if (!SUPPORTED || getMode() === 'ask') return null;
+
+ const dir = await savedDirectory();
+ if (!dir || !await ensurePermission(dir)) return null;
+
+ const exists = async (name) => {
+ try {
+ await dir.getFileHandle(name);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+ const name = await freeName(filename, exists);
+ const handle = await dir.getFileHandle(name, { create: true });
+ return { writable: await handle.createWritable(), name };
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index f43981b..25fbd77 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -124,6 +124,25 @@ const en = {
// Settings
'settings.title': 'Settings',
'settings.coming_soon': 'Coming soon.',
+ 'settings.downloads': 'Downloads',
+ 'settings.dl_auto': 'Save automatically',
+ 'settings.dl_auto_hint': 'Files go straight into the folder you choose below, '
+ + 'with no dialog. Downloading twenty files puts twenty files there.',
+ 'settings.dl_ask': 'Ask every time',
+ 'settings.dl_ask_hint': 'A Save As dialog for each file — one per file, '
+ + 'including when you download a selection.',
+ 'settings.dl_folder': 'Folder: {name}',
+ 'settings.dl_no_folder': 'No folder chosen — downloads go wherever your browser '
+ + 'puts them',
+ 'settings.dl_choose': 'Choose folder',
+ 'settings.dl_change': 'Change',
+ 'settings.dl_forget': 'Forget',
+ 'settings.dl_path_note': 'A web page cannot be given a path, so there is nothing '
+ + 'to type here: your browser grants access to the folder you pick, and MeshBay '
+ + 'only ever writes inside it. You may be asked to confirm once per session.',
+ 'settings.dl_unsupported': 'This browser cannot write into a folder of your '
+ + 'choosing (no File System Access API), so downloads go to its own download '
+ + 'folder. Chrome and Edge support choosing one.',
'settings.profile': 'Profile',
'settings.username': 'Username',
'settings.node_pins': 'Node identities',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 24e8843..2296b2d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1179,6 +1179,17 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.group-desc-edit textarea:focus { outline: none; border-color: var(--border-focus); }
.group-desc-edit div { display: flex; gap: 6px; margin-top: 4px; }
+.settings-choice {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 7px 0;
+ cursor: pointer;
+}
+.settings-choice input { margin-top: 3px; flex-shrink: 0; }
+.settings-choice strong { display: block; font-size: 0.88em; color: var(--text); }
+.settings-choice .settings-hint { display: block; margin-top: 2px; }
+
/* ── Transfers widget ────────────────────────────────────────────────────── */
.transfer-wrap { position: relative; display: flex; align-items: center; }