Theseus: bundle Pithos 0.3.0 (PIN gate, vault-derived phrase, guided setup)

Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs.
This commit is contained in:
Local Dev 2026-10-03 20:42:23 +02:00
parent de7735feb3
commit a3d7c90b78
8 changed files with 526 additions and 21 deletions

View file

@ -1,13 +1,14 @@
{
"id": "pithos",
"name": "Pithos",
"version": "0.2.0",
"version": "0.3.0",
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
"main": "index.js",
"capabilities": [
"sidebar-panel"
"sidebar-panel",
"vault-derive"
],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json"
}

View file

@ -6,24 +6,19 @@
import { EventEmitter } from 'node:events';
import { spawn } from 'node:child_process';
import { loadPty } from './pty.js';
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
let ptyModule;
async function loadPty() {
if (ptyModule === undefined) {
try { ptyModule = await import('@lydell/node-pty'); } catch { ptyModule = null; }
}
return ptyModule && (ptyModule.default || ptyModule);
}
export class LoginSession extends EventEmitter {
// registration: async () => ({ registered, indexerUrl }), used to learn the
// outcome when login ran in a console window we cannot read.
constructor(daemon, { registration } = {}) {
// ptyDir: where terminal support may be downloaded if the host lacks it.
constructor(daemon, { registration, ptyDir } = {}) {
super();
this.daemon = daemon;
this.registration = registration;
this.ptyDir = ptyDir;
this.term = null;
this.buffer = '';
this.state = 'idle';
@ -43,7 +38,14 @@ export class LoginSession extends EventEmitter {
if (this.term) throw new Error('a login is already in progress');
this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' };
this.info = {};
const pty = await loadPty();
this.set('preparing');
let pty = null;
try {
pty = await loadPty({ installDir: this.ptyDir });
} catch (e) {
this.lastError = e.message;
}
// Without terminal support, run s3d login in its own console window.
if (!pty) return this.startInConsole();
this.buffer = '';
this.generated = null;

View file

@ -0,0 +1,98 @@
// The pseudo-terminal that `s3d login` needs (it reads the recovery phrase
// with term.ReadPassword, which fails on a plain pipe).
//
// Hosts that ship node_modules (web console, desktop) have @lydell/node-pty
// installed. The Theseus add-on leaves it out (the Windows build is 12 MB of
// native code), so on first use it fetches the one platform package it needs
// from the npm registry, checks it against the integrity hash pinned below,
// and unpacks it into the add-on's data folder.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import zlib from 'node:zlib';
import { pathToFileURL } from 'node:url';
const VERSION = '1.2.0-beta.15';
// dist.integrity from the npm registry for each platform package.
const PACKAGES = {
'win32-x64': 'sha512-2f8twEmDVxZ7drchAXjtevpmSPhFok0avAnzXro4t5gmz0xsPNKkoZvymwtuIS3xo7PzQqZOPQ/YzwEMb7oIzQ==',
'win32-arm64': 'sha512-pyAk91w7wnnKrD4mrHXtIXRfmzSWV5bEzvRhurXcMCtCc2TJ424ciUskIgWMhAPP6y3KyUnqElj+U6kY3iOt0A==',
'darwin-x64': 'sha512-yDT2oqPqYMBScyuk1U9Rg5VKcrbMOD9o9jWYYamDADA3NSbUISroPChrqYRQ74Y7BQtNH4gqYAiWOZRi5uQZ0Q==',
'darwin-arm64': 'sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==',
'linux-x64': 'sha512-+U/5AVvHT6W+8OCYcnJgN0Qgc0ycO3TfD6aaFJHK+WHij797f8gsi5dV1HEO9l6YQmWCD+VL5gaLDhx3mxHwCA==',
'linux-arm64': 'sha512-wkbNF7dYAmtJv+o2+iztVlNwnUB4B0uX0wh/UD+mwMcmE2gNMnW9GChXO7fEE5XJokD0vB5idiHpGegaN+G/sg==',
};
let cached;
// Returns the pty module, or null when none is available and none can be
// installed. installDir: where a downloaded package lives (null = never download).
export async function loadPty({ installDir, onProgress = () => {} } = {}) {
if (cached) return cached;
try {
const m = await import('@lydell/node-pty');
return (cached = m.default || m);
} catch { /* not shipped with this host */ }
if (!installDir) return null;
const plat = `${process.platform}-${process.arch}`;
if (!PACKAGES[plat]) return null;
const pkgDir = path.join(installDir, `node-pty-${plat}-${VERSION}`);
const entry = path.join(pkgDir, 'lib', 'index.js');
if (!fs.existsSync(entry)) await install(plat, pkgDir, onProgress);
const m = await import(pathToFileURL(entry).href);
return (cached = m.default || m);
}
async function install(plat, pkgDir, onProgress) {
const name = `node-pty-${plat}`;
const url = `https://registry.npmjs.org/@lydell/${name}/-/${name}-${VERSION}.tgz`;
onProgress({ phase: 'download', what: 'terminal support', url });
const res = await fetch(url);
if (!res.ok) throw new Error(`could not download terminal support: HTTP ${res.status}`);
const tgz = Buffer.from(await res.arrayBuffer());
const want = PACKAGES[plat];
const got = 'sha512-' + crypto.createHash('sha512').update(tgz).digest('base64');
if (got !== want) throw new Error('terminal support package failed its integrity check');
onProgress({ phase: 'extract', what: 'terminal support' });
const tmp = pkgDir + '.tmp';
fs.rmSync(tmp, { recursive: true, force: true });
for (const { name: file, data } of untar(zlib.gunzipSync(tgz))) {
// npm tarballs put everything under package/.
const rel = file.replace(/^package\//, '');
if (!rel || rel === file) continue;
const out = path.join(tmp, rel);
if (!out.startsWith(tmp + path.sep)) throw new Error('unsafe path in terminal support package');
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, data);
}
fs.rmSync(pkgDir, { recursive: true, force: true });
fs.renameSync(tmp, pkgDir);
onProgress({ phase: 'done', what: 'terminal support' });
}
// Minimal ustar reader: regular files only, with pax "path" overrides.
function* untar(buf) {
let off = 0;
let paxPath = null;
while (off + 512 <= buf.length) {
const h = buf.subarray(off, off + 512);
if (h.every((b) => b === 0)) break;
const str = (a, b) => h.toString('utf8', a, b).replace(/\0.*$/s, '');
let name = str(0, 100);
const prefix = str(345, 500);
if (prefix) name = prefix + '/' + name;
const size = parseInt(str(124, 136).trim() || '0', 8);
const type = String.fromCharCode(h[156] || 48);
const data = buf.subarray(off + 512, off + 512 + size);
off += 512 + Math.ceil(size / 512) * 512;
if (type === 'x') {
const m = /\d+ path=([^\n]+)\n/.exec(data.toString('utf8'));
paxPath = m ? m[1] : null;
continue;
}
if (type === '0' || type === '\0') yield { name: paxPath || name, data: Buffer.from(data) };
paxPath = null;
}
}

View file

@ -50,7 +50,11 @@ export async function createPithos(opts = {}) {
const configFile = resolveConfigPath(configOpt);
const daemon = new Daemon({ configFile, binary: resolveBinary(binaryOpt, binDir) });
const cli = makeCli(daemon);
const login = new LoginSession(daemon, { registration: () => cli.registration() });
const login = new LoginSession(daemon, {
registration: () => cli.registration(),
// Hosts without node-pty (the Theseus add-on) fetch it here on first login.
ptyDir: binDir ? path.join(binDir, '..', 'pty') : null,
});
const secret = crypto.randomBytes(24).toString('base64url');
const sessions = new Set();
// One-time download links: id -> { path, exp }. Lets a host hand a file

View file

@ -114,6 +114,68 @@ module.exports = {
return { ok: true };
});
// ---- Theseus vault: PIN gate and the vault-derived recovery phrase ------
// Older Theseus builds have the vault but no requestUnlock (no PIN prompt);
// Pithos then runs ungated, as before.
const vault = api.vault || null;
const canPrompt = !!(vault && typeof vault.requestUnlock === "function");
async function vaultStatus() {
if (!vault || !vault.lifecycle) return { setup: false, unlocked: false, prompt: false };
const st = await vault.lifecycle.status();
return { setup: !!st.setup, unlocked: !!st.unlocked, prompt: canPrompt };
}
api.onMessage("vault-status", () => vaultStatus());
// Watch for the vault locking (Settings › Lock now, or a lock from another
// extension) and tell the panel at once. This runs in the main process:
// a hidden panel's own timers are throttled to about once a minute.
if (canPrompt) {
let lastUnlocked = null;
setInterval(async () => {
try {
const st = await vaultStatus();
if (st.unlocked !== lastUnlocked) {
lastUnlocked = st.unlocked;
api.emit("pithos-vault", st);
}
} catch {}
}, 3000).unref?.();
}
// Pithos shows access-key secrets and can delete buckets, so in Theseus it
// opens only with the vault unlocked: Theseus asks for the PIN (or the
// master password after three wrong tries) in its own prompt.
api.onMessage("gate", async () => {
const st = await vaultStatus();
if (!st.setup || !st.prompt || st.unlocked) return { ok: true, ...st };
const r = await vault.requestUnlock({ reason: "Open Pithos, your S3 storage on Sia." });
return { ...r, ...(await vaultStatus()) };
});
// Connect with a recovery phrase derived from the vault: nothing to write
// down, and restoring the vault restores access. The phrase is built and
// handed to s3d here; it never reaches the panel page.
// CHANGING THE PURPOSE PATH OR THE DERIVATION CUTS USERS OFF FROM THEIR DATA.
api.onMessage("connect-with-vault", async ({ indexerUrl } = {}) => {
const st = await vaultStatus();
if (!st.setup) throw new Error("Set up the password vault in Settings › Passwords first.");
if (!st.unlocked) {
if (!canPrompt) throw new Error("Unlock the password vault in Settings › Passwords first.");
const r = await vault.requestUnlock({ reason: "Create the recovery phrase for your Sia storage." });
if (!r.ok) throw new Error("The vault stayed locked.");
}
const bytes = await vault.derive("pithos/sia-recovery/v1");
const bip39 = api.require("bip39");
const phrase = bip39.entropyToMnemonic(Buffer.from(bytes.subarray(0, 16)).toString("hex"));
bytes.fill(0);
return call("POST", "/api/login", {
body: JSON.stringify({ indexerUrl, phrase }),
headers: { "content-type": "application/json" },
});
});
api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html" });
// The host has no quit hook for add-ons; without this an s3d we started

View file

@ -218,3 +218,11 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8
/* Narrow hosts (Theseus sidebar): never scroll sideways. */
html, body { overflow-x: hidden; }
.card { min-width: 0; }
/* Get started wizard */
.wizard { max-width: 720px; }
.wizard h2 { margin-top: 6px; }
.wizard-progress { margin-bottom: 18px; }
.wizard-progress .bar { margin-top: 6px; }
.wizard-nav { margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--border); }
.wizard-list { margin: 0 0 8px 1.2em; padding: 0; display: grid; gap: 4px; }

View file

@ -187,14 +187,16 @@ function renderPill() {
let cleanup = [];
let logSink = null;
const views = { overview, buckets, users, setup, settings, logs };
const views = { start: getStarted, overview, buckets, users, setup, settings, logs };
function route() {
cleanup.forEach((fn) => fn());
cleanup = [];
logSink = null;
const [, name = 'overview', ...rest] = location.hash.replace(/^#/, '').split('/');
const view = views[name] ? name : 'overview';
// 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);
const main = $('#main');
put(main);
@ -301,7 +303,7 @@ function bannersFor(s) {
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: '#/setup' }, 'Connect to Sia')));
h('a', { class: 'btn primary', href: '#/start' }, 'Get started')));
}
return out;
}
@ -324,6 +326,288 @@ async function daemonAction(action) {
} 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.'),
h('div', { class: 'row' },
h('button', { class: 'btn primary', onclick: () => openLink('https://sia.storage') }, 'Open sia.storage ↗'),
h('span', { class: 'muted small' }, 'Sign up there, then come back and press Next.')),
h('label', { class: 'check', style: 'margin-top:18px' },
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({ nextLabel: 'I have an account — Next', 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('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) {
@ -429,12 +713,13 @@ function setup(main) {
}
async function openLink(url) {
if (bridge) { try { await bridge.invoke('open-external', { url }); } catch (e) { fail(e); } return; }
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; } catch { /* fall through */ }
try { await api('POST', '/api/open-external', { url }); return true; } catch { /* fall through */ }
}
window.open(url, '_blank', 'noopener');
// Returns false when a pop-up blocker stopped it (no click behind the call).
return !!window.open(url, '_blank', 'noopener');
}
// ---------------------------------------------------------------- users & keys
@ -929,6 +1214,39 @@ function addSidebarControls() {
$('.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();
}
});
function showSignin() {
$('#app').hidden = true;
$('#signin').hidden = false;
@ -946,8 +1264,10 @@ async function boot() {
return;
}
$('#signin').hidden = true;
if (bridge && !(await vaultGate())) return;
$('#app').hidden = false;
if (bridge) addSidebarControls();
$('#app').dataset.booted = '1';
if (bridge) { addSidebarControls(); watchVaultLock(); }
// Backfill logs the server already holds, then stream.
try {
state.logs = await api('GET', '/api/logs');

View file

@ -19,10 +19,20 @@
</form>
</div>
<div id="locked" class="signin" hidden>
<div class="card signin-card">
<img src="icon.svg" alt="" width="48" height="48">
<h1>Pithos is locked</h1>
<p class="muted">Unlock your Theseus vault with your PIN or master password to open Pithos.</p>
<button class="btn primary" type="button" id="unlock-btn">Unlock</button>
</div>
</div>
<div id="app" class="app" hidden>
<aside class="sidebar">
<div class="brand"><img src="icon.svg" alt="" width="28" height="28"><span>Pithos</span></div>
<nav id="nav">
<a href="#/start" data-view="start">Get started</a>
<a href="#/overview" data-view="overview">Overview</a>
<a href="#/buckets" data-view="buckets">Buckets</a>
<a href="#/users" data-view="users">Users &amp; keys</a>