theseus/bundled-addons/pithos/ui/app.js

1814 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
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',
onclick: b.submit ? null : async () => { const v = b.value ? await b.value() : b.result; 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);
const v = b.value ? await b.value() : b.result;
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) { state.login = d; emit(); },
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
};
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 = label;
}
// ---------------------------------------------------------------- router
let cleanup = [];
let logSink = null;
const views = { start: getStarted, overview, buckets, users, setup, settings, logs };
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 ? 'start' : 'overview';
const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/');
const view = views[name] ? name : fallback;
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view);
// Compact bar (narrow hosts): name the page, fold the menu away.
const here = document.querySelector(`#nav a[data-view="${view}"]`);
$('#here').textContent = here ? here.textContent : '';
$('.sidebar').classList.remove('nav-open');
$('#nav-toggle').setAttribute('aria-expanded', '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,
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';
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: '#/setup' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')),
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 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.'),
);
} catch (e) {
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message));
}
}
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.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: '#/start' }, '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', {}, 'Get started'),
h('p', { class: 'muted' }, '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(); };
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')),
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)
function setup(main) {
const body = h('div', { class: 'card' });
main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Sia connection'),
h('p', { class: 'muted' }, 'Register this s3d with a Sia indexer. You do this once; the indexer then handles hosts and contracts for it.'))),
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(body, steps,
h('h2', {}, l.state === 'done' ? 'Connected' : 'Connected to Sia'),
h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, buckets 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(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')),
h('small', {}, 'Pithos cannot see which login the account belongs to. On sia.storage it is the account whose "My apps" lists "S3d".')),
h('div', { class: 'row', style: 'margin-top:16px' },
daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'),
h('a', { class: 'btn', href: '#/users' }, 'Users & keys'),
h('button', { class: 'btn danger', onclick: switchAccount }, 'Switch Sia account…')),
archivesBlock());
return;
}
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());
}
// Earlier Sia accounts set aside by "Switch Sia account", restorable.
function archivesBlock() {
const list = accountInfo?.archives || [];
if (!list.length) return null;
return h('div', { style: 'margin-top:22px' },
h('h3', {}, 'Earlier Sia accounts on this computer'),
h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside by "Switch Sia account". Restoring one puts its users, keys and buckets back (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.users.length ? ` · users: ${a.users.join(', ')}` : ''}`)),
h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => restoreAccount(a) }, 'Restore')))))));
}
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('Switch Sia account?', h('div', { class: 'stack' },
h('p', { style: 'margin:0' }, `s3d is connected to ${accountName(accountInfo)}. Switching connects it to a different Sia account.`),
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 buckets are set aside on this computer, not deleted, and can be restored here.`),
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 other 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: 'Switch 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();
Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null });
location.hash = '#/start';
} catch (e) { fail(e); }
}
async function restoreAccount(a) {
const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name);
const ok = await confirmModal(`Restore ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and buckets. The current account is set aside and can be restored the same way.', 'Restore', 'primary');
if (!ok) return;
try {
await api('POST', '/api/account/restore', { name: a.name });
await api('POST', '/api/daemon/start').catch(() => {});
toast(`Restored ${name}`);
await refreshStatus();
await loadAccount();
draw();
} 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); location.hash = '#/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 = `http://${s?.config.apiAddress || '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', {}, h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…')),
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 }]);
}
// ---------------------------------------------------------------- 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: `http://${state.status?.config.apiAddress || '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());
const 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(); menu.remove(); 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';
const done = () => { menu.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); };
const away = (ev) => { if (!menu.contains(ev.target)) done(); };
const 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) => () => { location.hash = hash; };
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;
}
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); 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.indexerUrl ? new URL(a.indexerUrl).host : ''))) || 'not connected';
// "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: '#/setup' }, 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()),
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, 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)], ['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: '🔒 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 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) {
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}/<key>`), '. Writes and deletes always need a key.'),
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) => { location.hash = `#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`; };
const crumbs = h('div', { class: 'crumbs' },
h('button', { onclick: () => { location.hash = '#/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 = ''; } });
put(head,
h('div', { class: 'stack' }, crumbs, accountLine()),
h('div', { class: 'row' }, userPicker(userSel),
h('button', { class: 'btn', onclick: newFolder }, 'New folder'),
h('button', { class: 'btn', onclick: () => accessDialog(user, bucket) }, 'Access'),
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)], ['Delete', () => deleteFolder(f), 'danger']]);
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(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();
if (mode === 'details') {
put(card, h('table', { class: 'files' },
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, '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('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f))),
h('td', { class: 'num muted' }, '–'), 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('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o))),
h('td', { class: 'num' }, 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'), ' '),
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)),
h('span', { class: 'meta' }, fmtBytes(o.size)),
h('span', { onclick: stop }, 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)),
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;
}
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 "<name>/" 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)}`)),
[{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }]);
}
// 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)));
}
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); }
}
async function share(key) {
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` });
const out = h('div');
const gen = async () => {
const q = new URLSearchParams({ key, expires: expires.value });
if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); }
try {
const { url } = await api('GET', `${base}/presign?${q}`);
put(out, secretRow('Link', url));
} catch (e) { fail(e); }
return false;
};
await modal('Share link', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'),
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')),
out),
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
}
load();
}
// ---------------------------------------------------------------- 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() }, '✕');
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)),
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…'));
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.'))), card);
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'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); }
} }, '⤢ Full page'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
}
// 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 (await vaultGate()) {
const first = $('#app').dataset.booted !== '1';
$('#app').hidden = false;
if (first) location.reload();
}
});
// 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'));
}
function showSignin() {
$('#app').hidden = true;
$('#signin').hidden = false;
}
async function boot() {
const s = await api('GET', '/api/session');
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';
if (bridge) { addSidebarControls(); watchVaultLock(); }
// 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();
if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
connectEvents();
window.addEventListener('hashchange', route);
route();
setInterval(refreshStatus, 15000);
}
$('#nav-toggle').addEventListener('click', () => {
const open = $('.sidebar').classList.toggle('nav-open');
$('#nav-toggle').setAttribute('aria-expanded', String(open));
});
$('#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);