// Drives `s3d login` through a pseudo-terminal. s3d reads the recovery phrase // with term.ReadPassword, which needs a real console, so plain pipes fail. // The phrase only ever lives in this process's memory: it is written to the // pty and never logged or emitted except the one-time display of a phrase // s3d generated, which the user has to copy down. 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; export class LoginSession extends EventEmitter { // registration: async () => ({ registered, indexerUrl }), used to learn the // outcome when login ran in a console window we cannot read. // 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'; // idle | indexer | phrase | confirm | approve | registering | done | already | failed this.info = {}; } snapshot() { return { state: this.state, ...this.info }; } set(state, info = {}) { this.state = state; Object.assign(this.info, info); this.emit('state', this.snapshot()); } async start({ indexerUrl, phrase }) { if (this.term) throw new Error('a login is already in progress'); this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' }; // Kept in the state so "done" says which indexer this s3d now uses // (a blank answer means s3d's default, sia.storage). this.info = { indexerUrl: indexerUrl || 'https://sia.storage' }; 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; this.lastError = null; const term = pty.spawn(this.daemon.binary, ['login'], { name: 'xterm', cols: 200, rows: 50, cwd: this.daemon.cwd(), env: this.daemon.env(), }); this.term = term; this.set('starting'); // A cancelled session's pty can still flush output or exit after a new // one started; only the current term may drive the state machine. term.onData((d) => { if (this.term !== term) return; try { this.onData(d); } catch (e) { this.fail(e.message); } }); term.onExit(({ exitCode }) => { if (this.term !== term) return; this.term = null; this.answers = null; if (!['done', 'already'].includes(this.state)) { this.set('failed', { error: this.lastError || `s3d login exited with code ${exitCode}` }); } }); return this.snapshot(); } // Fallback when the pty module is not shipped (the Theseus add-on): run // s3d login in its own console window and check the result when it closes. startInConsole() { if (process.platform !== 'win32') { throw new Error('run "s3d login" in a terminal on this machine, then reload this page'); } // detached gives a console app its own visible console window on Windows. const child = spawn(this.daemon.binary, ['login'], { cwd: this.daemon.cwd(), env: this.daemon.env(), detached: true, stdio: 'ignore', windowsHide: false, }); this.term = { kill: () => child.kill(), write() {} }; const term = this.term; this.answers = null; this.set('terminal'); child.on('error', (e) => { if (this.term === term) this.fail(e.message); }); child.on('exit', async () => { if (this.term !== term) return; this.term = null; try { const r = await this.registration(); if (r.registered) this.set('done', { indexerUrl: r.indexerUrl }); else this.set('failed', { error: 'the login window closed before registration finished' }); } catch (e) { this.set('failed', { error: e.message }); } }); return this.snapshot(); } // The user confirms they stored a phrase s3d generated; we echo it back. confirmGenerated() { if (this.state !== 'confirm' || !this.generated) throw new Error('nothing to confirm'); this.write(this.generated); this.generated = null; this.info.generatedPhrase = undefined; this.set('approve-wait'); } cancel() { if (this.term) { try { this.term.kill(); } catch {} } this.term = null; this.answers = null; this.generated = null; this.set('idle', { error: undefined, approvalUrl: undefined, generatedPhrase: undefined }); } write(s) { this.term?.write(s + '\r'); } onData(chunk) { this.buffer += chunk.replace(ANSI, ''); // Work line-by-line, but prompts have no trailing newline, so also look at // the unterminated tail. const text = this.buffer; // Checked first: s3d prints the error and re-prompts in the same chunk, // and answering that prompt with a blank would make it generate a phrase. const bad = text.match(/Invalid recovery phrase: (.+)$/m); if (bad) { // s3d would re-prompt forever; stop and let the user retype. return this.fail(`invalid recovery phrase: ${bad[1].trim()}`); } if (/already registered with (\S+?)\.?\s*$/m.test(text)) { const m = text.match(/already registered with (\S+?)\.?\s*$/m); this.buffer = ''; return this.set('already', { indexerUrl: m[1] }); } if (/Indexer URL \(default: [^)]*\):\s*$/.test(text)) { this.buffer = ''; this.set('indexer'); return this.write(this.answers.indexerUrl); } if (/Recovery Phrase:\s*(.+)$/m.test(text) && !this.generated) { // s3d generated a phrase because we sent a blank one. this.generated = text.match(/Recovery Phrase:\s*(.+)$/m)[1].trim(); } if (/Confirm recovery phrase:\s*$/.test(text)) { this.buffer = ''; if (!this.generated) return this.fail('could not read the generated recovery phrase'); return this.set('confirm', { generatedPhrase: this.generated }); } if (/Enter recovery phrase:\s*$/.test(text)) { this.buffer = ''; this.set('phrase'); const p = this.answers.phrase; this.answers.phrase = ''; return this.write(p); } const url = text.match(/visiting the following URL:\s*(\S+)/); if (url) { this.buffer = text.slice(url.index + url[0].length); return this.set('approve', { approvalUrl: url[1] }); } if (/Login successful/.test(text)) { this.buffer = ''; return this.set('done'); } if (/app connection was declined/.test(text)) { this.buffer = ''; return this.fail('the connection was declined on the indexer'); } const fatal = text.match(/^(failed to [^\n]+)$/m); if (fatal) this.lastError = fatal[1].trim(); // Keep the tail bounded; anything we care about is short. if (this.buffer.length > 8192) this.buffer = this.buffer.slice(-2048); } fail(error) { const term = this.term; this.term = null; this.answers = null; this.generated = null; this.set('failed', { error, generatedPhrase: undefined }); if (term) { try { term.kill(); } catch {} } } }