From b9f7e08ae4f274629bb85945eab0c81bae875c61 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 3 Oct 2026 20:23:42 +0200 Subject: [PATCH] Pithos: guided "Get started" setup that opens the approval page itself First run now lands on a step-by-step guide instead of the overview: create a Sia Storage account (link out, Pithos never sees the password), install s3d, choose or generate the recovery phrase, connect, start the gateway and create the first access key, with Back/Next on every step. The connect step opens the indexer's approval page on its own and moves on as soon as the approval lands; where a browser blocks the pop-up it says so and makes the button the primary action. Hosts without node-pty (the Theseus add-on) now fetch the one platform package they need from the npm registry on first login, pinned to its published sha512 integrity, so the approval link can be read there too. The console-window login stays only as the fallback when that fails. A retry after a failed connect with an existing phrase goes back to the phrase step: the phrase is not kept, and a blank one would make s3d generate a new one. --- core/login.js | 22 ++-- core/pty.js | 98 +++++++++++++++++ core/server.js | 6 +- ui/app.css | 8 ++ ui/app.js | 287 +++++++++++++++++++++++++++++++++++++++++++++++-- ui/index.html | 1 + 6 files changed, 404 insertions(+), 18 deletions(-) create mode 100644 core/pty.js diff --git a/core/login.js b/core/login.js index d60b198..df10e4c 100644 --- a/core/login.js +++ b/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/core/pty.js b/core/pty.js new file mode 100644 index 0000000..deafb3f --- /dev/null +++ b/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/core/server.js b/core/server.js index 6f62e8d..0ed5864 100644 --- a/core/server.js +++ b/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/ui/app.css b/ui/app.css index c7f80d4..b182af8 100644 --- a/ui/app.css +++ b/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/ui/app.js b/ui/app.js index 40d78cf..e08f012 100644 --- a/ui/app.js +++ b/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,276 @@ 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; + 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, so keep them offline and safe.'), + h('div', { class: 'stack' }, + h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'new', onchange: () => { wiz.mode = 'new'; draw(); } }), 'Create a new phrase for me (first time)'), + h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'existing', onchange: () => { wiz.mode = 'existing'; 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.'), + 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; + 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 })]; + } + } + } + + // 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 +701,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 diff --git a/ui/index.html b/ui/index.html index 6a37b80..66004b2 100644 --- a/ui/index.html +++ b/ui/index.html @@ -23,6 +23,7 @@