From a3d7c90b78afbe69f202cca2f2205f37e4144ce1 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 20:42:23 +0200 Subject: [PATCH] Theseus: bundle Pithos 0.3.0 (PIN gate, vault-derived phrase, guided setup) Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs. --- bundled-addons/pithos/addon.json | 5 +- bundled-addons/pithos/core/login.js | 22 +- bundled-addons/pithos/core/pty.js | 98 ++++++++ bundled-addons/pithos/core/server.js | 6 +- bundled-addons/pithos/index.js | 62 +++++ bundled-addons/pithos/ui/app.css | 8 + bundled-addons/pithos/ui/app.js | 336 ++++++++++++++++++++++++++- bundled-addons/pithos/ui/index.html | 10 + 8 files changed, 526 insertions(+), 21 deletions(-) create mode 100644 bundled-addons/pithos/core/pty.js diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 60fd25f3..b3e6c813 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -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" } diff --git a/bundled-addons/pithos/core/login.js b/bundled-addons/pithos/core/login.js index d60b198f..df10e4c1 100644 --- a/bundled-addons/pithos/core/login.js +++ b/bundled-addons/pithos/core/login.js @@ -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; diff --git a/bundled-addons/pithos/core/pty.js b/bundled-addons/pithos/core/pty.js new file mode 100644 index 00000000..deafb3f3 --- /dev/null +++ b/bundled-addons/pithos/core/pty.js @@ -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; + } +} diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 6f62e8d5..0ed58647 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -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 diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index b578dd27..4f366b5a 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -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 diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index c7f80d45..b182af8c 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -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; } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 40d78cf3..bc58ecaa 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -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'); diff --git a/bundled-addons/pithos/ui/index.html b/bundled-addons/pithos/ui/index.html index 6a37b808..3cf1b7da 100644 --- a/bundled-addons/pithos/ui/index.html +++ b/bundled-addons/pithos/ui/index.html @@ -19,10 +19,20 @@ + +