/** * TTY boot splash: timer, spinner, status lines, full-screen redraw. * Disable with BARE_OS_NO_SPLASH=1 or when stdout is not a TTY (falls back to no-op). */ const CLEAR = '\x1b[3J\x1b[2J\x1b[H' const HIDE_CURSOR = '\x1b[?25l' const SHOW_CURSOR = '\x1b[?25h' const RESET = '\x1b[0m' const DIM = '\x1b[2m' const BOLD = '\x1b[1m' const GREEN = '\x1b[32m' const MAGENTA = '\x1b[35m' const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] /** Seconds for one left→right shimmer pass (iPhone-style slide-to-unlock pacing). */ const TITLE_SHIMMER_PERIOD_SEC = 7.5 /** How wide the bright band is in character positions (Gaussian sigma). */ const TITLE_SHIMMER_SIGMA = 2.8 const TITLE_BASE_RGB = [58, 62, 72] const TITLE_PEAK_RGB = [238, 240, 245] /** * Truecolor title line with a slow moving highlight (no fast hue stepping). * @param {number} tSec */ function formatTitleShimmer(tSec) { const label = ' ◆ BARE-OS ◆' const n = label.length const u = (tSec / TITLE_SHIMMER_PERIOD_SEC) % 1 const center = u * (n + 2) - 1 let out = '' const [br, bg, bb] = TITLE_BASE_RGB const [pr, pg, pb] = TITLE_PEAK_RGB for (let i = 0; i < n; i++) { const dist = i - center const w = Math.exp( -(dist * dist) / (2 * TITLE_SHIMMER_SIGMA * TITLE_SHIMMER_SIGMA) ) const r = Math.round(br + (pr - br) * w) const g = Math.round(bg + (pg - bg) * w) const b = Math.round(bb + (pb - bb) * w) const ch = label[i] out += `\x1b[38;2;${r};${g};${b}m${BOLD}${ch}` } return out + RESET } /** @returns {boolean} */ function splashDisabled() { return globalThis.process?.env?.BARE_OS_NO_SPLASH === '1' } /** * @param {import('stream').Writable | null | undefined} stdout * @param {{ bootLimitMs?: number }} [opts] */ export function createBootSplash(stdout, opts = {}) { const bootLimitMs = opts.bootLimitMs ?? 60_000 if ( splashDisabled() || !stdout || typeof stdout.write !== 'function' || !stdout.isTTY ) { return { start() {}, stop() {}, setPhase() {}, log() {}, prepareForKernel() {}, fail() {} } } let t0 = 0 let frame = 0 /** @type {ReturnType | null} */ let tick = null let phase = 'Starting…' /** @type {string[]} */ const lines = [] const maxLines = 8 function elapsedSec() { return (Date.now() - t0) / 1000 } function draw() { const w = Math.max(40, Number(stdout.columns) || 72) const el = elapsedSec() const sp = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] frame++ const barW = Math.min(36, w - 8) const progress = Math.min(1, el / (bootLimitMs / 1000)) const filled = Math.round(barW * progress) const bar = GREEN + '█'.repeat(filled) + DIM + '░'.repeat(Math.max(0, barW - filled)) + RESET const title = formatTitleShimmer(el) const sub = `${DIM} network boot · ${(bootLimitMs / 1000).toFixed(0)}s limit${RESET}` const logBlock = lines.length ? lines.map((l) => ` ${DIM}›${RESET} ${l}`).join('\n') + '\n' : '' const timeLine = ` ${BOLD}Boot${RESET} ${el.toFixed(1)}s ${sp} ${phase}` const art = [ '', title, sub, '', ` ${bar}`, '', logBlock, timeLine, '' ].join('\n') const out = CLEAR + HIDE_CURSOR + art try { stdout.write(out) } catch { /* ignore */ } } return { start() { t0 = Date.now() frame = 0 phase = 'Initializing…' lines.length = 0 if (tick) clearInterval(tick) draw() tick = setInterval(draw, 90) }, stop() { if (tick) { clearInterval(tick) tick = null } }, /** @param {string} msg */ setPhase(msg) { phase = msg }, /** @param {string} msg */ log(msg) { lines.push(msg) while (lines.length > maxLines) lines.shift() }, /** Second clear before interactive shell / fish. */ prepareForKernel() { this.stop() try { stdout.write(CLEAR + SHOW_CURSOR + RESET) } catch { /* ignore */ } }, /** Show error then restore cursor. */ fail(msg) { this.stop() try { stdout.write(CLEAR + SHOW_CURSOR + MAGENTA + msg + RESET + '\n') } catch { /* ignore */ } } } }