// Pithos UI. Plain DOM, no build step: every host serves these files as-is. // All user content (object keys, user names, log lines) goes in via // textContent through h(); nothing here assigns innerHTML. // ---------------------------------------------------------------- helpers function h(tag, attrs = {}, ...children) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v == null || v === false) continue; if (k === 'class') el.className = v; else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'dataset') Object.assign(el.dataset, v); else if (v === true) el.setAttribute(k, ''); else el.setAttribute(k, v); } for (const c of children.flat(Infinity)) { if (c == null || c === false) continue; el.append(c instanceof Node ? c : String(c)); } return el; } const $ = (sel, root = document) => root.querySelector(sel); // replaceChildren, but skipping the null/false that conditional children produce. function put(el, ...children) { el.replaceChildren(...children.flat(Infinity).filter((c) => c != null && c !== false)); } // Inside the Theseus sidebar the page is the add-on's own file, with no // server origin to fetch from: every call goes over the add-on IPC bridge // and the add-on forwards it to the control server. const bridge = window.silentmode && typeof window.silentmode.invoke === 'function' ? window.silentmode : null; // On pithos.sia (Theseus) pages have real addresses: pithos.sia///, // pithos.sia/settings… Elsewhere (sidebar, web console, desktop) they stay #/ routes. const PATH_MODE = !bridge && (location.protocol === 'bns:' || location.hostname === 'pithos.sia'); async function api(method, path, body, { raw } = {}) { if (bridge) { const r = await bridge.invoke('api', { method, path, body }); const data = r.data || {}; if (r.status >= 400) throw new Error(data.error || `HTTP ${r.status}`); return data; } const opts = { method, headers: { 'x-pithos': '1' }, credentials: 'same-origin' }; if (body !== undefined) { opts.headers['content-type'] = 'application/json'; opts.body = JSON.stringify(body); } const res = await fetch(path, opts); if (res.status === 401 && path !== '/api/session') { showSignin(); throw new Error('signed out'); } if (raw) return res; const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } function toast(msg, kind = '') { const el = h('div', { class: `toast ${kind}` }, msg); $('#toasts').append(el); setTimeout(() => el.remove(), kind === 'error' ? 7000 : 3500); } const fail = (e) => { if (e.message !== 'signed out') toast(e.message, 'error'); }; function fmtBytes(n) { if (n == null || isNaN(n)) return '–'; const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${i ? n.toFixed(n < 10 ? 2 : 1) : n} ${u[i]}`; } function fmtDate(s) { if (!s) return '–'; const d = new Date(s); return isNaN(d) ? s : d.toLocaleString(); } function fmtDuration(ms) { const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; } async function copy(text, what = 'Copied') { try { await navigator.clipboard.writeText(text); toast(what); } catch { prompt('Copy:', text); } } function modal(title, body, buttons = []) { return new Promise((resolve) => { const close = (v) => { root.remove(); document.removeEventListener('keydown', onKey); resolve(v); }; const onKey = (e) => { if (e.key === 'Escape') close(undefined); }; const foot = h('div', { class: 'foot' }, buttons.map((b) => h('button', { class: `btn ${b.kind || ''}`, type: b.submit ? 'submit' : 'button', // A fixed result (Cancel: false/null) always closes; only a value() // action that returns false keeps the dialog open (e.g. Copy link). onclick: b.submit ? null : async () => { if (!b.value) return close(b.result); const v = await b.value(); if (v !== false) close(v); }, }, b.label))); const form = h('form', { class: 'modal', onsubmit: async (e) => { e.preventDefault(); const b = buttons.find((x) => x.submit); if (!b.value) return close(b.result); const v = await b.value(); if (v !== false) close(v); } }, h('h2', {}, title), body, foot); const root = h('div', { class: 'modal-backdrop', onmousedown: (e) => { if (e.target === root) close(undefined); } }, form); $('#modal-root').append(root); document.addEventListener('keydown', onKey); setTimeout(() => (form.querySelector('input, textarea, select') || form.querySelector('.btn.primary'))?.focus(), 0); }); } function confirmModal(title, text, label = 'Delete', kind = 'danger') { return modal(title, h('p', {}, text), [ { label: 'Cancel', result: false }, { label, kind: `primary ${kind === 'danger' ? '' : ''}`, submit: true, result: true }, ]).then((v) => v === true); } function secretRow(label, value) { return h('div', { class: 'stack' }, h('span', { class: 'small muted' }, label), h('div', { class: 'secret' }, h('code', {}, value), h('button', { class: 'btn small', type: 'button', onclick: () => copy(value) }, 'Copy'))); } // ---------------------------------------------------------------- state const state = { status: null, login: { state: 'idle' }, logs: [], logSeq: 0, selectedUser: localStorage.getItem('pithos.user') || '', }; const listeners = new Set(); const onState = (fn) => { listeners.add(fn); return () => listeners.delete(fn); }; const emit = () => listeners.forEach((fn) => fn()); async function refreshStatus() { try { state.status = await api('GET', '/api/status'); state.login = state.status.login; renderPill(); emit(); } catch (e) { fail(e); } } const eventHandlers = { status(d) { if (!state.status) return; state.status.daemon = d; renderPill(); emit(); }, log(l) { if (l.seq <= state.logSeq) return; state.logSeq = l.seq; state.logs.push(l); if (state.logs.length > 2000) state.logs.shift(); logSink?.(l); }, login(d) { const was = state.login?.state; state.login = d; emit(); // the account card names the new account as soon as it is connected if (['done', 'already'].includes(d?.state) && d.state !== was) refreshNavAccount(true); }, install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); }, move(p) { progressSink?.(p); }, convert(p) { progressSink?.(p); }, }; function connectEvents() { if (bridge) { bridge.on('pithos-event', ({ event, data }) => eventHandlers[event]?.(data)); bridge.invoke('events-start').catch(fail); return; } const es = new EventSource('/api/events'); for (const [name, fn] of Object.entries(eventHandlers)) es.addEventListener(name, (e) => fn(JSON.parse(e.data))); es.onerror = () => { /* EventSource reconnects on its own */ }; } function daemonState() { const s = state.status; if (!s) return 'stopped'; if (s.daemon.state === 'stopped' && s.external) return 'external'; if (s.daemon.state === 'crashed' && s.daemon.needsLogin) return 'not connected'; return s.daemon.state; } function renderPill() { const st = daemonState(); const label = { running: 's3d running', starting: 'Starting…', stopping: 'Stopping…', crashed: 's3d stopped (error)', 'not connected': 'Not connected to Sia', stopped: 's3d stopped', external: 's3d running (external)' }[st]; const pill = $('#daemon-pill'); pill.querySelector('.dot').className = `dot ${st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st}`; pill.querySelector('.label').textContent = isHosted() ? `On Silent Mode · ${label}` : label; } // ---------------------------------------------------------------- router let cleanup = []; let logSink = null; const views = { start: getStarted, account, overview, buckets, users, setup, settings, logs }; // Pages that live under Account in the menu. const NAV_OF = { start: 'account', setup: 'account' }; // [page, ...parts] for the current address. On pithos.sia the first part is a // page name or an S3 user (whose drives the rest addresses). function currentRoute() { if (!PATH_MODE) { const parts = location.hash.replace(/^#\/?/, '').split('/').filter((x, i) => i > 0 || x); // #/u///…: a drive of a given S3 user (links handed to a browser) if (parts[0] === 'u' && parts[1]) { const user = decodeURIComponent(parts[1]); if (state.selectedUser !== user) { state.selectedUser = user; try { localStorage.setItem('pithos.user', user); } catch {} } return ['buckets', ...parts.slice(2)]; } return parts; } const segs = location.pathname.split('/').filter(Boolean); if (!segs.length) return []; const first = decodeURIComponent(segs[0]); // pithos.sia/app: the app's front door (the bare pithos.sia is the website) if (first === 'app') return segs.slice(1); if (first === 'drives') return ['buckets']; if (views[first]) return segs; if (state.selectedUser !== first) { state.selectedUser = first; try { localStorage.setItem('pithos.user', first); } catch {} } return ['buckets', ...segs.slice(1)]; } // '#/page/parts' → the address it has on pithos.sia. function hashToPath(hash) { const [name = 'overview', ...rest] = String(hash).replace(/^#\/?/, '').split('/'); if (name === 'buckets') { const user = state.selectedUser; if (!user) return '/drives'; return `/${encodeURIComponent(user)}${rest.length ? '/' + rest.join('/') : ''}`; } return `/${[name, ...rest].join('/')}`; } // The page the user is on as a '#/' route another browser can open // (drives carry their S3 user, which that browser does not know yet). function shareableRoute() { const [name = 'overview', ...rest] = currentRoute(); if (name === 'buckets' && state.selectedUser) return `#/u/${encodeURIComponent(state.selectedUser)}${rest.length ? '/' + rest.join('/') : ''}`; return `#/${[name, ...rest].join('/')}`; } // "Open in browser": this Pithos in the default browser, at the same page. function addOpenInBrowser() { const run = async () => { try { await api('POST', '/api/open-in-browser', { route: shareableRoute() }); toast('Opened in your browser'); } catch (e) { fail(e); } }; const row = h('div', { class: 'host-controls' }, h('button', { class: 'btn small', title: 'Open this page of Pithos in your default browser', onclick: run }, '↗ Open in browser')); $('.sidebar').insertBefore(row, $('#daemon-pill')); addMenuAction('↗ Open in browser', run); } // Go to a page given as '#/page/parts', whichever kind of address this host uses. function navTo(hash) { if (!PATH_MODE) { location.hash = hash; return; } const p = hashToPath(hash); if (p !== location.pathname) history.pushState(null, '', p); route(); } function route() { cleanup.forEach((fn) => fn()); cleanup = []; logSink = null; // First run lands on the guided setup until it has been finished once. const fallback = !setupDone() && !state.status?.registration?.registered ? 'account' : 'overview'; const [name = fallback, ...rest] = currentRoute(); const view = views[name] ? name : fallback; const navView = NAV_OF[view] || view; for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView); // Compact bar (narrow hosts): name the page, fold the menu away. const here = document.querySelector(`#nav a[data-view="${navView}"]`); $('#here').textContent = here ? (here.dataset.title || here.textContent) : ''; refreshNavAccount(); setMenu(false); const main = $('#main'); put(main); views[view](main, rest.map(decodeURIComponent)); } // ---------------------------------------------------------------- overview function overview(main) { const daemonCard = h('div', { class: 'card' }); const statsCard = h('div', { class: 'card' }); const banners = h('div'); main.append( h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Overview'), h('p', { class: 'muted' }, 'Your s3d gateway and its upload pipeline to Sia.'))), banners, pendingBanner(), h('div', { class: 'grid two' }, daemonCard, statsCard), ); function draw() { const s = state.status; if (!s) return; put(banners, ...bannersFor(s)); const st = daemonState(); const busy = st === 'starting' || st === 'stopping'; const running = st === 'running' || st === 'starting'; if (s.mode === 'hosted') { put(daemonCard, h('div', { class: 'row spread' }, h('h2', {}, 'Gateway on Silent Mode'), h('span', { class: `badge ${st === 'running' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)), h('dl', { class: 'kv' }, h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · your own, connected from this computer'), h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, hostedUrl()), h('span', { class: 'small muted' }, ' (path-style)')), h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : '–'), h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : 'starts on the next request'), h('dt', {}, 'Downloads'), h('dd', {}, `${fmtBytes(s.hosted?.usage?.down || 0)} this month${s.hosted?.downloadCapBytes ? ` of ${fmtBytes(s.hosted.downloadCapBytes)}` : ''}`), ), h('p', { class: 'small muted' }, 'Files are encrypted on this computer before they reach the server. s3d there starts when something asks for your drives and stops when idle.'), h('div', { class: 'row', style: 'margin-top:16px' }, h('button', { class: 'btn primary', disabled: busy || running, onclick: () => daemonAction('start') }, 'Start'), h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'), h('a', { class: 'btn', href: '#/settings' }, 'Where your drives live'))); return; } put(daemonCard, h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)), h('dl', { class: 'kv' }, h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/account' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect'), spaceLine()), h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)), s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))], h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')), h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : '–'), h('dt', {}, 'Config'), h('dd', {}, h('code', {}, s.config.file)), h('dt', {}, 'Data'), h('dd', {}, h('code', {}, s.config.directory)), ), h('div', { class: 'row', style: 'margin-top:16px' }, st === 'external' ? h('span', { class: 'muted small' }, 'Another s3d process is serving this config. Stop it to manage it from here.') : [ h('button', { class: 'btn primary', disabled: busy || running || !s.daemon.binary, onclick: () => daemonAction('start') }, 'Start'), h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'), h('button', { class: 'btn danger', disabled: busy || !running, onclick: () => daemonAction('stop') }, 'Stop'), ], ), ); } async function drawStats() { const st = daemonState(); if (st !== 'running' && st !== 'external') { put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'muted' }, 'Start s3d to see live upload stats.')); return; } try { const x = await api('GET', '/api/stats'); const af = await api('GET', '/api/autoflush').catch(() => null); const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l)); put(statsCard, h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'), h('button', { class: 'btn small', disabled: !x.pendingObjects, title: 'Upload everything waiting in the local buffer now, even if slabs are not full', onclick: flushNow }, 'Flush now')), h('div', { class: 'stats' }, stat(x.pendingObjects, `pending · ${fmtBytes(x.pendingSize)}`), stat(x.uploadedObjects, `on Sia · ${fmtBytes(x.uploadedSize)}`), stat(x.unpinnedObjects, 'awaiting pin'), stat(x.multipartUploads, 'open multipart'), stat(x.failedUploads, 'failed uploads', x.failedUploads > 0), stat(x.orphanedObjects, 'orphaned', x.orphanedObjects > 0), ), h('p', { class: 'small muted', style: 'margin:12px 0 0' }, 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.'), af && h('p', { class: 'small muted', style: 'margin:6px 0 0' }, autoFlushLine(af, x), ' ', h('a', { href: '#/settings' }, af.enabled ? 'Change' : 'Turn on')), ); } catch (e) { put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message)); } } function autoFlushLine(af, x) { if (!af.enabled) return 'Leftovers upload only when a block fills up or you flush.'; if (af.flushing) return 'Uploading leftovers now…'; if (af.lastError && x.pendingObjects) return `The last automatic upload failed (${af.lastError}). Pithos will try again later.`; if (x.pendingObjects && af.dueAt) { const min = Math.max(1, Math.ceil((af.dueAt - Date.now()) / 60_000)); return `Leftovers upload automatically in about ${min} min if nothing new arrives.`; } return `Leftovers upload automatically after ${af.minutes} min with no new uploads.`; } async function flushNow(ev) { const btn = ev.currentTarget; btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Flushing…'); try { await api('POST', '/api/flush'); toast('All pending objects are on Sia'); } catch (e) { fail(e); } drawStats(); } draw(); drawStats(); loadAccount().then(draw); cleanup.push(onState(draw)); let lastState = daemonState(); cleanup.push(onState(() => { if (daemonState() !== lastState) { lastState = daemonState(); drawStats(); } })); const t = setInterval(() => { draw(); drawStats(); }, 4000); cleanup.push(() => clearInterval(t)); } function bannersFor(s) { const out = []; if (s.mode === 'hosted') { if (s.hosted?.error) out.push(h('div', { class: 'banner warn' }, h('span', {}, `Silent Mode is not reachable: ${s.hosted.error}`), h('a', { class: 'btn', href: '#/settings' }, 'Settings'))); return out; } if (!s.daemon.binary) { out.push(h('div', { class: 'banner warn' }, h('span', {}, `s3d is not installed. Pithos can download the official v${s.s3d.pinned} release and check its SHA-256.`), s.s3d.installable && h('button', { class: 'btn primary', onclick: installBinary }, `Install s3d v${s.s3d.pinned}`))); } else if (s.s3d?.outdated) { out.push(h('div', { class: 'banner warn' }, h('span', {}, `s3d v${s.s3d.version} is older than v${s.s3d.pinned}. User and key listing needs v0.2.0 or newer.`), s.s3d.installable && h('button', { class: 'btn', onclick: installBinary }, `Install v${s.s3d.pinned}`))); } if (s.registration?.registered === false || s.daemon.needsLogin) { out.push(h('div', { class: 'banner info' }, h('span', {}, 'This s3d is not connected to a Sia indexer yet.'), h('a', { class: 'btn primary', href: '#/account' }, 'Get started'))); } return out; } async function installBinary(ev) { const btn = ev.currentTarget; btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Downloading…'); try { const r = await api('POST', '/api/binary/install'); toast(`Installed s3d v${r.version?.version || ''}`); } catch (e) { fail(e); } refreshStatus(); } async function daemonAction(action) { try { await api('POST', `/api/daemon/${action}`); await refreshStatus(); } catch (e) { fail(e); } } // ---------------------------------------------------------------- get started (guided setup) // One step per screen, Back / Next, from "no account" to a working S3 key. // The connect step drives the same /api/login flow as the Sia connection // page, but opens the indexer's approval page by itself and moves on as // soon as the approval lands. const SETUP_DONE_KEY = 'pithos.setupDone'; const wiz = { step: 0, indexerUrl: 'https://sia.storage', mode: 'new', phrase: '', ownIndexer: false, openedUrl: null, key: null }; function setupDone() { try { return localStorage.getItem(SETUP_DONE_KEY) === '1'; } catch { return false; } } function getStarted(main) { const STEPS = ['Welcome', 'Sia Storage account', 'Install s3d', 'Recovery phrase', 'Connect', 'Start gateway', 'Access key', 'Done']; const body = h('div', { class: 'card wizard' }); main.append( h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, setupDone() ? 'Add a Sia account' : 'Get started'), h('p', { class: 'muted' }, setupDone() ? 'Connect s3d to another Sia account, one step at a time.' : 'Set up your own S3 storage on Sia, one step at a time.'))), body, ); const go = (n) => { wiz.step = Math.max(0, Math.min(STEPS.length - 1, n)); draw(); }; // Sia Storage never tells an app who signed in, so the person types the // email while they are signing in there, and it becomes the account label. // One input lives across redraws, so typing survives the login events. function emailField() { if (!wiz.emailInput) { const inp = h('input', { type: 'email', placeholder: 'you@example.com', maxlength: 120, autocomplete: 'email' }); let timer; const save = () => { clearTimeout(timer); const v = inp.value.trim(); if (v === (wiz.savedEmail || '')) return; wiz.savedEmail = v; api('PUT', '/api/account/label', { label: v }).then(() => refreshNavAccount(true)).catch(() => {}); }; inp.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(save, 800); }); inp.addEventListener('change', save); wiz.emailInput = inp; } const inp = wiz.emailInput; if (document.activeElement === inp) queueMicrotask(() => inp.focus()); return h('label', { class: 'field', style: 'max-width:420px;margin-top:16px' }, h('span', {}, 'Your Sia Storage email (optional)'), inp, h('small', {}, 'Sia Storage does not tell apps who you are, so Pithos names this account with the email you type here. You can change it later on Accounts.')); } const nav = (opts = {}) => h('div', { class: 'row wizard-nav' }, wiz.step > 0 && wiz.step < STEPS.length - 1 && h('button', { class: 'btn', disabled: opts.backDisabled, onclick: () => go(wiz.step - 1) }, 'Back'), h('span', { style: 'flex:1' }), opts.extra, opts.next !== false && h('button', { class: 'btn primary', disabled: !!opts.nextDisabled, onclick: opts.onNext || (() => go(wiz.step + 1)) }, opts.nextLabel || 'Next')); function progress() { return h('div', { class: 'wizard-progress' }, h('div', { class: 'small muted' }, `Step ${wiz.step + 1} of ${STEPS.length} · ${STEPS[wiz.step]}`), h('div', { class: 'bar' }, h('i', { style: `width:${Math.round((wiz.step / (STEPS.length - 1)) * 100)}%` }))); } function draw() { const s = state.status; const registered = !!s?.registration?.registered || ['done', 'already'].includes(state.login.state); let content; switch (wiz.step) { case 0: content = [ h('h2', {}, 'Your own S3 storage, on Sia'), h('p', {}, 'Pithos runs s3d, an S3-compatible gateway, on this computer. Your files are encrypted here and spread across independent Sia hosts.'), h('p', {}, 'This takes about five minutes:'), h('ol', { class: 'wizard-list' }, h('li', {}, 'Create a free Sia Storage account (50 GB free)'), h('li', {}, 'Install s3d'), h('li', {}, 'Choose your recovery phrase'), h('li', {}, 'Approve Pithos on Sia Storage'), h('li', {}, 'Start the gateway and create an access key')), registered && h('div', { class: 'banner info' }, h('span', {}, 'This s3d is already connected to Sia. You can skip ahead.'), h('button', { class: 'btn', onclick: () => go(5) }, 'Skip to the gateway')), nav({ nextLabel: 'Get started' }), ]; break; case 1: { const url = h('input', { type: 'url', value: wiz.indexerUrl, oninput: (e) => { wiz.indexerUrl = e.target.value.trim(); } }); content = [ h('h2', {}, 'Create your Sia Storage account'), h('p', {}, 'Sia Storage runs the indexer that rents space from Sia hosts for you. The free plan gives you 50 GB; you sign up with Sia Storage directly, and Pithos never sees your account password.'), !wiz.ownIndexer && [ h('div', { class: 'row' }, h('button', { class: wiz.accountReady ? 'btn' : 'btn primary', onclick: () => openLink('https://sia.storage') }, 'Open sia.storage ↗'), h('span', { class: 'muted small' }, 'Sign up or sign in there, then come back here.')), // Pithos cannot see the sia.storage tab, so the user confirms; the // Connect step proves it anyway, since approving needs a signed-in account. h('label', { class: 'check', style: 'margin-top:16px' }, h('input', { type: 'checkbox', checked: !!wiz.accountReady, onchange: (e) => { wiz.accountReady = e.target.checked; draw(); } }), 'I have created my Sia Storage account and I am signed in'), ], h('label', { class: 'check', style: 'margin-top:12px' }, h('input', { type: 'checkbox', checked: wiz.ownIndexer, onchange: (e) => { wiz.ownIndexer = e.target.checked; if (!wiz.ownIndexer) wiz.indexerUrl = 'https://sia.storage'; draw(); } }), 'I run my own indexer instead'), wiz.ownIndexer && h('label', { class: 'field', style: 'margin-top:8px' }, h('span', {}, 'Indexer URL'), url), nav({ nextDisabled: !wiz.ownIndexer && !wiz.accountReady, onNext: () => { if (!/^https?:\/\/\S+$/.test(wiz.indexerUrl)) return toast('Enter the indexer URL', 'error'); go(2); }, }), ]; break; } case 2: { const ok = !!s?.daemon.binary && !s?.s3d?.outdated && !!s?.s3d?.version; content = [ h('h2', {}, 'Install s3d'), ok ? h('p', {}, '✓ s3d ', h('code', {}, `v${s.s3d.version}`), ' is installed.') : [ h('p', {}, s?.daemon.binary ? `Your s3d (v${s.s3d.version || '?'}) is too old for Pithos.` : 's3d is not installed yet.'), h('p', { class: 'muted small' }, `Pithos downloads the official v${s?.s3d?.pinned} release from the Sia Foundation and checks its SHA-256 before installing it.`), h('button', { class: 'btn primary', disabled: !s?.s3d?.installable, onclick: async (e) => { await installBinary(e); draw(); } }, `Install s3d v${s?.s3d?.pinned}`), ], nav({ nextDisabled: !ok }), ]; break; } case 3: { const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { wiz.phrase = e.target.value; } }); phrase.value = wiz.phrase; const vaultOk = !!(bridge && vaultInfo && vaultInfo.setup); content = [ h('h2', {}, 'Your recovery phrase'), h('p', {}, 'Twelve words that let you reconnect to the same storage later, on this computer or a new one. Your files cannot be reached without them.'), h('div', { class: 'stack' }, vaultOk && h('label', { class: 'check', style: 'align-items:flex-start' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'vault', onchange: () => { wiz.mode = 'vault'; wiz.modeChosen = true; draw(); } }), h('span', {}, h('strong', {}, 'Use my Theseus vault'), ' (recommended)', h('br'), h('span', { class: 'small muted' }, 'Nothing to write down. The phrase comes from your password vault, so backing up the vault backs up your storage too.'))), h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'new', onchange: () => { wiz.mode = 'new'; wiz.modeChosen = true; draw(); } }), 'Create a new phrase for me to write down'), h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'existing', onchange: () => { wiz.mode = 'existing'; wiz.modeChosen = true; draw(); } }), 'I already have a phrase (reconnecting)')), wiz.mode === 'existing' && h('label', { class: 'field', style: 'margin-top:12px' }, h('span', {}, 'Recovery phrase'), phrase, h('small', {}, 'Sent only to the s3d process on this computer.')), wiz.mode === 'new' && h('p', { class: 'muted small', style: 'margin-top:12px' }, 'You will see the new phrase on the next screen and confirm you saved it before anything is registered.'), wiz.mode === 'vault' && h('p', { class: 'muted small', style: 'margin-top:12px' }, 'Theseus may ask for your PIN or master password to open the vault.'), nav({ nextLabel: 'Connect', onNext: () => { if (wiz.mode === 'existing' && wiz.phrase.trim().split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error'); wiz.openedUrl = null; go(4); startConnect(); } }), ]; break; } case 4: content = connectStep(registered); break; case 5: { const st = daemonState(); const running = st === 'running' || st === 'external'; content = [ h('h2', {}, 'Start your gateway'), running ? h('p', {}, '✓ s3d is running. Your S3 endpoint is ', h('code', {}, `http://${s.config.apiAddress}`), '.') : st === 'starting' ? h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Starting s3d…') : [ st === 'crashed' && h('div', { class: 'banner warn' }, h('span', {}, 's3d stopped. The Logs page shows why.'), h('a', { class: 'btn', href: '#/logs' }, 'Logs')), h('p', {}, 'Pithos starts s3d for you. It keeps running while Pithos is open.'), h('button', { class: 'btn primary', onclick: () => daemonAction(st === 'crashed' ? 'restart' : 'start') }, 'Start s3d'), ], nav({ nextDisabled: !running }), ]; break; } case 6: { if (wiz.key) { const k = wiz.key; const endpoint = `http://${s?.config.apiAddress}`; content = [ h('h2', {}, 'Your first access key'), h('p', {}, 'S3 apps sign in with this key pair. You can see it again under Users & keys.'), secretRow('Access key ID', k.accessKeyId), secretRow('Secret key', k.secretKey), h('div', { class: 'row', style: 'margin-top:10px' }, h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…')), h('p', { class: 'small muted', style: 'margin:14px 0 6px' }, 'Try it with the aws CLI:'), h('pre', { class: 'snippet' }, `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user}\naws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user}\naws configure set region us-east-1 --profile ${k.user}\naws --profile ${k.user} --endpoint-url ${endpoint} s3 mb s3://my-first-bucket`), nav({ backDisabled: true }), ]; } else { const name = h('input', { type: 'text', value: 'me', pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}' }); content = [ h('h2', {}, 'Create an access key'), h('p', {}, 'Each S3 user owns its own buckets. Start with one for yourself; you can add more for apps or people later.'), h('label', { class: 'field' }, h('span', {}, 'User name'), name), nav({ nextLabel: 'Create user and key', onNext: async (e) => { e.currentTarget.disabled = true; try { const r = await api('POST', '/api/users', { name: name.value.trim(), withKey: true }); wiz.key = r.key; state.selectedUser = r.name; try { localStorage.setItem('pithos.user', r.name); } catch {} } catch (err) { fail(err); } draw(); } }), ]; } break; } default: try { localStorage.setItem(SETUP_DONE_KEY, '1'); } catch {} content = [ h('h2', {}, '🎉 You are all set'), h('p', {}, 'Your S3 gateway is running and stores everything on Sia. Point any S3 app at it with the key you just created, or manage files right here.'), h('div', { class: 'row' }, h('a', { class: 'btn primary', href: '#/buckets' }, 'Open buckets'), h('a', { class: 'btn', href: '#/overview' }, 'Overview')), ]; } put(body, progress(), content); } async function startConnect() { if (state.status?.registration?.registered) return; if (wiz.mode === 'vault' && bridge) { try { const r = await bridge.invoke('connect-with-vault', { indexerUrl: wiz.indexerUrl }); if (r.status >= 400) throw new Error(r.data?.error || `HTTP ${r.status}`); } catch (e) { fail(e); } return; } try { await api('POST', '/api/login', { indexerUrl: wiz.indexerUrl, phrase: wiz.mode === 'existing' ? wiz.phrase.trim() : '' }); wiz.phrase = ''; } catch (e) { fail(e); } } function connectStep(registered) { const l = state.login; const retry = h('button', { class: 'btn', onclick: () => go(3) }, 'Back to the phrase'); if (registered) { // Approval landed: move on by itself. setTimeout(() => { if (wiz.step === 4) { refreshStatus(); go(5); } }, 1200); return [h('h2', {}, 'Connected'), h('p', {}, '✓ Pithos is approved on ', h('code', {}, l.indexerUrl || wiz.indexerUrl), '. Continuing…'), nav({ next: false })]; } switch (l.state) { case 'confirm': { const ack = h('input', { type: 'checkbox' }); const go2 = h('button', { class: 'btn primary', disabled: true, onclick: () => api('POST', '/api/login/confirm').catch(fail) }, 'I saved it — continue'); ack.addEventListener('change', () => { go2.disabled = !ack.checked; }); return [ h('h2', {}, 'Write down your recovery phrase'), h('p', {}, 'This is the only way to reconnect to your storage. Pithos does not keep it. Write it on paper or store it in a password manager.'), h('div', { class: 'phrase' }, (l.generatedPhrase || '').split(/\s+/).map((w) => h('span', {}, w))), h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn small', onclick: () => copy(l.generatedPhrase, 'Phrase copied: clear your clipboard after storing it') }, 'Copy')), h('label', { class: 'check', style: 'margin:16px 0' }, ack, 'I have stored these 12 words somewhere safe'), nav({ next: false, extra: go2 }), ]; } case 'approve': { // Open the approval page once, by itself; the button re-opens it. if (l.approvalUrl && wiz.openedUrl !== l.approvalUrl) { wiz.openedUrl = l.approvalUrl; wiz.autoOpened = null; openLink(l.approvalUrl).then((ok) => { wiz.autoOpened = ok; if (wiz.step === 4) draw(); }); } return [ h('h2', {}, 'Approve Pithos on Sia Storage'), h('p', {}, wiz.autoOpened === false ? 'Open the approval page, sign in to Sia Storage and approve the "S3d" app. Pithos continues on its own once you do.' : 'The approval page has opened. Sign in to Sia Storage there and approve the "S3d" app. Pithos continues on its own once you do.'), h('div', { class: 'row' }, h('button', { class: wiz.autoOpened === false ? 'btn primary' : 'btn', onclick: () => openLink(l.approvalUrl) }, wiz.autoOpened === false ? 'Open the approval page ↗' : 'Open the approval page again ↗'), h('button', { class: 'btn small', onclick: () => copy(l.approvalUrl) }, 'Copy link')), emailField(), h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for your approval…'), nav({ next: false, extra: h('button', { class: 'btn danger', onclick: () => api('POST', '/api/login/cancel').then(() => go(3)).catch(fail) }, 'Cancel') }), ]; } case 'terminal': return [ h('h2', {}, 'Finish in the console window'), h('p', {}, 'Terminal support could not be set up here, so a console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the link it prints. This page continues when the window closes.'), h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Waiting for the login window…'), nav({ next: false }), ]; case 'failed': return [ h('h2', {}, 'Connection failed'), h('div', { class: 'banner warn' }, h('span', {}, l.error || 'The login did not finish.')), // The phrase is dropped from memory once sent, so a retry with an // existing phrase has to ask for it again; a blank one would make // s3d generate a new phrase instead. nav({ next: false, extra: [retry, h('button', { class: 'btn primary', onclick: () => { wiz.openedUrl = null; if (wiz.mode === 'existing' && !wiz.phrase.trim()) return go(3); startConnect(); } }, 'Try again')] }), ]; case 'idle': return [h('h2', {}, 'Connect to Sia'), h('p', {}, 'Ready to connect.'), nav({ nextLabel: 'Connect', onNext: () => startConnect() })]; default: { const what = l.state === 'preparing' ? 'Setting up terminal support (first time only)…' : 'Contacting the indexer…'; return [h('h2', {}, 'Connecting'), h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ` ${what}`), nav({ next: false })]; } } } if (bridge && vaultInfo?.setup && !wiz.modeChosen) wiz.mode = 'vault'; // Resume where things stand if the user comes back mid-setup. if (wiz.step === 0 && state.login.state === 'approve') wiz.step = 4; draw(); cleanup.push(onState(draw)); } // ---------------------------------------------------------------- setup (indexer login) // Account: the Sia account this s3d stores and pays through. Before one is // connected it is the step-by-step guide; after, the account itself, with // adding another account and switching back to earlier ones. function account(main, rest) { const registered = !!state.status?.registration?.registered; if (!registered && ['idle', 'failed'].includes(state.login.state)) { getStarted(main, rest); // Accounts set aside earlier stay one click away while adding a new one. const slot = h('div'); main.append(slot); loadAccount().then(() => { const b = otherAccounts(() => route()); if (b) put(slot, h('div', { class: 'card', style: 'margin-top:16px' }, b)); }); return; } return setup(main, rest); } // Earlier Sia accounts set aside on this computer, each one click away. const archiveName = (a) => a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name); async function switchToArchive(a, after) { const name = archiveName(a); const ok = await confirmModal(`Switch to ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and drives. The current account is set aside and you can switch back the same way.', 'Switch', 'primary'); if (!ok) return; try { await api('POST', '/api/account/restore', { name: a.name }); await api('POST', '/api/daemon/start').catch(() => {}); toast(`Switched to ${name}`); await refreshStatus(); await loadAccount(); refreshNavAccount(true); after(); } catch (e) { fail(e); } } // The account switcher: every Sia account on this computer side by side, the // one in use marked, the others one click away, and Add another account. function accountSwitcher(onAdd, after) { const list = accountInfo?.archives || []; const cur = h('div', { class: 'acct-tile current' }, h('div', { class: 'row spread' }, h('span', { class: 'badge ok' }, 'in use'), h('span', { class: 'small muted' }, accountInfo?.indexerUrl ? new URL(accountInfo.indexerUrl).host : '')), h('strong', { class: 'acct-name' }, accountName(accountInfo)), h('span', { class: 'small muted', id: 'acct-space' }, ' ')); api('GET', '/api/space').then((sp) => { if (sp?.available) put(cur.querySelector('#acct-space'), `${fmtBytes(sp.remainingStorage)} free of ${fmtBytes(sp.maxPinnedData)}`); }).catch(() => {}); return h('div', { class: 'card acct-switcher' }, h('div', { class: 'row spread', style: 'margin-bottom:10px' }, h('h2', { style: 'margin:0' }, 'Your Sia accounts'), list.length ? h('span', { class: 'small muted' }, `${list.length + 1} on this computer`) : null), h('div', { class: 'acct-tiles' }, cur, list.map((a) => h('div', { class: 'acct-tile' }, h('span', { class: 'small muted' }, a.archivedAt ? `set aside ${fmtDate(a.archivedAt)}` : 'set aside'), h('strong', { class: 'acct-name' }, archiveName(a)), h('span', { class: 'small muted' }, [a.fingerprint && `key ${a.fingerprint}`, a.users.length && `users: ${a.users.join(', ')}`].filter(Boolean).join(' · ')), a.movedTo ? h('span', { class: 'small muted' }, `Drives moved to ${a.movedTo}; kept as a backup.`) : h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => switchToArchive(a, after) }, 'Switch to'))), h('button', { type: 'button', class: 'acct-tile add', onclick: onAdd }, h('span', { class: 'acct-plus' }, '+'), h('strong', {}, 'Add another account'), h('span', { class: 'small muted' }, 'This one is set aside, ready to switch back to')))); } function otherAccounts(after) { const list = accountInfo?.archives || []; if (!list.length) return null; async function restore(a) { return switchToArchive(a, after); } return h('div', {}, h('h3', {}, 'Other accounts on this computer'), h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside when another account was added. Switching back restores its users, keys and drives; the current account is set aside the same way.'), h('table', {}, h('tbody', {}, list.map((a) => h('tr', {}, h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)), h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.fingerprint ? ` · key ${a.fingerprint}` : ''}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`), a.movedTo && h('div', { class: 'small muted' }, `Left behind when these drives moved to ${a.movedTo}; kept as a backup only.`)), h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData || !!a.movedTo, onclick: () => restore(a) }, 'Switch to'))))))); } function setup(main) { const body = h('div', { class: 'card' }); const switcher = h('div', { hidden: true, style: 'margin-bottom:16px' }); main.append( h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Accounts'), h('p', { class: 'muted' }, 'Your Sia accounts on this computer. The one in use stores, and pays for, everything on this s3d.'))), switcher, body, ); let draft = { indexerUrl: 'https://sia.storage', phrase: '', mode: 'existing' }; function draw() { let l = state.login; // Registered already (e.g. on an earlier run): show that instead of the form. if (l.state === 'idle' && state.status?.registration?.registered) l = { state: 'already', indexerUrl: state.status.registration.indexerUrl }; const step = ['idle', 'failed'].includes(l.state) ? 0 : ['done', 'already'].includes(l.state) ? 2 : 1; const steps = h('div', { class: 'steps' }, ['Recovery phrase', 'Approve on the indexer', 'Connected'].map((t, i) => h('span', { class: i === step ? 'on' : '' }, `${i + 1}. ${t}`))); if (l.state === 'done' || l.state === 'already') { const label = h('input', { type: 'text', placeholder: 'e.g. the email you sign in with', value: accountInfo?.label || '', maxlength: 120 }); put(switcher, accountSwitcher(switchAccount, draw)); switcher.hidden = false; put(body, h('div', { class: 'small muted', style: 'text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px' }, 'Account in use'), h('h2', {}, accountInfo?.label || (l.indexerUrl ? new URL(l.indexerUrl).host : 'Connected to Sia')), h('p', { class: 'row small', style: 'margin-top:-6px' }, h('span', { class: 'badge ok' }, 'connected'), accountInfo?.label && l.indexerUrl && h('span', { class: 'muted' }, new URL(l.indexerUrl).host)), h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, drives and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'), h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'), h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label, h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); refreshNavAccount(true); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')), h('small', {}, 'Sia Storage does not tell apps your email, so name the account here: the email you approved the connection with is a good label.')), siaAccountBlock(accountInfo, async () => { try { accountInfo = await api('GET', '/api/account?fresh=1'); draw(); } catch (e) { fail(e); } }), h('div', { class: 'row', style: 'margin-top:16px' }, daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); navTo('#/overview'); } }, 'Start s3d'), h('a', { class: 'btn', href: '#/users' }, 'Users & keys'))); return; } switcher.hidden = true; if (l.state === 'confirm') { const words = (l.generatedPhrase || '').split(/\s+/); const ack = h('input', { type: 'checkbox', id: 'ack' }); const go = h('button', { class: 'btn primary', disabled: true, onclick: () => api('POST', '/api/login/confirm').catch(fail) }, 'Continue'); ack.addEventListener('change', () => { go.disabled = !ack.checked; }); put(body, steps, h('h2', {}, 'Write down your new recovery phrase'), h('p', {}, 'This phrase is the only way to register this storage again on another machine or after a reinstall. Pithos does not keep it. Store it offline.'), h('div', { class: 'phrase' }, words.map((w) => h('span', {}, w))), h('div', { class: 'row', style: 'margin-top:14px' }, h('button', { class: 'btn small', onclick: () => copy(l.generatedPhrase, 'Phrase copied - clear your clipboard after storing it') }, 'Copy')), h('label', { class: 'check', style: 'margin:16px 0' }, ack, 'I have stored the phrase somewhere safe'), h('div', { class: 'row' }, go, h('button', { class: 'btn', onclick: cancel }, 'Cancel'))); return; } if (l.state === 'terminal') { put(body, steps, h('h2', {}, 'Finish in the console window'), h('p', {}, 'A console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the approval link it prints. This page updates when the window closes.'), h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Waiting for the login window…'), h('div', { class: 'row' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel'))); return; } if (step === 1) { const waiting = l.state === 'approve' ? null : h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Talking to the indexer…'); put(body, steps, h('h2', {}, 'Approve the connection'), waiting, l.approvalUrl && [ h('p', {}, 'Open this link, sign in to your indexer account and approve the "S3d" app. This page continues by itself once you approve.'), h('div', { class: 'secret' }, h('code', {}, l.approvalUrl)), h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn primary', onclick: () => openLink(l.approvalUrl) }, 'Open approval page'), h('button', { class: 'btn', onclick: () => copy(l.approvalUrl) }, 'Copy link')), h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for approval…'), ], h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel'))); return; } // step 0: form const url = h('input', { type: 'url', value: draft.indexerUrl, required: true, oninput: (e) => { draft.indexerUrl = e.target.value; } }); const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { draft.phrase = e.target.value; } }); phrase.value = draft.phrase; const radios = h('div', { class: 'stack' }, h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'existing', onchange: () => { draft.mode = 'existing'; draw(); } }), 'I have a recovery phrase'), h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'new', onchange: () => { draft.mode = 'new'; draw(); } }), 'Generate a new one')); put(body, steps, l.state === 'failed' && h('div', { class: 'banner warn' }, h('span', {}, l.error || 'Login failed')), h('form', { class: 'stack', onsubmit: start }, h('label', { class: 'field' }, h('span', {}, 'Indexer'), url, h('small', {}, 'https://sia.storage is the hosted indexer run by the Sia Foundation. Use your own indexd URL if you run one.')), radios, draft.mode === 'existing' && h('label', { class: 'field' }, h('span', {}, 'Recovery phrase'), phrase, h('small', {}, 'Sent only to the local s3d process. It derives this app\'s key and is not stored by Pithos.')), h('div', { class: 'row' }, h('button', { class: 'btn primary', type: 'submit' }, 'Connect'))), archivesBlock()); } function archivesBlock() { const b = otherAccounts(draw); return b && h('div', { style: 'margin-top:22px' }, b); } async function switchAccount() { let pending = null; if (daemonState() === 'running') { try { pending = await api('GET', '/api/stats'); } catch {} } const users = (await api('GET', '/api/users').catch(() => [])).map((u) => u.name); const waiting = pending && pending.pendingObjects > 0; const ok = await modal('Add another Sia account?', h('div', { class: 'stack' }, h('p', { style: 'margin:0' }, `s3d is connected to ${accountName(accountInfo)}. s3d uses one Sia account at a time, so adding another sets this one aside on this computer; you can switch back to it any time on this page.`), h('ul', { class: 'small', style: 'margin:0;padding-left:1.2em' }, h('li', {}, `The current account keeps its files. Its S3 users${users.length ? ` (${users.join(', ')})` : ''}, keys and drives are set aside on this computer, not deleted, and you can switch back to it on the Account page.`), h('li', {}, 'The new account starts empty: you create users and keys again, and S3 apps need the new keys.'), h('li', {}, 's3d stops now; the guide then connects the new account.')), waiting && h('div', { class: 'banner warn' }, h('span', {}, `${pending.pendingObjects} object(s) (${fmtBytes(pending.pendingSize)}) are still waiting to upload to Sia.`), h('button', { class: 'btn', type: 'button', onclick: async (e) => { e.currentTarget.disabled = true; try { await api('POST', '/api/flush'); toast('Pending uploads are on Sia'); } catch (err) { fail(err); } } }, 'Flush now'))), [{ label: 'Cancel', result: false }, { label: 'Add account', kind: 'primary', submit: true, result: true }]); if (!ok) return; try { const r = await api('POST', '/api/account/switch'); toast(`Previous account set aside in ${r.archived}`); await refreshStatus(); await loadAccount(); refreshNavAccount(true); Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null, emailInput: null, savedEmail: '' }); navTo('#/start'); } catch (e) { fail(e); } } async function start(e) { e.preventDefault(); const phrase = draft.mode === 'existing' ? draft.phrase.trim() : ''; if (draft.mode === 'existing' && phrase.split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error'); try { await api('POST', '/api/login', { indexerUrl: draft.indexerUrl, phrase }); draft.phrase = ''; } catch (err) { fail(err); } } async function cancel() { try { await api('POST', '/api/login/cancel'); } catch (e) { fail(e); } } draw(); loadAccount().then(draw); cleanup.push(onState(draw)); } async function openLink(url) { if (bridge) { try { await bridge.invoke('open-external', { url }); return true; } catch (e) { fail(e); return false; } } const host = state.status?.host; if (host === 'desktop' || host === 'theseus') { try { await api('POST', '/api/open-external', { url }); return true; } catch { /* fall through */ } } // Returns false when a pop-up blocker stopped it (no click behind the call). return !!window.open(url, '_blank', 'noopener'); } // ---------------------------------------------------------------- users & keys function users(main) { const list = h('div', { class: 'card' }); main.append( h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Users & keys'), h('p', { class: 'muted' }, 'Each S3 user owns its own buckets. Access keys let S3 clients (aws, rclone, apps) act as that user.')), h('div', { class: 'row' }, h('button', { class: 'btn', onclick: openCredentialsDialog }, 'Open saved credentials'), h('button', { class: 'btn primary', onclick: createUser }, 'New user'))), list, ); let expanded = new Set(); async function load() { let rows; try { rows = await api('GET', '/api/users'); } catch (e) { put(list, h('p', { class: 'error' }, e.message)); return; } if (!rows.length) { put(list, h('div', { class: 'empty' }, h('p', {}, 'No users yet.'), h('button', { class: 'btn primary', onclick: createUser }, 'Create the first user'))); return; } const keys = await api('GET', '/api/keys').catch(() => []); put(list, h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, 'User'), h('th', {}, 'Access keys'), h('th', {}))), h('tbody', {}, rows.flatMap((u) => { const open = expanded.has(u.name); const userKeys = keys.filter((k) => k.user === u.name); const main = h('tr', { class: 'clickable', onclick: () => { open ? expanded.delete(u.name) : expanded.add(u.name); load(); } }, h('td', {}, h('strong', {}, u.name)), h('td', {}, `${u.keys.length}`), h('td', { class: 'actions' }, h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); navTo('#/buckets'); } }, 'Drives'), ' ', h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'), ' ', h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteUser(u.name); } }, 'Delete'))); if (!open) return [main]; const detail = h('tr', {}, h('td', { colspan: 3, style: 'background:var(--surface-2)' }, userKeys.length ? h('table', {}, h('tbody', {}, userKeys.map((k) => h('tr', {}, h('td', {}, h('code', {}, k.accessKeyId)), h('td', { class: 'actions' }, h('button', { class: 'btn small', onclick: () => showKey(k) }, 'Show secret & config'), ' ', h('button', { class: 'btn small danger', onclick: () => deleteKey(k.accessKeyId) }, 'Revoke')))))) : h('p', { class: 'muted', style: 'margin:0' }, 'No keys. Create one to use S3 clients or browse buckets.'))); return [main, detail]; })))); } async function createUser() { const name = h('input', { type: 'text', required: true, pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}', placeholder: 'e.g. backups' }); const withKey = h('input', { type: 'checkbox', checked: true }); // The user's own bucket, named after them unless edited. const bucket = h('input', { type: 'text', pattern: '[a-z0-9][a-z0-9-]{1,61}[a-z0-9]', placeholder: 'bucket name' }); let bucketEdited = false; bucket.addEventListener('input', () => { bucketEdited = true; }); name.addEventListener('input', () => { if (!bucketEdited) bucket.value = suggestBucket(name.value); }); const v = await modal('New user', h('div', { class: 'stack' }, h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'Letters, digits, . _ @ -')), h('label', { class: 'field' }, h('span', {}, 'Their drive'), bucket, h('small', {}, 'Created with the user, like a home drive (an S3 bucket). Lowercase letters, digits and hyphens. They can add more later.')), h('label', { class: 'check' }, withKey, 'Create an access key now')), [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => ({ name: name.value.trim(), withKey: withKey.checked, bucket: bucket.value.trim() || undefined }) }]); if (!v) return; try { const r = await api('POST', '/api/users', v); toast(r.bucket ? `Created ${r.name} with bucket ${r.bucket}` : r.bucketError ? `Created ${r.name}; bucket not created: ${r.bucketError}` : `Created ${r.name} (start s3d to give them a bucket)`); expanded.add(r.name); if (!state.selectedUser) { state.selectedUser = r.name; localStorage.setItem('pithos.user', r.name); } await load(); if (r.key) showKey(r.key, true); } catch (e) { fail(e); } } async function newKey(user) { try { const k = await api('POST', '/api/keys', { user }); expanded.add(user); await load(); showKey(k, true); } catch (e) { fail(e); } } async function deleteUser(name) { if (!(await confirmModal(`Delete ${name}?`, 'Its access keys stop working immediately. s3d refuses if the user still owns buckets.'))) return; try { await api('DELETE', `/api/users/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); } } async function deleteKey(id) { if (!(await confirmModal('Revoke this key?', `Clients using ${id} will be refused from now on.`, 'Revoke'))) return; try { await api('DELETE', `/api/keys/${encodeURIComponent(id)}`); toast('Key revoked'); load(); } catch (e) { fail(e); } } load(); } function showKey(k, fresh) { const s = state.status; const endpoint = s?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000'; const aws = `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user} aws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user} aws configure set region us-east-1 --profile ${k.user} aws --profile ${k.user} --endpoint-url ${endpoint} s3 ls`; const rclone = `[sia-${k.user}] type = s3 provider = Other access_key_id = ${k.accessKeyId} secret_access_key = ${k.secretKey} endpoint = ${endpoint} region = us-east-1 force_path_style = true`; const env = `AWS_ACCESS_KEY_ID=${k.accessKeyId} AWS_SECRET_ACCESS_KEY=${k.secretKey} AWS_REGION=us-east-1 AWS_ENDPOINT_URL=${endpoint}`; const tabs = { 'aws cli': aws, rclone, env }; const pre = h('pre', { class: 'snippet' }, aws); const tabBtns = h('div', { class: 'row' }, Object.keys(tabs).map((t, i) => h('button', { type: 'button', class: `btn small ${i === 0 ? 'primary' : ''}`, onclick: (e) => { for (const b of tabBtns.children) b.classList.remove('primary'); e.currentTarget.classList.add('primary'); pre.textContent = tabs[t]; }, }, t))); modal(fresh ? `Access key for ${k.user}` : `Key ${k.accessKeyId}`, h('div', { class: 'stack' }, fresh && h('p', { class: 'small muted', style: 'margin:0' }, 'Treat the secret like a password. You can view it again here later.'), secretRow('Access key ID', k.accessKeyId), secretRow('Secret key', k.secretKey), h('div', { class: 'row' }, h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…'), h('button', { class: 'btn small', type: 'button', onclick: () => exportJsonDialog(k) }, 'Export JSON…')), h('h3', { style: 'margin-top:8px' }, 'Client config'), tabBtns, pre, h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => copy(pre.textContent) }, 'Copy snippet'))), [{ label: 'Done', kind: 'primary', submit: true, result: true }]); } // ---------------------------------------------------------------- plain JSON export // The shape scripts and agents read: one key, one drive. It is not encrypted, // so the dialog says where it should and should not go. async function exportJsonDialog(k) { const endpoint = state.status?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000'; let drives = []; try { drives = (await api('GET', `/api/s3/${encodeURIComponent(k.user)}/buckets`)).map((b) => b.name); } catch {} const sel = h('select', {}, drives.length ? drives.map((n) => h('option', { value: n }, n)) : h('option', { value: '' }, '(no drives yet)')); const json = () => JSON.stringify({ endpoint, accessKey: k.accessKeyId, secretKey: k.secretKey, bucket: sel.value }, null, 2) + '\n'; const masked = () => json().replace(k.secretKey, '•'.repeat(12)); const pre = h('pre', { class: 'snippet' }, masked()); sel.addEventListener('change', () => { pre.textContent = masked(); }); await modal('Export JSON', h('div', { class: 'stack' }, h('p', { class: 'small muted', style: 'margin:0' }, `A plain JSON file with the endpoint, this key and one drive of ${k.user}, for scripts, backup tools and AI agents. The key can reach every drive of ${k.user}; the bucket field only says which one to use.`), drives.length > 0 && h('label', { class: 'field' }, h('span', {}, 'Drive'), sel), pre, h('div', { class: 'banner warn' }, h('span', {}, 'The secret is not encrypted in this file. Keep it out of git, chats and shared folders, and give an agent its own S3 user so you can revoke it alone. For a protected copy use Save credentials….')), isHosted() && h('p', { class: 'small muted', style: 'margin:0' }, 'Drives on Silent Mode are encrypted by Pithos on your computer, so other tools see encrypted files except in folders you share.')), [{ label: 'Cancel', result: null }, { label: 'Copy', value: async () => { await copy(json(), 'JSON copied'); return false; } }, { label: 'Save file', kind: 'primary', submit: true, value: async () => { try { const name = `pithos-${k.user}${sel.value ? '-' + sel.value : ''}.json`; if (await saveFile(name, json())) toast('Saved ' + name); return true; } catch (e) { fail(e); return false; } } }]); } // ---------------------------------------------------------------- saved credentials (encrypted file) // A key pair saved to a file the user keeps. Password files use PBKDF2-SHA256 // (600k) + AES-256-GCM in the page; vault files are sealed by the Theseus // add-on with a key derived from the vault, so they need no extra password. // Only the user name and date stay readable, to tell files apart. const CRED_FORMAT = 'pithos-credentials'; const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); async function passwordKey(password, salt, iterations) { const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); } async function sealCredentials(k, { mode, password }) { const payload = JSON.stringify({ user: k.user, accessKeyId: k.accessKeyId, secretKey: k.secretKey, endpoint: state.status?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000', region: 'us-east-1', }); const head = { format: CRED_FORMAT, v: 1, user: k.user, saved: new Date().toISOString() }; if (mode === 'vault') { const r = await bridge.invoke('vault-seal', { plaintext: payload }); return { ...head, protection: 'theseus-vault', iv: r.iv, ct: r.ct }; } const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const iterations = 600000; const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await passwordKey(password, salt, iterations), new TextEncoder().encode(payload)); return { ...head, protection: 'password', kdf: { name: 'PBKDF2', hash: 'SHA-256', iterations, salt: b64(salt) }, iv: b64(iv), ct: b64(ct) }; } async function openCredentials(file, password) { if (!file || file.format !== CRED_FORMAT || file.v !== 1) throw new Error('This is not a Pithos credentials file'); let text; if (file.protection === 'theseus-vault') { if (!bridge) throw new Error('This file is protected with a Theseus vault. Open it in Pithos inside Theseus.'); text = (await bridge.invoke('vault-open', { iv: file.iv, ct: file.ct })).plaintext; } else { try { const key = await passwordKey(password, unb64(file.kdf.salt), file.kdf.iterations); text = new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: unb64(file.iv) }, key, unb64(file.ct))); } catch { throw new Error('Wrong password, or the file is damaged'); } } return JSON.parse(text); } async function saveFile(name, text) { if (bridge) { const r = await bridge.invoke('save-file', { name, text }); return !!r.saved; } const url = URL.createObjectURL(new Blob([text], { type: 'application/json' })); const a = h('a', { href: url, download: name }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); return true; } async function saveCredentialsDialog(k) { const vaultOk = !!(bridge && vaultInfo && vaultInfo.setup && vaultInfo.prompt); let mode = vaultOk ? 'vault' : 'password'; const p1 = h('input', { type: 'password', placeholder: 'Password (at least 8 characters)', autocomplete: 'new-password' }); const p2 = h('input', { type: 'password', placeholder: 'Repeat the password', autocomplete: 'new-password' }); const pwBox = h('div', { class: 'stack', hidden: mode === 'vault' }, p1, p2, h('small', { class: 'muted' }, 'Pithos cannot recover this password. Without it the file cannot be opened.')); const choice = vaultOk && h('div', { class: 'stack' }, h('label', { class: 'check', style: 'align-items:flex-start' }, h('input', { type: 'radio', name: 'credmode', checked: true, onchange: () => { mode = 'vault'; pwBox.hidden = true; } }), h('span', {}, h('strong', {}, 'Protect with my Theseus vault'), h('br'), h('span', { class: 'small muted' }, 'No extra password. Opens in Pithos with the same vault unlocked.'))), h('label', { class: 'check', style: 'align-items:flex-start' }, h('input', { type: 'radio', name: 'credmode', onchange: () => { mode = 'password'; pwBox.hidden = false; } }), h('span', {}, h('strong', {}, 'Protect with a password'), h('br'), h('span', { class: 'small muted' }, 'Opens in Pithos anywhere, with the password.')))); await modal('Save credentials', h('div', { class: 'stack' }, h('p', { class: 'small muted', style: 'margin:0' }, `Saves the access key and secret for ${k.user} to an encrypted file. Open it later under Users & keys.`), choice, pwBox), [{ label: 'Cancel', result: null }, { label: 'Save file', kind: 'primary', submit: true, value: async () => { if (mode === 'password') { if (p1.value.length < 8) { toast('Use at least 8 characters', 'error'); return false; } if (p1.value !== p2.value) { toast("The two passwords don't match", 'error'); return false; } } try { const sealed = await sealCredentials(k, { mode, password: p1.value }); if (await saveFile(`pithos-${k.user}-${k.accessKeyId.slice(0, 6)}.pithoskey`, JSON.stringify(sealed, null, 2))) toast('Credentials saved'); return true; } catch (e) { fail(e); return false; } } }]); } function openCredentialsDialog() { const input = h('input', { type: 'file', accept: '.pithoskey,application/json' }); input.addEventListener('change', async () => { const f = input.files[0]; if (!f) return; let file; try { file = JSON.parse(await f.text()); } catch { return toast('This is not a Pithos credentials file', 'error'); } let password = ''; if (file.protection === 'password') { const pw = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'current-password' }); password = await modal(`Open credentials${file.user ? ` for ${file.user}` : ''}`, h('label', { class: 'field' }, h('span', {}, 'Password'), pw), [{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => pw.value }]); if (!password) return; } try { showKey(await openCredentials(file, password)); } catch (e) { fail(e); } }); input.click(); } // ---------------------------------------------------------------- buckets & objects // View types for drives and folders: Icons, List or Details, remembered per page. const VIEW_TYPES = [['icons', '▦', 'Icons'], ['list', '☰', 'List'], ['details', '≣', 'Details']]; function viewMode(page, fallback) { try { const v = localStorage.getItem(`pithos.view.${page}`); if (VIEW_TYPES.some(([k]) => k === v)) return v; } catch {} return fallback; } function viewSwitch(page, current, onChange) { return h('div', { class: 'seg', role: 'group', 'aria-label': 'View' }, VIEW_TYPES.map(([key, icon, label]) => h('button', { type: 'button', class: key === current ? 'on' : '', title: label, 'aria-label': label, 'aria-pressed': String(key === current), onclick: () => { try { localStorage.setItem(`pithos.view.${page}`, key); } catch {} onChange(key); } }, icon))); } // "⋯" menu for one item: [label, action, kind?] rows; closes on any outside click. function itemMenu(items) { const btn = h('button', { type: 'button', class: 'btn small more', title: 'More', 'aria-label': 'More actions', 'aria-haspopup': 'menu' }, '⋯'); btn.addEventListener('click', (e) => { e.stopPropagation(); document.querySelectorAll('.menu').forEach((m) => m.remove()); // Picking an item must also drop the listeners, or a stale Escape handler // swallows Escape for every dialog opened afterwards. let menu; function done() { menu?.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); } menu = h('div', { class: 'menu', role: 'menu' }, items.filter(Boolean).map(([label, run, kind]) => h('button', { type: 'button', role: 'menuitem', class: kind || '', onclick: (ev) => { ev.stopPropagation(); done(); run(); } }, label))); document.body.append(menu); const r = btn.getBoundingClientRect(); const w = menu.offsetWidth, ht = menu.offsetHeight; menu.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px'; menu.style.top = (r.bottom + 4 + ht > innerHeight - 8 ? Math.max(8, r.top - ht - 4) : r.bottom + 4) + 'px'; function away(ev) { if (!menu.contains(ev.target)) done(); } function esc(ev) { if (ev.key === 'Escape') { ev.stopPropagation(); done(); btn.focus(); } } setTimeout(() => { document.addEventListener('mousedown', away, true); document.addEventListener('keydown', esc, true); }, 0); menu.querySelector('button')?.focus(); }); return btn; } const go = (hash) => () => navTo(hash); const ICONS = { download: '', share: '', }; function iconButton(name, label, run) { const b = h('button', { type: 'button', class: 'btn small icon', title: label, 'aria-label': label, onclick: (e) => { e.stopPropagation(); run(); } }); b.innerHTML = ICONS[name]; // fixed markup above, never user data return b; } const onEnter = (fn) => (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) fn(); }; const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || ''; function fileKind(key) { const e = EXT(key); if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'svg', 'ico'].includes(e)) return 'image'; if (['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus'].includes(e)) return 'audio'; if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(e)) return 'video'; if (['txt', 'md', 'json', 'csv', 'log', 'xml', 'yml', 'yaml', 'toml', 'ini', 'html', 'htm', 'css', 'js', 'mjs', 'ts', 'py', 'go', 'rs', 'sh', 'sql'].includes(e)) return 'text'; return null; } const isHtml = (key) => ['html', 'htm'].includes(EXT(key)); function suggestBucket(name) { let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); if (b && b.length < 3) b += '-files'; return b.slice(0, 63).replace(/-+$/, ''); } function fileType(key) { const e = EXT(key); return ({ image: 'Image', audio: 'Audio', video: 'Video', text: 'Text' }[fileKind(key)] || 'File') + (e ? ` (.${e})` : ''); } function fileIcon(key) { return { image: '🖼️', audio: '🎵', video: '🎬', text: '📄' }[fileKind(key)] || '📦'; } function buckets(main, [bucket, ...prefixParts]) { const head = h('div', { class: 'page-head' }); const body = h('div'); main.append(head, body); let users = []; const userSel = h('select', { onchange: (e) => { state.selectedUser = e.target.value; localStorage.setItem('pithos.user', e.target.value); if (PATH_MODE) navTo('#/buckets'); else { location.hash = '#/buckets'; route(); } } }); (async () => { try { users = await api('GET', '/api/users'); } catch (e) { put(body, h('div', { class: 'card' }, h('p', { class: 'error' }, e.message))); return; } const usable = users.filter((u) => u.keys.length); if (!usable.length) { put(head, h('div', {}, h('h1', {}, 'Drives'))); put(body, h('div', { class: 'card empty' }, h('p', {}, users.length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.'), h('a', { class: 'btn primary', href: '#/users' }, 'Users & keys'))); return; } if (!usable.some((u) => u.name === state.selectedUser)) state.selectedUser = usable[0].name; put(userSel, ...usable.map((u) => h('option', { value: u.name, selected: u.name === state.selectedUser }, u.name))); const st = daemonState(); if (st !== 'running' && st !== 'external') { put(head, h('div', {}, h('h1', {}, 'Drives'))); put(body, h('div', { class: 'card empty' }, h('p', {}, 's3d is not running.'), h('button', { class: 'btn primary', onclick: async () => { await daemonAction('start'); setTimeout(route, 1500); } }, 'Start s3d'))); return; } if (bucket) objectBrowser(head, body, state.selectedUser, bucket, prefixParts.join('/'), userSel); else bucketList(head, body, state.selectedUser, userSel); })(); // Follow s3d: the page was drawn for one state; redraw when it flips. const runningNow = () => ['running', 'external'].includes(daemonState()); let wasRunning = runningNow(); cleanup.push(onState(() => { if (runningNow() !== wasRunning) { wasRunning = runningNow(); route(); } })); } function userPicker(userSel) { return h('label', { class: 'row small', title: 'The S3 user whose buckets you see. All users share the one Sia account below.' }, h('span', { class: 'muted' }, 'S3 user'), h('span', { style: 'width:180px' }, userSel)); } // ---- Sia account (one per s3d) ------------------------------------------- let accountInfo = null; async function loadAccount() { try { accountInfo = await api('GET', '/api/account'); } catch { accountInfo = null; } return accountInfo; } const accountName = (a) => (a && (a.label || a.sia?.fingerprint || (a.indexerUrl ? new URL(a.indexerUrl).host : ''))) || 'not connected'; // What the indexer says about this s3d's Sia account. The figures are the // ones sia.storage shows for the app, which is how a person tells which of // their accounts (or apps) this is. function siaAccountBlock(a, onRefresh) { const sia = a?.sia; if (!sia?.connected) return null; const used = sia.pinnedData != null ? `${fmtBytes(sia.pinnedData)}${sia.maxPinnedData ? ` of ${fmtBytes(sia.maxPinnedData)}` : ''}` : '–'; return h('div', { class: 'card', style: 'margin:14px 0;background:var(--surface-2)' }, h('div', { class: 'row spread' }, h('h3', { style: 'margin:0' }, 'Which Sia account this is'), onRefresh && h('button', { class: 'btn small', type: 'button', onclick: onRefresh }, 'Check again')), sia.error && h('p', { class: 'small error' }, `The indexer did not answer: ${sia.error}`), h('dl', { class: 'kv' }, h('dt', {}, 'Account key'), h('dd', {}, h('code', { title: sia.publicKey }, sia.fingerprint), ' ', h('button', { class: 'btn small', type: 'button', onclick: () => copy(`ed25519:${sia.publicKey}`, 'Account key copied') }, 'Copy')), h('dt', {}, 'App'), h('dd', {}, sia.app?.name || 'S3d'), h('dt', {}, 'Stored'), h('dd', {}, used), h('dt', {}, 'Last used'), h('dd', {}, sia.lastUsed ? fmtDate(sia.lastUsed) : '–'), h('dt', {}, 'Indexer'), h('dd', {}, h('code', {}, sia.indexerUrl))), h('p', { class: 'small muted', style: 'margin:8px 0 0' }, 'On sia.storage, the account whose app "', sia.app?.name || 'S3d', '" shows this storage and last-used time is this one. ', 'The account is decided by two things together: the sia.storage login that approved the connection, and the recovery phrase. ', 'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.')); } // "N files are waiting to upload to Sia" with Upload now. s3d holds small // uploads until about 40 MB has built up; until then they exist only on this // computer, which is easy to miss. function pendingBanner() { const el = h('div', { hidden: true }); const st = daemonState(); if (st !== 'running' && st !== 'external') return el; Promise.all([api('GET', '/api/stats'), api('GET', '/api/autoflush').catch(() => null)]).then(([x, af]) => { if (!x?.pendingObjects) return; const n = x.pendingObjects; const when = af?.enabled ? (af.dueAt ? `They go up by themselves in about ${Math.max(1, Math.ceil((af.dueAt - Date.now()) / 60_000))} min if nothing new arrives.` : `They go up by themselves after ${af.minutes} min with no new uploads.`) : 'They go up only when about 40 MB has built up, or when you upload them now.'; const btn = h('button', { class: 'btn primary small', onclick: async () => { btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Uploading…'); try { await api('POST', '/api/flush'); toast('Your files are on Sia'); el.hidden = true; } catch (e) { fail(e); btn.disabled = false; put(btn, 'Upload now'); } } }, 'Upload now'); put(el, h('div', { class: 'banner info' }, h('span', {}, h('strong', {}, `${n.toLocaleString()} file${n === 1 ? '' : 's'} (${fmtBytes(x.pendingSize)}) waiting to upload to Sia. `), h('span', { class: 'small' }, `Until then they are only on this computer. ${when}`)), btn)); el.hidden = false; }).catch(() => {}); return el; } // The Accounts card at the top of the navigation: which Sia account this is // and how much room it has left. Refreshed at most every 30 s as pages change. let navAccountAt = 0; function refreshNavAccount(force) { if (!force && Date.now() - navAccountAt < 30_000) return; navAccountAt = Date.now(); loadAccount().then((a) => { $('#na-name').textContent = a?.registered ? accountName(a) : 'Not connected yet'; }).catch(() => {}); api('GET', '/api/space').then((s) => { const el = $('#na-space'); if (!s?.available || !(s.maxPinnedData > 0)) { el.hidden = true; return; } const pct = Math.min(100, Math.round((s.pinnedData / s.maxPinnedData) * 100)); put(el, h('span', {}, `${fmtBytes(s.remainingStorage)} free of ${fmtBytes(s.maxPinnedData)}`), h('span', { class: `bar${pct >= 90 ? ' full' : ''}` }, h('i', { style: `width:${Math.max(pct, s.pinnedData > 0 ? 2 : 0)}%` }))); el.hidden = false; }).catch(() => {}); } // Space on the Sia account: "12.4 GiB of 46.6 GiB used · 34.2 GiB free" and a bar. function spaceLine() { const el = h('div', { class: 'space', hidden: true }); api('GET', '/api/space').then((s) => { if (!s?.available || !(s.maxPinnedData > 0)) return; const pct = Math.min(100, Math.round((s.pinnedData / s.maxPinnedData) * 100)); put(el, h('div', { class: 'small muted' }, `${fmtBytes(s.pinnedData)} of ${fmtBytes(s.maxPinnedData)} used on Sia · `, h('strong', {}, `${fmtBytes(s.remainingStorage)} free`)), h('div', { class: `bar space-bar${pct >= 90 ? ' full' : ''}`, title: `${pct}% used` }, h('i', { style: `width:${Math.max(pct, s.pinnedData > 0 ? 1 : 0)}%` }))); el.hidden = false; }).catch(() => {}); return el; } // "Sia account: …" line for page headers; fills itself in. function accountLine() { const el = h('div', { class: 'small muted account-line' }, 'Sia account: …'); loadAccount().then((a) => put(el, 'Sia account: ', h('strong', {}, accountName(a)), a && a.label && a.indexerUrl ? h('span', {}, ` (${new URL(a.indexerUrl).host})`) : null, ' · ', h('a', { href: '#/account' }, a && a.registered ? 'Manage' : 'Connect'))); return el; } async function bucketList(head, body, user, userSel) { put(head, h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine(), spaceLine()), h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive'))); const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); const bar = h('div', { class: 'view-bar' }); put(body, pendingBanner(), bar, card); const base = `/api/s3/${encodeURIComponent(user)}/buckets`; let mode = viewMode('drives', 'icons'); let list = []; async function load() { try { list = await api('GET', base); draw(); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } } // Buckets read as drives: the top level you put folders into. function draw() { put(bar, list.length ? viewSwitch('drives', mode, (m) => { mode = m; draw(); }) : null); if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; } const open = (b) => go(`#/buckets/${encodeURIComponent(b.name)}`); const menu = (b) => itemMenu([['Open', open(b)], ['Access…', () => accessDialog(user, b.name).then(load)], ['Rename…', () => renameDrive(b.name)], ['Delete', () => remove(b.name), 'danger']]); if (mode === 'details') { put(card, h('table', { class: 'files' }, h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', {}, 'Contents'), h('th', { class: 'hide-sm' }, 'Access'), h('th', {}))), h('tbody', {}, list.map((b) => h('tr', { class: 'clickable', tabindex: '0', onclick: open(b), onkeydown: onEnter(open(b)) }, h('td', { class: 'name' }, h('span', { class: 'drive-icon sm', 'aria-hidden': 'true' }), h('span', { class: 'label' }, b.name)), h('td', { class: 'muted' }, usageLine(b.name)), h('td', { class: 'hide-sm' }, accessBadge(b.name)), h('td', { class: 'actions', onclick: (e) => e.stopPropagation() }, menu(b))))))); } else if (mode === 'list') { put(card, h('ul', { class: 'rows' }, list.map((b) => h('li', { class: 'clickable', tabindex: '0', onclick: open(b), onkeydown: onEnter(open(b)) }, h('span', { class: 'drive-icon sm', 'aria-hidden': 'true' }), h('span', { class: 'label' }, b.name), h('span', { class: 'meta' }, usageLine(b.name)), h('span', { onclick: (e) => e.stopPropagation() }, menu(b)))))); } else { put(card, h('div', { class: 'tiles' }, list.map((b) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: open(b), onkeydown: onEnter(open(b)) }, h('div', { class: 'drive-icon', 'aria-hidden': 'true' }), h('div', { class: 'tile-name' }, b.name), usageLine(b.name), h('div', { class: 'tile-meta' }, accessBadge(b.name)), h('div', { class: 'tile-more', onclick: (e) => e.stopPropagation() }, menu(b)))))); } } function accessBadge(bucketName) { const el = h('span', {}, h('span', { class: 'badge' }, '…')); api('GET', `${base}/${encodeURIComponent(bucketName)}/policy`).then((p) => { put(el, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: isHosted() ? '🔒 encrypted' : '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public])); }).catch(() => put(el, '?')); return el; } // Files and size on a drive, counted in the background. function usageLine(bucketName) { const el = h('span', { class: 'small muted usage' }, 'counting…'); api('GET', `${base}/${encodeURIComponent(bucketName)}/usage`) .then((u) => put(el, `${u.files.toLocaleString()}${u.partial ? '+' : ''} file${u.files === 1 ? '' : 's'} · ${fmtBytes(u.bytes)}${u.partial ? '+' : ''}`)) .catch(() => put(el, '')); return el; } async function create() { const name = h('input', { type: 'text', required: true, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', placeholder: 'my-drive' }); const v = await modal('New bucket', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.')), [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]); if (!v) return; try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); } } async function renameDrive(current) { const input = h('input', { type: 'text', required: true, value: current, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', spellcheck: 'false' }); const name = await modal('Rename drive', h('div', { class: 'stack' }, h('label', { class: 'field' }, h('span', {}, 'New name'), input, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens.')), h('p', { class: 'small muted', style: 'margin:0' }, 'Files and access settings move to the new name; nothing is uploaded again. S3 apps that use this drive need the new name.')), [{ label: 'Cancel', result: null }, { label: 'Rename', kind: 'primary', submit: true, value: () => input.value.trim() }]); if (!name || name === current) return; try { const r = await api('POST', `${base}/${encodeURIComponent(current)}/rename`, { name }); toast(`Renamed to ${name}${r.moved ? ` (${r.moved} file${r.moved === 1 ? '' : 's'})` : ''}`); load(); } catch (e) { fail(e); } } async function remove(name) { if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return; try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); } } load(); } async function accessDialog(user, bucket) { if (isHosted()) return hostedAccessDialog(user, bucket); const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}/policy`; let current = 'private'; try { current = (await api('GET', base)).public; } catch (e) { return fail(e); } const opts = [ ['private', 'Private', 'Only this user\'s access keys can read.'], ['read', 'Public read', 'Anyone who knows an object\'s URL can download it.'], ['read-list', 'Public read + list', 'Anyone can also list the bucket\'s contents.'], ]; let choice = current === 'custom' ? 'private' : current; const endpoint = `http://${state.status?.config.apiAddress}`; const v = await modal(`Access for ${bucket}`, h('div', { class: 'stack' }, current === 'custom' && h('div', { class: 'banner warn' }, 'This bucket has a custom policy. Saving replaces it.'), opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' }, h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }), h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))), h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/`), '. Writes and deletes always need a key. You can change this at any time.'), localOnly() && h('div', { class: 'banner info small', style: 'margin:0' }, h('span', {}, 'Right now s3d listens only on this computer, so "public" means public to programs on this computer. Other people reach public files once s3d has a public address.')), h('p', { class: 'small muted', style: 'margin:0' }, 'Access applies to the whole bucket: s3d cannot make a single folder public, or give other people write rights. Keep public files in a bucket of their own, or send a share link for a single file.')), [{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]); if (!v || v === current) return; try { await api('PUT', base, { mode: v }); toast('Access updated'); } catch (e) { fail(e); } } function objectBrowser(head, body, user, bucket, prefix, userSel) { if (prefix && !prefix.endsWith('/')) prefix += '/'; const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}`; const go = (p) => navTo(`#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`); const crumbs = h('div', { class: 'crumbs' }, h('button', { onclick: () => navTo('#/buckets') }, 'Drives'), h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go('') }, bucket), prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)])); const fileInput = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => { uploadFiles([...e.target.files]); e.target.value = ''; } }); // Private or public at a glance, one click to change; Share for this folder // (or, at the top, the whole drive). const accessChip = h('button', { type: 'button', class: 'chip access-chip', title: 'Who can read this drive. Click to change.', onclick: async () => { await accessDialog(user, bucket); paintAccess(); } }, '…'); async function paintAccess() { try { let mode; if (isHosted()) { const rules = await api('GET', '/api/hosted/rules'); const pub = rules.find((r) => r.bucket === bucket && r.prefix === '' && r.type === 'public'); mode = pub ? pub.rights : 'private'; } else mode = (await api('GET', `${base}/policy`)).public; accessChip.className = `chip access-chip ${mode === 'private' ? 'private' : 'public'}`; put(accessChip, { private: isHosted() ? '🔒 Private · encrypted' : '🔒 Private', read: '🌐 Public', 'read-list': '🌐 Public + list', custom: '⚙ Custom' }[mode] || mode); } catch { put(accessChip, 'Access'); } } paintAccess(); const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : siaFolderLink(prefix)); const shareBtn = h('button', { type: 'button', class: 'chip', title: prefix ? 'Share this folder' : 'Share this drive', onclick: shareHere }, '🔗 Share'); put(head, h('div', { class: 'stack' }, h('div', { class: 'row crumb-row' }, crumbs, accessChip, shareBtn), accountLine()), h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn', onclick: newFolder }, 'New folder'), h('button', { class: 'btn primary', onclick: () => fileInput.click() }, 'Upload'), fileInput)); const uploads = h('div', { class: 'uploads' }); const card = h('div', { class: 'card dropzone' }, h('p', { class: 'muted' }, 'Loading…')); const bar = h('div', { class: 'view-bar' }); put(body, uploads, bar, card); let mode = viewMode('files', 'details'); let depth = 0; card.addEventListener('dragenter', (e) => { e.preventDefault(); depth++; card.classList.add('over'); }); card.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; card.classList.remove('over'); } }); card.addEventListener('dragover', (e) => e.preventDefault()); card.addEventListener('drop', (e) => { e.preventDefault(); depth = 0; card.classList.remove('over'); uploadFiles([...e.dataTransfer.files]); }); let pages = []; async function load(token) { try { const q = new URLSearchParams({ prefix }); if (token) q.set('token', token); const page = await api('GET', `${base}/objects?${q}`); pages = token ? [...pages, page] : [page]; draw(); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); } } function draw() { const folders = pages.flatMap((p) => p.folders); const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix); const last = pages[pages.length - 1]; put(bar, folders.length || objects.length ? viewSwitch('files', mode, (m) => { mode = m; draw(); }) : null); if (!folders.length && !objects.length) { put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This drive is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.'))); return; } const folderName = (f) => f.slice(prefix.length).replace(/\/$/, ''); const fileName = (o) => o.key.slice(prefix.length); const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() ? ['Share…', () => folderShareDialog(user, bucket, f)] : ['Public link…', () => siaFolderLink(f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]); const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : isHtml(o.key) ? 'Show as text' : 'Open', () => openViewer(o)], isHtml(o.key) && ['Open as page ↗', () => openPage(o.key)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Rename…', () => renameItem(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]); const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')); const stop = (e) => e.stopPropagation(); // Narrow screens: Download and Share as icons beside the ⋯ menu. const quickIcons = (o) => h('span', { class: 'narrow-only icon-row' }, iconButton('download', 'Download', () => downloadObject(o.key)), iconButton('share', 'Share link', () => share(o.key))); if (mode === 'details') { put(card, h('table', { class: 'files' }, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num hide-sm' }, 'Size'), h('th', { class: 'hide-sm' }, 'Type'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), h('tbody', {}, folders.map((f) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, h('td', { class: 'name' }, h('div', { class: 'cell-name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)))), h('td', { class: 'num muted hide-sm' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }), h('td', { class: 'actions', onclick: stop }, folderMenu(f)))), objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, h('td', { class: 'name' }, h('div', { class: 'cell-name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o), readableMark(o)))), h('td', { class: 'num hide-sm' }, fmtBytes(o.size)), h('td', { class: 'hide-sm muted' }, fileType(o.key)), h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)), h('td', { class: 'actions', onclick: stop }, h('span', { class: 'wide-only' }, h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), ' ', h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), ' '), quickIcons(o), fileMenu(o)))))), more); } else if (mode === 'list') { put(card, h('ul', { class: 'rows' }, folders.map((f) => h('li', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)), h('span', { class: 'meta' }), h('span', { onclick: stop }, folderMenu(f)))), objects.map((o) => h('li', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o), readableMark(o)), h('span', { class: 'meta hide-sm' }, fmtBytes(o.size)), h('span', { class: 'row-actions', onclick: stop }, quickIcons(o), fileMenu(o))))), more); } else { put(card, h('div', { class: 'tiles files' }, folders.map((f) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => go(f), onkeydown: onEnter(() => go(f)) }, h('div', { class: 'thumb' }, h('span', { class: 'big-icon', 'aria-hidden': 'true' }, '📁')), h('div', { class: 'tile-name' }, folderName(f)), h('div', { class: 'tile-more', onclick: stop }, folderMenu(f)))), objects.map((o) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) }, thumbFor(o), h('div', { class: 'tile-name', title: fileName(o) }, fileName(o), readableMark(o)), h('div', { class: 'small muted' }, fmtBytes(o.size)), h('div', { class: 'tile-more', onclick: stop }, fileMenu(o))))), more); } } // Icons view: pictures show themselves once scrolled into view; larger // ones (and everything else) keep a type icon. const thumbs = new IntersectionObserver((entries) => { for (const en of entries) { if (!en.isIntersecting) continue; thumbs.unobserve(en.target); const key = en.target.dataset.key; const path = `${base}/object?key=${encodeURIComponent(key)}`; (bridge ? bridge.invoke('view-url', { path }).then((r) => r.url) : Promise.resolve(`${path}&inline=1`)) .then((src) => { const img = h('img', { alt: '', src }); img.onload = () => put(en.target, img); }) .catch(() => {}); } }, { rootMargin: '200px' }); cleanup.push(() => thumbs.disconnect()); function thumbFor(o) { const el = h('div', { class: 'thumb' }, h('span', { class: 'big-icon', 'aria-hidden': 'true' }, fileIcon(o.key))); if (fileKind(o.key) === 'image' && o.size <= 8 * 1024 ** 2) { el.dataset.key = o.key; thumbs.observe(el); } return el; } // On Silent Mode, files the server can read: shared on purpose, or stored // before the drive moved there. let shareRules = []; if (isHosted()) api('GET', '/api/hosted/rules').then((r) => { shareRules = r; draw(); }).catch(() => {}); function readableMark(o) { if (!isHosted() || o.encrypted !== false) return null; const shared = shareRules.some((r) => r.bucket === bucket && (r.type === 'link' ? r.prefix === o.key : o.key.startsWith(r.prefix))); return shared ? h('span', { class: 'small muted', title: 'Shared, so stored readable on the server' }, ' 🌐') : h('span', { class: 'small', title: 'Stored readable on the server (from before your drives moved). Encrypt it under Access.' }, ' 🔓'); } function uploadFiles(files) { for (const file of files) { if (file.size > 5 * 1024 ** 3) { toast(`${file.name}: single uploads are limited to 5 GiB`, 'error'); continue; } const key = prefix + file.name; const bar = h('i'); const pct = h('span', { class: 'muted' }, '0%'); const row = h('div', { class: 'upload' }, h('span', {}, key), h('div', { class: 'bar' }, bar), pct); uploads.append(row); if (bridge) { // The whole file crosses the IPC bridge as one buffer, so keep it to // sizes a renderer can hold comfortably; S3 clients have no limit. if (file.size > 1024 ** 3) { row.remove(); toast(`${file.name}: uploads from the sidebar are limited to 1 GiB; use an S3 client for larger files`, 'error'); continue; } pct.textContent = 'uploading…'; bar.style.width = '100%'; bar.style.opacity = '.4'; file.arrayBuffer() .then((bytes) => bridge.invoke('upload', { path: `${base}/object?key=${encodeURIComponent(key)}`, bytes, type: file.type })) .then((r) => { if (r.status >= 200 && r.status < 300) { pct.textContent = 'done'; bar.style.opacity = '1'; setTimeout(() => row.remove(), 1500); load(); } else { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${r.data?.error || `HTTP ${r.status}`}`, 'error'); } }) .catch((e) => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${e.message}`, 'error'); }); continue; } const xhr = new XMLHttpRequest(); xhr.open('PUT', `${base}/object?key=${encodeURIComponent(key)}`); xhr.setRequestHeader('x-pithos', '1'); if (file.type) xhr.setRequestHeader('content-type', file.type); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const p = Math.round((e.loaded / e.total) * 100); bar.style.width = p + '%'; pct.textContent = p + '%'; } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { pct.textContent = 'done'; setTimeout(() => row.remove(), 1500); load(); } else { pct.textContent = 'failed'; pct.className = 'error'; let m = xhr.statusText; try { m = JSON.parse(xhr.responseText).error; } catch {} toast(`${file.name}: ${m}`, 'error'); } }; xhr.onerror = () => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: network error`, 'error'); }; xhr.send(file); } } // An empty "/" marker object keeps the folder visible while empty. async function newFolder() { const name = h('input', { type: 'text', required: true, pattern: '[^/]+', placeholder: 'photos' }); const v = await modal('New folder', h('label', { class: 'field' }, h('span', {}, 'Name'), name), [{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]); if (!v) return; try { await api('PUT', `${base}/folder?prefix=${encodeURIComponent(prefix + v + '/')}`); toast(`Created ${v}`); load(); } catch (e) { fail(e); } } function downloadObject(key) { const path = `${base}/object?key=${encodeURIComponent(key)}`; // The sidebar page has no server origin; Theseus's download manager // fetches the file through a one-time link instead. if (bridge) return bridge.invoke('download', { path }).then(() => toast('Download started')).catch(fail); const a = h('a', { href: path, download: '' }); document.body.append(a); a.click(); a.remove(); } // Viewer: images, audio, video and text open inside Pithos; anything else // offers a download. async function openViewer(o) { const name = o.key.slice(prefix.length); const kind = fileKind(o.key); if (kind === 'audio') return playFolder(o); const path = `${base}/object?key=${encodeURIComponent(o.key)}`; const src = async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`); let content; try { if (kind === 'image') content = h('img', { class: 'viewer-media', src: await src(), alt: name }); else if (kind === 'audio') content = h('audio', { class: 'viewer-audio', src: await src(), controls: true, autoplay: true }); else if (kind === 'video') content = h('video', { class: 'viewer-media', src: await src(), controls: true, autoplay: true }); else if (kind === 'text') { const t = await api('GET', `${base}/text?key=${encodeURIComponent(o.key)}`); content = h('div', {}, h('pre', { class: 'snippet viewer-text' }, t.text), t.truncated && h('p', { class: 'small muted' }, `Showing the first 512 KiB of ${fmtBytes(t.size)}.`)); } else content = h('div', { class: 'empty' }, h('p', {}, 'Pithos cannot show this kind of file.'), h('p', { class: 'small' }, 'Download it to open it on your computer.')); } catch (e) { return fail(e); } await modal(name, h('div', { class: 'viewer' }, content, h('p', { class: 'small muted', style: 'margin:10px 0 0' }, `${fmtBytes(o.size)} · ${fmtDate(o.modified)}`)), [isHtml(o.key) && { label: 'Open as page ↗', value: () => { openPage(o.key); return false; } }, { label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }].filter(Boolean)); } // An HTML file as a web page, in a new tab. The server sandboxes it, so its // scripts run without any access to Pithos; files beside it (CSS, images, // other pages) load as they would from a website. async function openPage(key) { try { const { path } = await api('POST', `${base}/page`, { key }); if (bridge) { await bridge.invoke('open-page', { path }); return; } const w = window.open(path, '_blank', 'noopener'); if (!w && !/^(desktop)$/.test(state.status?.host)) toast('Allow pop-ups for Pithos to open pages in a new tab', 'error'); } catch (e) { fail(e); } } // Music plays in the docked player, on through this folder's other tracks. function playFolder(o) { const where = `${bucket}/${prefix}`.replace(/\/$/, ''); const tracks = pages.flatMap((p) => p.objects).filter((x) => fileKind(x.key) === 'audio').map((x) => { const path = `${base}/object?key=${encodeURIComponent(x.key)}`; return { key: x.key, name: x.key.slice(prefix.length), where, src: async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`) }; }); player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key))); } // Rename a file or folder in place (a server-side copy: quick, no new upload). async function renameItem(key) { const folder = key.endsWith('/'); const current = folder ? key.slice(0, -1).split('/').pop() : key.split('/').pop(); const input = h('input', { type: 'text', required: true, value: current, pattern: '[^/]+', spellcheck: 'false' }); // Select the name without its extension, as file managers do. setTimeout(() => { if (!folder && current.includes('.')) input.setSelectionRange(0, current.lastIndexOf('.')); else input.select(); }, 30); const name = await modal(folder ? 'Rename folder' : 'Rename file', h('div', { class: 'stack' }, h('label', { class: 'field' }, h('span', {}, 'New name'), input), folder && h('p', { class: 'small muted', style: 'margin:0' }, 'Everything inside moves with it. This is quick: the files stay where they are on Sia.')), [{ label: 'Cancel', result: null }, { label: 'Rename', kind: 'primary', submit: true, value: () => input.value.trim() }]); if (!name || name === current) return; try { if (folder) { const r = await api('POST', `${base}/rename-prefix`, { from: key, name }); toast(`Renamed to ${name}${r.moved ? ` (${r.moved} item${r.moved === 1 ? '' : 's'})` : ''}`); } else { await api('POST', `${base}/rename-object`, { from: key, name }); toast(`Renamed to ${name}`); } load(); } catch (e) { fail(e); } } async function deleteObject(key) { if (!(await confirmModal('Delete object?', key))) return; try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); } } async function deleteFolder(p) { if (!(await confirmModal('Delete folder?', `Every object under ${p} will be deleted.`))) return; try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); } } // Share: the link is made as soon as the dialog opens and remade when the // validity or address changes. s3d listens only on this computer, so // without an address of your own the link opens only here; it says so. async function share(key) { if (isHosted()) return hostedShare(key); const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v, selected: v === '86400' }, l))); const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || ''; const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: 'https://files.example.com' }); const out = h('div', {}, h('p', { class: 'muted small' }, 'Making the link…')); const note = h('div'); let link = ''; const gen = async () => { const q = new URLSearchParams({ key, expires: expires.value }); const own = endpoint.value.trim(); try { localStorage.setItem('pithos.publicEndpoint', own); } catch {} if (own) q.set('endpoint', own); try { ({ url: link } = await api('GET', `${base}/presign?${q}`)); put(out, secretRow('Link', link)); } catch (e) { put(out, h('p', { class: 'error small' }, e.message)); } put(note, !own && localOnly() && h('div', { class: 'banner info small', style: 'margin:0' }, h('span', {}, 'This link opens only on this computer: s3d listens on ', h('code', {}, state.status?.config.apiAddress || '127.0.0.1'), ', which other devices cannot reach. To share with other people, s3d needs a public address (for example your own server in front of it).'))); }; expires.addEventListener('change', gen); endpoint.addEventListener('change', gen); gen(); await modal('Share', h('div', { class: 'stack' }, siaLinkBlock({ key }), h('details', { class: 'more-opts', open: !!savedEndpoint }, h('summary', {}, 'Link for this computer, or your own server'), h('div', { class: 'stack', style: 'margin-top:8px' }, h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link straight to s3d that works without a key until it expires.'), h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires), out, note, h('label', { class: 'field' }, h('span', {}, 'Your own public address'), endpoint, h('small', {}, 'The address where other people reach this s3d, e.g. a reverse proxy on your server. The link is remade when you change it.'))))), [{ label: 'Done', kind: 'primary', submit: true, result: true }]); } // A public link anyone can open, at navigate.st/sia: the file is read from // your Sia account through Silent Mode, so it works with this computer off. function siaLinkBlock({ key, prefix }) { const folder = prefix != null && key == null; const days = h('select', {}, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l))); const out = h('div'); const btn = h('button', { type: 'button', class: 'btn primary', onclick: async () => { btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Making the link…'); try { const r = await api('POST', `${base}/sia-link`, { key, prefix, days: Number(days.value) }); put(out, secretRow('Public link', r.url), h('p', { class: 'small muted', style: 'margin:0' }, `Works until ${new Date(r.until * 1000).toLocaleDateString()}. Switch it off any time in Settings › Public links.`)); put(btn, 'Make another link'); } catch (e) { put(out, h('p', { class: 'error small', style: 'margin:0' }, e.message)); put(btn, 'Try again'); } btn.disabled = false; } }, 'Create public link'); return h('div', { class: 'stack' }, h('h3', { style: 'margin:0' }, 'Public link'), h('p', { class: 'small muted', style: 'margin:0' }, folder ? `A page with the files ${prefix ? 'in this folder' : 'at the top of this drive'} that anyone can open, on any device, even with this computer off. Files in sub-folders are not included.` : 'Anyone with the link can open this file on any device, even with this computer off. It is read from your Sia account through navigate.st; only this file is shared.'), h('div', { class: 'row' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn), out); } async function siaFolderLink(prefix) { await modal(`Public link for ${prefix ? prefix.slice(0, -1).split('/').pop() : bucket}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]); } // On Silent Mode a link is a rule on the server, and the file is stored // readable while it works. async function hostedShare(key) { const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days'], ['2592000', '30 days (max)']].map(([v, l]) => h('option', { value: v }, l))); const out = h('div'); const gen = async () => { put(out, h('span', { class: 'spinner' })); try { const { url } = await api('GET', `${base}/presign?${new URLSearchParams({ key, expires: expires.value })}`); put(out, secretRow('Link', url)); } catch (e) { put(out); fail(e); } return false; }; await modal('Share link', h('div', { class: 'stack' }, h('p', { class: 'small muted', style: 'margin:0' }, 'Anyone with the link can open this file until it ends, even with this computer off. While the link works the file is stored readable on the server; afterwards Pithos encrypts it again.'), h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires), out), [{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]); } load(); } // Settings › Public links: links made at navigate.st/sia, each can be switched off. async function publicLinksCard(card) { let list = []; try { list = await api('GET', '/api/sia-links'); } catch { card.remove(); return; } if (!list.length) { card.remove(); return; } put(card, h('h2', {}, 'Public links'), h('p', { class: 'small muted' }, 'Links anyone can open at navigate.st/sia. Switching one off stops it at once.'), h('table', {}, h('tbody', {}, list.map((l) => h('tr', {}, h('td', {}, h('a', { href: l.url, target: '_blank', rel: 'noopener' }, l.prefix ? `📁 ${l.bucket}/${l.prefix}` : `${l.bucket}/${l.key}`), h('div', { class: 'small muted' }, `until ${new Date(l.until * 1000).toLocaleDateString()}${l.prefix ? ` · ${l.files} files` : ''}`)), h('td', { class: 'actions' }, h('button', { class: 'btn small', onclick: () => copy(l.url, 'Link copied') }, 'Copy'), ' ', h('button', { class: 'btn small danger', onclick: async () => { if (!(await confirmModal('Switch this link off?', 'Anyone who has it will no longer be able to open it.', 'Switch off'))) return; try { await api('DELETE', `/api/sia-links/${encodeURIComponent(l.id)}`); toast('Link switched off'); publicLinksCard(card); } catch (e) { fail(e); } } }, 'Switch off'))))))); } // ---------------------------------------------------------------- on Silent Mode const isHosted = () => state.status?.mode === 'hosted'; const hostedUrl = () => state.status?.hosted?.url || 'https://s3.silentmode.st'; // Where S3 clients reach this s3d: the server, or the local address. const s3Endpoint = () => (isHosted() ? hostedUrl() : `http://${state.status?.config.apiAddress}`); // Long jobs (moving drives, converting folders) report through events. let progressSink = null; function progressLine(p) { if (p.phase === 'flush') return 'Sending leftovers to Sia…'; if (p.phase === 'stop') return 'Stopping s3d…'; if (p.phase === 'upload') return `Sending the s3d folder… ${fmtBytes(p.sent)} of ${fmtBytes(p.total)}`; if (p.phase === 'download') return `Bringing the s3d folder back… ${fmtBytes(p.got)} of ${fmtBytes(p.total)}`; if (p.phase === 'start') return 'Starting s3d…'; if (p.phase === 'done') return 'Done.'; if (p.phase === 'failed') return `Failed: ${p.error}`; if (p.total != null) return `Converting files… ${p.done} of ${p.total}`; return ''; } function hostedCard(card) { let info = null; async function load() { try { info = await api('GET', '/api/hosted'); } catch (e) { put(card, h('h2', {}, 'Where your drives live'), h('p', { class: 'error' }, e.message)); return; } draw(); } function draw() { const me = info.me; const intro = h('p', { class: 'small muted' }, 'On this computer, s3d runs here and needs this computer on. On Silent Mode, the same s3d runs on Silent Mode\'s server with your own Sia account: apps, S3 clients and share links work with this computer off, and you can reach your drives from other devices. ', 'Your recovery phrase never leaves this computer, and Pithos encrypts file names and contents before they leave, so the server cannot read them. Only folders you make public or share stay readable.'); const parts = [h('div', { class: 'row spread' }, h('h2', {}, 'Where your drives live'), h('span', { class: `badge ${info.mode === 'hosted' ? 'ok' : ''}` }, info.mode === 'hosted' ? 'On Silent Mode' : 'On this computer')), intro]; if (!info.signedIn) { const url = h('input', { type: 'url', value: info.url, autocomplete: 'off' }); parts.push( info.error && h('p', { class: 'small error' }, info.error), h('label', { class: 'field' }, h('span', {}, 'Silent Mode server'), url), h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: () => signIn(url.value.trim()) }, 'Sign in to Silent Mode')), h('p', { class: 'small muted' }, info.secrets === 'vault' ? 'You sign in with a key from your Theseus vault: the same vault on another computer reaches the same drives.' : 'You sign in with a key kept in a file beside s3d.yml. Back it up below: without it, encrypted files cannot be read.')); } else { parts.push(h('dl', { class: 'kv' }, h('dt', {}, 'Server'), h('dd', {}, h('code', {}, info.url)), h('dt', {}, 'Account'), h('dd', {}, h('code', {}, info.id), me?.gate?.name ? ` · ${me.gate.name}` : ''), me && [h('dt', {}, 'Downloads'), h('dd', {}, `${fmtBytes(me.usage?.down || 0)} this month${me.downloadCapBytes ? ` of ${fmtBytes(me.downloadCapBytes)}` : ''}`)], me && [h('dt', {}, 'Drives there'), h('dd', {}, { empty: 'none yet', active: 'yes', 'moving-out': 'being moved back' }[me.state] || me.state)])); const status = h('p', { class: 'small muted' }); progressSink = (p) => put(status, progressLine(p)); const buttons = []; if (info.mode === 'local' && me?.state === 'empty') { buttons.push(h('button', { class: 'btn primary', onclick: moveUp }, 'Move my drives to Silent Mode')); } if (info.mode === 'local' && me?.state === 'active') { buttons.push(h('button', { class: 'btn primary', onclick: () => setMode('hosted') }, 'Use my drives on Silent Mode')); } if (info.mode === 'hosted') { buttons.push(h('button', { class: 'btn', onclick: moveDown }, 'Move my drives back to this computer')); } buttons.push(h('button', { class: 'btn', onclick: signOut }, 'Sign out')); parts.push(h('div', { class: 'row' }, buttons), status); if (info.mode === 'local' && me?.state === 'empty') { parts.push(h('p', { class: 'small muted' }, 'Moving sends s3d\'s database (your drives, users and keys) and any files still waiting for Sia. Files already on Sia are not uploaded again. This computer keeps a copy of the old folder, set aside.')); } } if (info.secrets === 'file') { parts.push(h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn small', onclick: exportKey }, 'Back up my key'), !info.signedIn && h('button', { class: 'btn small', onclick: importKey }, 'Restore a key'))); } put(card, ...parts); } async function signIn(url, signup) { try { const r = await api('POST', '/api/hosted/signin', { url, signup }); if (r.needsSignup) return signUp(url, r); toast('Signed in to Silent Mode'); load(); } catch (e) { fail(e); } } // A slot on the server costs memory and traffic, so opening one needs a // BNS name you own, a small deposit, or an invite. async function signUp(url, r) { const tabs = []; if (r.gate.bns) tabs.push('bns'); if (r.gate.deposit) tabs.push('deposit'); if (r.gate.invite) tabs.push('invite'); if (!tabs.length) return toast('This server is not taking new accounts', 'error'); let kind = tabs[0]; const name = h('input', { type: 'text', placeholder: 'yourname.bch', autocomplete: 'off' }); const sig = h('textarea', { rows: 3, placeholder: 'Signature (base64)' }); const txid = h('input', { type: 'text', placeholder: 'Transaction id', autocomplete: 'off' }); const code = h('input', { type: 'text', placeholder: 'Invite code', autocomplete: 'off' }); const panes = { bns: h('div', { class: 'stack' }, h('p', { class: 'small muted', style: 'margin:0' }, 'Sign this message with the wallet that owns the name (Electron Cash: Tools › Sign/verify message; Cashonize and Paytaca have the same):'), secretRow('Message', r.bnsMessage), h('label', { class: 'field' }, h('span', {}, 'BNS name'), name), h('label', { class: 'field' }, h('span', {}, 'Signature'), sig)), deposit: r.gate.deposit && h('div', { class: 'stack' }, h('p', { class: 'small muted', style: 'margin:0' }, `Send at least ${r.gate.deposit.minSats.toLocaleString()} sats to the address below, with this memo (OP_RETURN). It is refundable after fair use.`), secretRow('Address', r.gate.deposit.address), secretRow('Memo', r.depositMemo), h('label', { class: 'field' }, h('span', {}, 'Transaction id'), txid, h('small', {}, 'Wait for one confirmation (about ten minutes).'))), invite: h('div', { class: 'stack' }, h('label', { class: 'field' }, h('span', {}, 'Invite code'), code)), }; const holder = h('div'); const label = { bns: 'BNS name', deposit: 'Deposit', invite: 'Invite' }; const switcher = h('div', { class: 'row' }, tabs.map((t) => h('label', { class: 'check' }, h('input', { type: 'radio', name: 'gate', checked: t === kind, onchange: () => { kind = t; put(holder, panes[t]); } }), label[t]))); put(holder, panes[kind]); const proof = await modal('Open your space on Silent Mode', h('div', { class: 'stack' }, h('p', { class: 'small muted', style: 'margin:0' }, 'Each person gets one slot. Choose how to show you are one person:'), tabs.length > 1 && switcher, holder), [{ label: 'Cancel', result: null }, { label: 'Open my space', kind: 'primary', submit: true, value: () => ( kind === 'bns' ? { type: 'bns', name: name.value.trim(), signature: sig.value.trim() } : kind === 'deposit' ? { type: 'deposit', txid: txid.value.trim() } : { type: 'invite', code: code.value.trim() }) }]); if (proof) signIn(url, proof); } async function moveUp(ev, force = false) { if (!force && !(await confirmModal('Move your drives to Silent Mode?', 'Pithos sends leftovers to Sia, stops s3d here and moves its folder to the server. From then on your drives run on Silent Mode, and new files are encrypted before they leave this computer.', 'Move', 'primary'))) return; try { const r = await api('POST', '/api/hosted/move-up', { force }); toast(`Your drives are on Silent Mode (${r.users.length} user${r.users.length === 1 ? '' : 's'})`); await refreshStatus(); route(); } catch (e) { if (!force && /move anyway/.test(e.message)) { if (await confirmModal('Some uploads have not reached Sia', `${e.message}`, 'Move anyway', 'primary')) return moveUp(ev, true); return; } fail(e); load(); } } async function moveDown(ev, force = false) { if (!force && !(await confirmModal('Move your drives back here?', 'Silent Mode sends leftovers to Sia, stops its s3d and hands the folder back. s3d then runs on this computer again; files encrypted on Silent Mode stay encrypted and Pithos keeps reading them. Public folders, share links and shared keys stop working.', 'Move back', 'primary'))) return; try { await api('POST', '/api/hosted/move-down', { force }); toast('Your drives are back on this computer'); await refreshStatus(); route(); } catch (e) { if (!force && /move anyway/.test(e.message)) { if (await confirmModal('Some uploads have not reached Sia', e.message, 'Move anyway', 'primary')) return moveDown(ev, true); return; } fail(e); load(); } } async function setMode(mode) { try { await api('POST', '/api/hosted/mode', { mode }); await refreshStatus(); route(); } catch (e) { fail(e); } } async function signOut() { if (info.mode === 'hosted' && !(await confirmModal('Sign out?', 'Your drives stay on Silent Mode and keep working for apps and links. This computer shows its own s3d until you sign in again.', 'Sign out', 'primary'))) return; try { await api('POST', '/api/hosted/signout'); await refreshStatus(); route(); } catch (e) { fail(e); } } async function exportKey() { try { const k = await api('GET', '/api/hosted/secret'); const text = JSON.stringify(k, null, 2); if (bridge) { await bridge.invoke('save-file', { name: 'pithos-hosted-key.json', text }); return; } const a = h('a', { href: URL.createObjectURL(new Blob([text], { type: 'application/json' })), download: 'pithos-hosted-key.json' }); document.body.append(a); a.click(); a.remove(); } catch (e) { fail(e); } } async function importKey() { const ta = h('textarea', { rows: 6, placeholder: '{ "v": 1, "secret": "…" }' }); const v = await modal('Restore a key', h('div', { class: 'stack' }, h('p', { class: 'small muted', style: 'margin:0' }, 'Paste the contents of a pithos-hosted-key.json backup. It replaces the key this computer uses.'), h('div', { class: 'banner warn' }, 'Files encrypted with the current key can only be read with it. Back it up first if anything was encrypted with it.'), ta), [{ label: 'Cancel', result: null }, { label: 'Restore', kind: 'primary', submit: true, value: () => ta.value }]); if (!v) return; try { await api('POST', '/api/hosted/secret', JSON.parse(v)); toast('Key restored'); load(); } catch (e) { fail(e); } } load(); } // Folder and drive sharing on Silent Mode: public, or a key for someone. async function folderShareDialog(user, bucket, folder) { const where = folder ? `${bucket}/${folder}` : bucket; const list = h('div', { class: 'stack' }); async function loadRules() { let rules = []; try { rules = (await api('GET', '/api/hosted/rules')).filter((r) => r.bucket === bucket && r.prefix === folder && r.type !== 'link'); } catch (e) { return put(list, h('p', { class: 'error' }, e.message)); } if (!rules.length) return put(list, h('p', { class: 'small muted', style: 'margin:0' }, 'Not shared: only you can read it, and it is stored encrypted.')); put(list, rules.map((r) => h('div', { class: 'row spread' }, h('span', {}, r.type === 'public' ? (r.rights === 'read-list' ? '🌐 Public, with a file list' : '🌐 Public') : `🔑 Key ${r.accessKeyId} · ${{ read: 'read', add: 'add files', edit: 'edit' }[r.rights]}${r.label ? ` · ${r.label}` : ''}${r.expires ? ` · until ${fmtDate(r.expires)}` : ''}`), h('button', { class: 'btn small danger', type: 'button', onclick: () => removeRule(r) }, r.type === 'public' ? 'Make private' : 'Revoke')))); } async function removeRule(r) { try { await api('DELETE', `/api/hosted/rules?id=${encodeURIComponent(r.id)}&user=${encodeURIComponent(user)}`); toast('Stopped sharing; files are encrypted again'); loadRules(); } catch (e) { fail(e); } } const kind = h('select', {}, h('option', { value: 'public:read' }, 'Anyone: read files'), h('option', { value: 'public:read-list' }, 'Anyone: read files and see the list'), h('option', { value: 'grant:read' }, 'Someone with a key: read'), h('option', { value: 'grant:add' }, 'Someone with a key: add new files'), h('option', { value: 'grant:edit' }, 'Someone with a key: add, change and delete')); const label = h('input', { type: 'text', placeholder: 'Who it is for (optional)' }); const until = h('select', {}, [['', 'No expiry'], ['86400', '1 day'], ['604800', '7 days'], ['2592000', '30 days']].map(([v, l]) => h('option', { value: v }, l))); const out = h('div', { class: 'stack' }); const progress = h('p', { class: 'small muted', style: 'margin:0' }); const add = async () => { const [type, rights] = kind.value.split(':'); progressSink = (p) => put(progress, progressLine(p)); try { const r = await api('POST', '/api/hosted/rules', { user, type, bucket, prefix: folder, rights, label: label.value.trim(), expires: until.value ? Date.now() + Number(until.value) * 1000 : undefined, }); put(progress, ''); if (r.type === 'public') { const who = state.status.hosted.name || state.status.hosted.id; const at = `https://navigate.st/s3/${encodeURIComponent(who)}/${encodeURIComponent(bucket)}/${folder.split('/').map(encodeURIComponent).join('/')}`; put(out, secretRow(r.rights === 'read-list' ? 'Public address (a page anyone can open)' : 'Public address: add a file name', at), r.rights !== 'read-list' && h('p', { class: 'small muted', style: 'margin:0' }, 'For example ', h('code', {}, `${at}index.html`), '.')); } else put(out, h('p', { class: 'small muted', style: 'margin:0' }, 'Give these to the person. The secret is shown here, and in this list, only to you.'), secretRow('S3 endpoint', hostedUrl()), secretRow('Access key', r.accessKeyId), secretRow('Secret key', r.secretKey), secretRow('Bucket and folder', `${bucket}/${folder}`)); loadRules(); } catch (e) { put(progress, ''); fail(e); } return false; }; loadRules(); await modal(`Share ${where}`, h('div', { class: 'stack' }, list, h('label', { class: 'field' }, h('span', {}, 'Share with'), kind), h('label', { class: 'field' }, h('span', {}, 'Label'), label), h('label', { class: 'field' }, h('span', {}, 'Ends'), until), h('p', { class: 'small muted', style: 'margin:0' }, 'Shared files are stored readable on the server, because the people you share with have no decryption key. Stop sharing and Pithos encrypts them again.'), progress, out), [{ label: 'Close', result: true }, { label: 'Share', kind: 'primary', submit: true, value: add }]); } // A drive on Silent Mode: everything shared in it, in one place. async function hostedAccessDialog(user, bucket) { const list = h('div', { class: 'stack' }); const progress = h('p', { class: 'small muted', style: 'margin:0' }); progressSink = (p) => put(progress, progressLine(p)); async function load() { let rules = []; try { rules = (await api('GET', '/api/hosted/rules')).filter((r) => r.bucket === bucket); } catch (e) { return put(list, h('p', { class: 'error' }, e.message)); } if (!rules.length) return put(list, h('p', { class: 'small muted', style: 'margin:0' }, 'Nothing in this drive is shared. Everything is encrypted, and only you can read it.')); const what = (r) => (r.type === 'public' ? (r.rights === 'read-list' ? '🌐 public + list' : '🌐 public') : r.type === 'link' ? `🔗 link until ${fmtDate(r.expires)}` : `🔑 ${r.accessKeyId} · ${{ read: 'read', add: 'add files', edit: 'edit' }[r.rights]}`); put(list, h('table', {}, h('tbody', {}, rules.map((r) => h('tr', {}, h('td', {}, h('code', {}, `/${r.prefix}`)), h('td', {}, what(r), r.label ? h('span', { class: 'small muted' }, ` · ${r.label}`) : ''), h('td', { class: 'actions' }, h('button', { class: 'btn small danger', type: 'button', onclick: async () => { try { await api('DELETE', `/api/hosted/rules?id=${encodeURIComponent(r.id)}&user=${encodeURIComponent(user)}`); toast('Stopped sharing; files are encrypted again'); load(); } catch (e) { fail(e); } } }, 'Stop'))))))); } load(); const encryptOld = h('button', { class: 'btn small', type: 'button', onclick: async () => { encryptOld.disabled = true; try { const r = await api('POST', '/api/hosted/encrypt', { user, bucket }); toast(r.converted ? `Encrypted ${r.converted} file${r.converted === 1 ? '' : 's'}` : 'Every private file was already encrypted'); } catch (e) { fail(e); } encryptOld.disabled = false; } }, 'Encrypt files stored readable'); await modal(`Access for ${bucket}`, h('div', { class: 'stack' }, list, progress, h('div', { class: 'row' }, encryptOld, h('span', { class: 'small muted' }, 'Files from before your drives moved here are stored as they were. This encrypts every one that is not shared.')), h('p', { class: 'small muted', style: 'margin:0' }, 'Share a folder from its row in the drive, or the whole drive here. Public files are at ', h('code', {}, `${hostedUrl()}/p/${state.status?.hosted?.id}/${bucket}/`), '.')), [{ label: 'Close', result: true }, { label: 'Share the whole drive…', kind: 'primary', submit: true, value: () => { folderShareDialog(user, bucket, ''); return true; } }]); } // ---------------------------------------------------------------- music player // A player docked at the bottom of Pithos: it lives outside the page, so it // keeps playing while you browse other folders, drives or pages, and plays // on through the folder it was started from. let musicPlayer = null; function player() { if (musicPlayer) return musicPlayer; const audio = new Audio(); audio.preload = 'metadata'; let queue = []; let at = -1; let seeking = false; let retried = false; const mmss = (t) => (isFinite(t) ? `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}` : '0:00'); const title = h('div', { class: 'pl-title' }); const where = h('div', { class: 'pl-where small muted' }); const cur = h('span', { class: 'pl-time' }, '0:00'); const dur = h('span', { class: 'pl-time' }, '0:00'); const seek = h('input', { type: 'range', min: '0', max: '0', step: '1', value: '0', 'aria-label': 'Position', oninput: () => { seeking = true; cur.textContent = mmss(+seek.value); }, onchange: () => { audio.currentTime = +seek.value; seeking = false; } }); const playBtn = h('button', { type: 'button', class: 'pl-btn pl-main', 'aria-label': 'Play', onclick: () => (audio.paused ? audio.play().catch(() => {}) : audio.pause()) }, '▶'); const prevBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Previous', title: 'Previous', onclick: () => prev() }, '⏮'); const nextBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Next', title: 'Next', onclick: () => next() }, '⏭'); const closeBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Stop and close the player', title: 'Close', onclick: () => close() }, '✕'); // Volume: a slider beside a mute button; on narrow screens the button // opens the slider in a small pop-up above it. Remembered between visits. const saved = (() => { try { return JSON.parse(localStorage.getItem('pithos.volume') || 'null'); } catch { return null; } })(); audio.volume = saved && isFinite(saved.v) ? Math.min(1, Math.max(0, saved.v)) : 1; audio.muted = !!saved?.m; const volIcon = () => (audio.muted || audio.volume === 0 ? '🔇' : audio.volume < 0.5 ? '🔉' : '🔊'); const vol = h('input', { type: 'range', min: '0', max: '1', step: '0.05', value: String(audio.volume), class: 'pl-vol', 'aria-label': 'Volume', oninput: () => { audio.volume = +vol.value; if (audio.volume > 0) audio.muted = false; } }); const volBtn = h('button', { type: 'button', class: 'pl-btn pl-volbtn', title: 'Mute', 'aria-label': 'Mute', onclick: () => { if (matchMedia('(max-width: 760px)').matches) { volBox.classList.toggle('open'); return; } audio.muted = !audio.muted; } }, volIcon()); const muteInPop = h('button', { type: 'button', class: 'pl-btn', title: 'Mute', 'aria-label': 'Mute', onclick: () => { audio.muted = !audio.muted; } }, '🔇'); const volBox = h('div', { class: 'pl-volume' }, volBtn, h('div', { class: 'pl-volpop' }, muteInPop, vol)); audio.addEventListener('volumechange', () => { volBtn.textContent = volIcon(); const m = audio.muted ? 'Unmute' : 'Mute'; volBtn.title = muteInPop.title = m; muteInPop.setAttribute('aria-label', m); muteInPop.textContent = audio.muted ? '🔈' : '🔇'; vol.value = String(audio.muted ? 0 : audio.volume); try { localStorage.setItem('pithos.volume', JSON.stringify({ v: audio.volume, m: audio.muted })); } catch {} }); document.addEventListener('mousedown', (e) => { if (!volBox.contains(e.target)) volBox.classList.remove('open'); }); const el = h('div', { class: 'player', role: 'region', 'aria-label': 'Music player', hidden: true }, h('div', { class: 'pl-controls' }, prevBtn, playBtn, nextBtn), h('div', { class: 'pl-info' }, h('div', { class: 'pl-head' }, title, where), h('div', { class: 'pl-seek' }, cur, seek, dur)), volBox, closeBtn); document.body.append(el); const showPlaying = () => { const p = !audio.paused; playBtn.textContent = p ? '⏸' : '▶'; playBtn.setAttribute('aria-label', p ? 'Pause' : 'Play'); if (navigator.mediaSession) navigator.mediaSession.playbackState = p ? 'playing' : 'paused'; }; audio.addEventListener('play', showPlaying); audio.addEventListener('pause', showPlaying); audio.addEventListener('playing', () => { retried = false; }); audio.addEventListener('loadedmetadata', () => { seek.max = String(Math.floor(audio.duration) || 0); dur.textContent = mmss(audio.duration); }); audio.addEventListener('timeupdate', () => { if (!seeking) { seek.value = String(Math.floor(audio.currentTime)); cur.textContent = mmss(audio.currentTime); } }); audio.addEventListener('ended', () => { if (at < queue.length - 1) next(); }); // Theseus view links last 10 minutes: when one runs out mid-track, fetch a // fresh link and carry on from the same spot. audio.addEventListener('error', () => { if (!queue[at] || retried) return toast(`Cannot play ${queue[at]?.name || 'this file'}`, 'error'); retried = true; load(at, audio.currentTime, true); }); async function load(i, from = 0, play = true) { at = i; const t = queue[i]; title.textContent = t.name; title.title = t.name; where.textContent = t.where; prevBtn.disabled = i <= 0; nextBtn.disabled = i >= queue.length - 1; let src; try { src = await t.src(); } catch (e) { return fail(e); } if (at !== i) return; // another track was chosen meanwhile audio.src = src; if (from) audio.addEventListener('loadedmetadata', () => { audio.currentTime = from; }, { once: true }); if (play) audio.play().catch(() => {}); if (navigator.mediaSession && window.MediaMetadata) navigator.mediaSession.metadata = new MediaMetadata({ title: t.name.replace(/\.[^.]+$/, ''), album: t.where, artist: 'Pithos' }); } const next = () => { if (at < queue.length - 1) load(at + 1); }; const prev = () => { if (audio.currentTime > 3 || at <= 0) audio.currentTime = 0; else load(at - 1); }; function close() { audio.pause(); audio.removeAttribute('src'); audio.load(); queue = []; at = -1; el.hidden = true; document.body.classList.remove('has-player'); } if (navigator.mediaSession) { const ms = navigator.mediaSession; try { ms.setActionHandler('play', () => audio.play().catch(() => {})); ms.setActionHandler('pause', () => audio.pause()); ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next); ms.setActionHandler('seekto', (d) => { audio.currentTime = d.seekTime; }); } catch {} } musicPlayer = { // tracks: [{ key, name, where, src: async () => url }] play(tracks, index) { queue = tracks; el.hidden = false; document.body.classList.add('has-player'); load(index); }, get current() { return queue[at]?.key || null; }, }; return musicPlayer; } // ---------------------------------------------------------------- settings function settings(main) { const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…')); const flushCard = h('div', { class: 'card', style: 'margin-top:16px' }); const whereCard = h('div', { class: 'card', style: 'margin-bottom:16px' }); main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Where your drives live, and s3d.yml for s3d on this computer. s3d reads it at start, so a restart applies the changes.'))), whereCard, card, flushCard); hostedCard(whereCard); const linksCard = h('div', { class: 'card', style: 'margin-top:16px' }); main.append(linksCard); publicLinksCard(linksCard); // s3d.yml and the leftovers setting are for s3d on this computer; on Silent // Mode the server manages both (leftovers always go to Sia). if (isHosted()) { put(card, h('h2', {}, 's3d on this computer'), h('p', { class: 'small muted' }, 'Your drives run on Silent Mode, so s3d on this computer is not used. Its settings come back when you move your drives back here.')); flushCard.remove(); return; } // Kept by Pithos itself (pithos-prefs.json), applies at once, no restart. (async () => { let af; try { af = await api('GET', '/api/autoflush'); } catch (e) { put(flushCard, h('p', { class: 'error' }, e.message)); return; } const on = h('input', { type: 'checkbox', checked: af.enabled }); const minutes = h('input', { type: 'number', min: 1, max: 1440, value: af.minutes, style: 'width:6em', disabled: !af.enabled }); on.addEventListener('change', () => { minutes.disabled = !on.checked; }); put(flushCard, h('h2', {}, 'Leftover uploads'), h('p', { class: 'small muted' }, 's3d packs uploads into blocks of about 40 MB before sending them to Sia, so a few small files can wait in the local buffer indefinitely. Pithos can upload them for you once uploading has stopped.'), h('label', { class: 'check' }, on, 'Upload leftovers automatically'), h('label', { class: 'field' }, h('span', {}, 'after this many minutes with no new uploads'), minutes, h('small', {}, 'Partly filled blocks use a little more Sia space than full ones. Pithos must be open for this to run.')), h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: saveFlush }, 'Save'))); async function saveFlush() { try { await api('PUT', '/api/autoflush', { enabled: on.checked, minutes: Number(minutes.value) || 10 }); toast(on.checked ? 'Leftovers will upload automatically' : 'Automatic upload is off'); } catch (e) { fail(e); } } })(); const fields = [ ['Network', [ ['apiAddress', 'S3 API address', 'text', 'host:port the S3 API listens on. Use 0.0.0.0:8000 to serve other machines.'], ['apiHTTPSAddress', 'S3 HTTPS address', 'text', 'Optional. s3d serves a self-signed certificate here.'], ['adminAddress', 'Admin API address', 'text', 'Pithos reads stats from here. Keep it on loopback.'], ['adminPassword', 'Admin API password', 'password', 'Generated on first start.'], ['s3.hostBases', 'Virtual-host bases', 'list', 'Comma-separated domains for bucket.domain style addressing.'], ]], ['Storage', [ ['directory', 'Data directory', 'text', 'Database and upload buffer. Changing it points s3d at a different store.'], ['sia.diskUsageLimit', 'Buffer disk limit (bytes)', 'number', 'Upper bound for objects waiting to upload.'], ['sia.uploadThreads', 'Upload threads', 'number', ''], ['sia.dataShards', 'Data shards', 'number', 'Erasure coding: data shards (default 10). Applies to new uploads only.'], ['sia.parityShards', 'Parity shards', 'number', 'Erasure coding: parity shards (default 20).'], ]], ['Logging', [ ['log.stdout.level', 'Console log level', 'select:debug,info,warn,error', ''], ['log.file.enabled', 'Write a log file', 'bool', ''], ['log.file.level', 'File log level', 'select:debug,info,warn,error', ''], ]], ]; (async () => { let cfg; try { cfg = await api('GET', '/api/config'); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); return; } const inputs = {}; const sections = fields.map(([title, list]) => h('div', { class: 'stack', style: 'margin-bottom:22px' }, h('h2', {}, title), h('div', { class: 'form-grid' }, list.map(([key, label, type, help]) => { const v = cfg.values[key]; let input; if (type === 'bool') input = h('input', { type: 'checkbox', checked: !!v }); else if (type.startsWith('select:')) input = h('select', {}, h('option', { value: '' }, '(default)'), type.slice(7).split(',').map((o) => h('option', { value: o, selected: v === o }, o))); else input = h('input', { type: type === 'list' ? 'text' : type, value: Array.isArray(v) ? v.join(', ') : (v ?? ''), placeholder: '(default)', autocomplete: 'off' }); inputs[key] = { input, type, orig: v }; return type === 'bool' ? h('label', { class: 'check' }, input, label) : h('label', { class: 'field' }, h('span', {}, label), input, help && h('small', {}, help)); })))); put(card, h('p', { class: 'small muted' }, 'File: ', h('code', {}, cfg.file)), ...sections, h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: save }, 'Save'))); async function save() { const patch = {}; for (const [key, { input, type, orig }] of Object.entries(inputs)) { let v; if (type === 'bool') v = input.checked; else if (type === 'number') v = input.value === '' ? null : Number(input.value); else if (type === 'list') v = input.value.split(',').map((s) => s.trim()).filter(Boolean); else v = input.value.trim() || null; const same = JSON.stringify(v ?? null) === JSON.stringify(orig ?? null) || (type === 'list' && !v.length && orig == null) || (type === 'bool' && !v && orig == null); if (!same) patch[key] = v; } if (!Object.keys(patch).length) return toast('Nothing changed'); try { const r = await api('PUT', '/api/config', patch); for (const k of Object.keys(patch)) inputs[k].orig = patch[k]; if (r.restartNeeded) { if (await confirmModal('Restart s3d?', 'The new settings apply after a restart.', 'Restart now', 'primary')) await daemonAction('restart'); } else toast('Saved'); refreshStatus(); } catch (e) { fail(e); } } })(); } // ---------------------------------------------------------------- logs function logs(main) { const box = h('div', { class: 'logs' }); let follow = true; box.addEventListener('scroll', () => { follow = box.scrollTop + box.clientHeight >= box.scrollHeight - 20; }); main.append( h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Logs'), h('p', { class: 'muted' }, 'Output from the s3d process Pithos started.')), h('button', { class: 'btn', onclick: () => copy(state.logs.map((l) => l.line).join('\n'), 'Logs copied') }, 'Copy all')), box); const line = (l) => h('div', { class: l.stream }, l.line); box.append(...state.logs.map(line)); if (!state.logs.length) box.append(h('div', { class: 'sys' }, 'No output yet.')); box.scrollTop = box.scrollHeight; logSink = (l) => { box.append(line(l)); while (box.childElementCount > 2000) box.firstChild.remove(); if (follow) box.scrollTop = box.scrollHeight; }; } // ---------------------------------------------------------------- boot // Theseus sidebar: Pithos fits the narrow panel; for more room it opens as a // regular tab, which steps back like any tab when another one is chosen. The // panel closes so only one Pithos shows. function addSidebarControls() { // The panel may have been widened by an earlier Pithos; start narrow. bridge.sidebar?.isMax?.().then((max) => { if (max) bridge.sidebar.restore?.() ?? bridge.sidebar.toggleMax?.(); }).catch(() => {}); const row = h('div', { class: 'host-controls' }, h('button', { class: 'btn small', title: 'Open Pithos in a tab and close this panel', onclick: async () => { try { await bridge.invoke('open-in-tab', { path: hashToPath(location.hash || '#/overview') }); await bridge.sidebar?.close?.(); } catch (e) { fail(e); } } }, '⤢ Full page')); $('.sidebar').insertBefore(row, $('#daemon-pill')); addMenuAction('⤢ Open as full page', () => row.querySelector('button').click()); addBarButton('⤢', 'Open as full page', () => row.querySelector('button').click()); } // A one-click icon in the compact top bar, beside ☰ (narrow layouts only). function addBarButton(icon, label, run) { $('.sidebar').insertBefore(h('button', { type: 'button', class: 'bar-btn', title: label, 'aria-label': label, onclick: run }, icon), $('#nav-toggle')); } // An action at the end of the ☰ menu (narrow layouts only; wide ones show // the same thing as a button under the navigation). function addMenuAction(label, run) { if (!$('#nav .nav-sep')) $('#nav').append(h('div', { class: 'nav-sep', role: 'separator' })); $('#nav').append(h('a', { href: '#', class: 'nav-action', onclick: (e) => { e.preventDefault(); setMenu(false); run(); } }, label)); } // Theseus: Pithos opens only with the vault unlocked. The add-on asks Theseus // for the unlock, and Theseus shows its own PIN / master-password prompt. let vaultInfo = null; async function vaultGate() { let r; try { r = await bridge.invoke('gate'); } catch (e) { fail(e); r = { ok: false }; } vaultInfo = r; if (r.ok) { $('#locked').hidden = true; return true; } $('#app').hidden = true; $('#locked').hidden = false; return false; } // Re-lock when the vault locks (Settings › Lock now), so secrets are not left // on screen. The add-on watches from the main process and pushes the change. function watchVaultLock() { bridge.on('pithos-vault', (st) => { vaultInfo = { ...vaultInfo, ...st }; if (st.setup && st.prompt && !st.unlocked && $('#locked').hidden) { $('#app').hidden = true; $('#locked').hidden = false; } }); } $('#unlock-btn').addEventListener('click', async () => { if (PATH_MODE) { try { const r = await api('POST', '/api/host/unlock'); if (r.ok) location.reload(); } catch (e) { fail(e); } return; } if (await vaultGate()) { const first = $('#app').dataset.booted !== '1'; $('#app').hidden = false; if (first) location.reload(); } }); // ---- Footer: pithos.sia, the version, and (in Theseus) the updater ----------- // Like Aegis: checks the extension channel when Pithos opens and every few // hours; a newer release replaces the version with "↑ vX". One click // downloads and verifies it, the next restarts Theseus to apply it. const UPDATES_URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates.json'; // The desktop app: newer builds are listed in Silent Mode's release manifest // (read by the local server). The chip downloads the matching installer, or // the portable exe, in the browser. function desktopUpdates(current, { ver, chip, check }) { const plat = state.status?.platform; const file = plat === 'linux' ? 'Pithos.AppImage' : plat === 'darwin' ? 'Pithos.dmg' : state.status?.portable ? 'Pithos-portable.exe' : 'Pithos-Setup.exe'; const rest = () => { chip.hidden = true; ver.hidden = false; }; const flash = (text, cls, title) => { chip.hidden = false; ver.hidden = true; chip.className = `brandupd ${cls}`; chip.textContent = text; chip.title = title || ''; chip.disabled = true; chip.onclick = null; setTimeout(() => paint(), 2500); }; let latest = null; function paint() { if (!latest || cmpSemver(latest.version, current) <= 0) return rest(); chip.hidden = false; ver.hidden = true; chip.className = 'brandupd'; chip.disabled = false; chip.textContent = `↑ v${latest.version}`; chip.title = `Pithos v${latest.version} is available${latest.changes ? ': ' + latest.changes : ''}. Click to download it, then run it to update.`; chip.onclick = () => window.open(`https://dl.silentmode.st/latest/${file}`, '_blank', 'noopener'); } async function scan(manual) { check.classList.add('spin'); try { latest = (await api('GET', `/api/desktop-release${manual ? '?fresh=1' : ''}`)).latest; if (manual && !(latest && cmpSemver(latest.version, current) > 0)) flash('✓ Up to date', 'ok', `Pithos v${current} is the latest desktop release`); else paint(); } catch (e) { if (manual) flash('⚠ Check failed', 'err', e.message); } finally { check.classList.remove('spin'); } } check.hidden = false; check.addEventListener('click', () => scan(true)); setTimeout(() => scan(false), 1500); setInterval(() => scan(false), 6 * 3600 * 1000); } // s3d's S3 port is reachable only from this computer (the default). function localOnly() { const a = state.status?.config?.apiAddress || ''; return /^(127\.|localhost|\[::1\])/.test(a); } function cmpSemver(a, b) { const pa = String(a || '0').split('.').map((n) => Number(n) || 0); const pb = String(b || '0').split('.').map((n) => Number(n) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function setupFooter() { const foot = $('#brandfoot'); const ver = $('#brandver'); const chip = $('#brandupd'); const check = $('#brandcheck'); const current = state.status?.pithos || null; ver.textContent = current ? `v${current}` : ''; foot.hidden = false; document.body.classList.add('has-foot'); if (bridge) { $('#brandlink').addEventListener('click', (e) => { e.preventDefault(); bridge.invoke('open-external', { url: 'https://pithos.sia/' }).catch(fail); }); } if (!bridge && state.status?.host === 'desktop' && current && !state.external) return desktopUpdates(current, { ver, chip, check }); if (!bridge || !current) return; // only the Theseus add-on updates itself let latest = null; let busy = false; const showChip = (text, cls, title, onclick) => { chip.hidden = false; ver.hidden = true; chip.className = `brandupd${cls ? ' ' + cls : ''}`; chip.textContent = text; chip.title = title || ''; chip.disabled = !onclick; chip.onclick = onclick || null; }; const rest = () => { chip.hidden = true; ver.hidden = false; }; const flash = (text, cls, title) => { showChip(text, cls, title, null); setTimeout(() => { if (!busy && chip.classList.contains(cls)) paint(); }, 2500); }; function paint() { if (latest && cmpSemver(latest, current) > 0) showChip(`↑ v${latest}`, '', `Pithos v${latest} is available. Click to install it.`, install); else rest(); } async function scan(manual) { check.classList.add('spin'); try { const r = await fetch(`${UPDATES_URL}?t=${Math.floor(Date.now() / 60000)}`, { cache: 'no-store' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const list = (await r.json())?.addons || []; latest = list.reduce((best, e) => (!best || cmpSemver(e.version, best) > 0 ? e.version : best), null); if (busy) return; if (manual && !(latest && cmpSemver(latest, current) > 0)) flash('✓ Up to date', 'ok', `Pithos v${current} is the latest release`); else paint(); } catch (e) { if (manual) flash('⚠ Check failed', 'err', e.message); } finally { check.classList.remove('spin'); } } async function install() { busy = true; showChip('Downloading…', 'wait', 'Downloading and verifying the signed release', null); try { const r = await bridge.invoke('update', { step: 'stage' }); if (r?.fallback) { busy = false; return showChip('Update in Settings', '', 'This Theseus updates extensions in Settings › Extensions', () => bridge.invoke('open-settings', { section: 'addons' }).catch(fail)); } if (!r?.staged) { busy = false; return r?.status === 'up-to-date' ? flash('✓ Up to date', 'ok', '') : flash('⚠ Update failed', 'err', r?.detail || r?.status || ''); } const next = r.next ? ` v${r.next}` : ''; // Newer Theseus applies it in place: the panel reloads on the new version. if (r.inPlace) { showChip(`Updating to${next}…`, 'wait', 'Pithos reloads on the new version in a moment', null); const a = await bridge.invoke('update', { step: 'apply-now' }).catch(() => null); if (a?.ok) return; } showChip(`↻ Restart to apply${next}`, '', `Pithos${next} is ready. Theseus restarts to apply it and reopens your tabs.`, async () => { try { const a = await bridge.invoke('update', { step: 'apply' }); if (a?.deferred) showChip(`Applies on next start${next}`, 'ok', 'Pithos updates the next time Theseus starts', null); } catch (e) { flash('⚠ Restart failed', 'err', e.message); } }); } catch (e) { busy = false; flash('⚠ Update failed', 'err', e.message); } } check.hidden = false; check.addEventListener('click', () => scan(true)); setTimeout(() => scan(false), 1500); setInterval(() => scan(false), 6 * 3600 * 1000); } // The full-page tab opened from the Theseus panel: one button back. function addBackToSidebar() { const row = h('div', { class: 'host-controls' }, h('button', { class: 'btn small', title: 'Show Pithos in the sidebar again and close this page', onclick: async () => { try { await api('POST', '/api/host/show-panel'); } catch (e) { return fail(e); } window.close(); // works for tabs the add-on opened; harmless otherwise } }, '⇤ Back to sidebar')); $('.sidebar').insertBefore(row, $('#daemon-pill')); addMenuAction('⇤ Back to sidebar', () => row.querySelector('button').click()); addBarButton('⇤', 'Back to sidebar', () => row.querySelector('button').click()); } function showSignin() { $('#app').hidden = true; $('#signin').hidden = false; } async function boot() { const s = await api('GET', '/api/session'); // pithos.sia in Theseus: locked with the vault, like the sidebar panel. if (s.locked) { $('#locked').hidden = false; return; } if (!s.authenticated) { showSignin(); if (!s.passwordLogin) { $('#signin-hint').textContent = 'Open Pithos with the sign-in link printed when it started.'; $('#signin-form').querySelector('input').hidden = true; $('#signin-form').querySelector('button').hidden = true; } return; } $('#signin').hidden = true; if (bridge && !(await vaultGate())) return; $('#app').hidden = false; $('#app').dataset.booted = '1'; state.external = !!s.external; if (bridge) { addSidebarControls(); watchVaultLock(); } if (!state.external && s.canOpenBrowser && ['desktop', 'theseus'].includes(s.host)) addOpenInBrowser(); // Backfill logs the server already holds, then stream. try { state.logs = await api('GET', '/api/logs'); state.logSeq = state.logs.at(-1)?.seq || 0; } catch {} await refreshStatus(); setupFooter(); if (!bridge && state.status?.host === 'theseus' && !state.external) addBackToSidebar(); connectEvents(); if (PATH_MODE) { window.addEventListener('popstate', route); // In-app links are written as #/ routes; follow them as real addresses. document.addEventListener('click', (e) => { const a = e.target.closest?.('a[href^="#/"]'); if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey) return; e.preventDefault(); navTo(a.getAttribute('href')); }); } else window.addEventListener('hashchange', route); route(); setInterval(refreshStatus, 15000); } function setMenu(open) { $('.sidebar').classList.toggle('nav-open', open); $('#nav-toggle').setAttribute('aria-expanded', String(open)); } $('#nav-toggle').addEventListener('click', (e) => { e.stopPropagation(); setMenu(!$('.sidebar').classList.contains('nav-open')); }); document.addEventListener('mousedown', (e) => { if ($('.sidebar').classList.contains('nav-open') && !$('#nav').contains(e.target) && !$('#nav-toggle').contains(e.target)) setMenu(false); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('.sidebar').classList.contains('nav-open')) { setMenu(false); $('#nav-toggle').focus(); } }); $('#nav').addEventListener('click', (e) => { if (e.target.closest('a')) setMenu(false); }); $('#signin-form').addEventListener('submit', async (e) => { e.preventDefault(); const err = $('#signin-error'); err.hidden = true; try { await api('POST', '/api/session', { password: $('#signin-password').value }); location.reload(); } catch (ex) { err.textContent = ex.message; err.hidden = false; } }); boot().catch(fail);