/** * TTY boot splash: centered card, framed log + bar, responsive width, 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' /** Move cursor to top-left without erasing (avoids full-screen flash on each tick). */ const CURSOR_HOME = '\x1b[H' /** Clear from cursor to end of screen (after redraw, drops stale lines if layout shrank). */ const CLEAR_FROM_CURSOR_DOWN = '\x1b[0J' /** Erase from cursor to end of line (removes leftover text when layout shifts). */ const EL_TO_END = '\x1b[K' /** Erase entire display row (blank spacer rows must use this, not bare `\\n`). */ const EL_WHOLE_LINE = '\x1b[2K' const HIDE_CURSOR = '\x1b[?25l' const SHOW_CURSOR = '\x1b[?25h' /** Max outer width of the framed card on very wide terminals (bar + borders). */ const MAX_CARD_OUTER = 68 /** Spinner/shimmer refresh; full redraw without CLEAR each tick — keep modest to limit flicker. */ const TICK_MS = 140 const RESET = '\x1b[0m' const DIM = '\x1b[2m' const BOLD = '\x1b[1m' const GREEN = '\x1b[32m' const MAGENTA = '\x1b[35m' const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] const TITLE_SHIMMER_PERIOD_SEC = 7.5 const TITLE_SHIMMER_SIGMA = 2.8 const TITLE_BASE_RGB = [58, 62, 72] const TITLE_PEAK_RGB = [238, 240, 245] const TITLE_LABEL = '◆ BARE OS ◆' const BOX = { tl: '┌', tr: '┐', bl: '└', br: '┘', lj: '├', rj: '┤', h: '─', v: '│' } /** * @param {string} s */ function stripAnsiForWidth(s) { return String(s).replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') } /** * @param {string} s */ function visibleWidth(s) { return stripAnsiForWidth(s).length } /** * @param {string} line * @param {number} cols */ function centerLine(line, cols) { const w = visibleWidth(line) const pad = Math.max(0, Math.floor((cols - w) / 2)) return ' '.repeat(pad) + line } /** * @param {string} text * @param {number} maxW * @returns {string[]} */ function wrapPlainText(text, maxW) { const t = String(text).trim() if (!t) return [''] if (maxW < 4) return [t.slice(0, maxW)] const words = t.split(/\s+/) /** @type {string[]} */ const out = [] let cur = '' for (const w of words) { if (!w) continue if (!cur) { if (w.length <= maxW) cur = w else { out.push(w.slice(0, maxW)) cur = '' } continue } const next = cur + ' ' + w if (next.length <= maxW) cur = next else { out.push(cur) cur = w.length <= maxW ? w : w.slice(0, maxW) } } if (cur) out.push(cur) return out.length ? out : [''] } /** * @param {string} plain * @param {number} innerW */ function fitPlainRightPad(plain, innerW) { const s = String(plain) if (s.length >= innerW) return s.slice(0, innerW) return s + ' '.repeat(innerW - s.length) } /** * @param {string} ansiContent * @param {number} innerW visible width target between │ … │ */ function frameInnerRow(ansiContent, innerW) { const vis = visibleWidth(ansiContent) const pad = Math.max(0, innerW - vis) return `${BOX.v} ${ansiContent}${' '.repeat(pad)} ${BOX.v}` } /** * One TTY row for splash redraw: clear trailing junk; blank rows clear the whole line. * Without this, `CURSOR_HOME` redraws leave ghost lines when top padding or block height changes. * @param {string} line */ function splashLineWithClear(line) { if (!line) return EL_WHOLE_LINE return line + EL_TO_END } /** * Truecolor title line with a slow moving highlight. * @param {number} tSec */ function formatTitleShimmer(tSec) { const label = TITLE_LABEL 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 * tagline?: string * footerLines?: string[] * }} [opts] */ export function createBootSplash(stdout, opts = {}) { const bootLimitMs = opts.bootLimitMs ?? 60_000 const taglineOpt = opts.tagline != null && String(opts.tagline).trim() ? String(opts.tagline).trim() : 'P2P-first system · Hyperswarm · Hyperdrive' const footerExtra = Array.isArray(opts.footerLines) && opts.footerLines.length ? opts.footerLines.map((s) => String(s).trim()).filter(Boolean) : [] 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 = [] /** First paint after start uses full clear; later ticks repaint from home only. */ let needsFullClear = true let lastCols = -1 let lastRows = -1 function elapsedSec() { return (Date.now() - t0) / 1000 } /** * Replace the last log line instead of pushing when updating rolling MBR status, * so heartbeat lines do not stack (e.g. repeated "MBR: still waiting…"). */ function shouldReplaceLastLogForMbrStatus(lastLine, nextMsg) { const n = String(nextMsg).trim() if (!n.startsWith('MBR:')) return false const L = String(lastLine).trim() if (L.startsWith('MBR:')) return true if (L.includes('Loading block 0') || L.includes('block 0 (MBR)')) return true return false } function computeLayout() { const cols = Math.max(24, Number(stdout.columns) || 80) const rows = Math.max(12, Number(stdout.rows) || 24) const hPad = cols >= 56 ? 8 : cols >= 44 ? 5 : 2 let cardOuter = Math.max(28, Math.min(cols - 2 * hPad, MAX_CARD_OUTER)) cardOuter = Math.min(cardOuter, cols) const innerW = Math.max(8, cardOuter - 4) const barVisW = innerW const reserved = 2 + 1 + 1 + 1 + 1 + 1 + 3 + 2 + 1 + footerExtra.length + 1 const maxLogLines = Math.min( 20, Math.max(4, Math.min(14, rows - reserved)) ) const useFrame = cols >= 36 return { cols, rows, cardOuter, innerW, barVisW, maxLogLines, useFrame } } function draw() { const el = elapsedSec() const sp = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] frame++ const { cols, rows, cardOuter, innerW, barVisW, maxLogLines, useFrame } = computeLayout() if (lastCols >= 0 && (cols !== lastCols || rows !== lastRows)) { needsFullClear = true } lastCols = cols lastRows = rows while (lines.length > maxLogLines) lines.shift() const progress = Math.min(1, el / (bootLimitMs / 1000)) const filled = Math.round(barVisW * progress) const barBody = GREEN + '█'.repeat(filled) + DIM + '░'.repeat(Math.max(0, barVisW - filled)) + RESET const title = formatTitleShimmer(el) const sub = `${DIM}${taglineOpt}${RESET}` /** @type {string[]} */ const block = [] block.push('') block.push(centerLine(title, cols)) block.push(centerLine(sub, cols)) block.push('') if (useFrame) { const topRule = BOX.tl + BOX.h.repeat(Math.max(0, cardOuter - 2)) + BOX.tr block.push(centerLine(topRule, cols)) const logSource = lines.length ? lines : ['—'] /** @type {string[]} */ const logExpanded = [] for (const l of logSource) { const wrapped = wrapPlainText(l, innerW - 2) for (const w of wrapped) logExpanded.push(w) } while (logExpanded.length > maxLogLines) logExpanded.shift() for (const w of logExpanded) { const inner = `${DIM}›${RESET} ${fitPlainRightPad(w, innerW - 2)}` block.push(centerLine(frameInnerRow(inner, innerW), cols)) } const midRule = BOX.lj + DIM + BOX.h.repeat(Math.max(0, cardOuter - 2)) + RESET + BOX.rj block.push(centerLine(midRule, cols)) const barRow = frameInnerRow(barBody, innerW) block.push(centerLine(barRow, cols)) const botRule = BOX.bl + BOX.h.repeat(Math.max(0, cardOuter - 2)) + BOX.br block.push(centerLine(botRule, cols)) } else { block.push(centerLine(DIM + '·'.repeat(Math.min(cardOuter, cols - 4)) + RESET, cols)) for (const l of lines.length ? lines : ['—']) { const wrapped = wrapPlainText(l, cols - 6) for (const w of wrapped) { block.push(centerLine(`${DIM}›${RESET} ${w}`, cols)) } } block.push(centerLine(DIM + '-'.repeat(Math.min(barVisW, cols - 6)) + RESET, cols)) block.push(centerLine(barBody, cols)) block.push(centerLine(DIM + '·'.repeat(Math.min(cardOuter, cols - 4)) + RESET, cols)) } block.push('') const head = `${BOLD}Boot${RESET} ${DIM}${el.toFixed(1)}s${RESET} ${sp} ` const headVis = visibleWidth(head) const firstWrapW = Math.max(12, cols - 4 - headVis) const phaseWrapped = wrapPlainText(phase, firstWrapW) phaseWrapped.forEach((w, i) => { const row = i === 0 ? head + w : ' '.repeat(Math.min(headVis, cols - 4)) + w block.push(centerLine(row, cols)) }) block.push('') block.push( centerLine( `${DIM}boot window ${(bootLimitMs / 1000).toFixed(0)}s${RESET}`, cols ) ) for (const fl of footerExtra) { block.push(centerLine(`${DIM}${fl}${RESET}`, cols)) } block.push('') const totalH = block.length const rawTop = Math.floor((rows - totalH) / 2) const topPad = Math.min(12, Math.max(0, rawTop)) const topSpacer = topPad > 0 ? Array.from({ length: topPad }, () => EL_WHOLE_LINE).join('\n') + '\n' : '' const art = topSpacer + block.map(splashLineWithClear).join('\n') let prefix if (needsFullClear) { prefix = CLEAR + HIDE_CURSOR needsFullClear = false } else { prefix = CURSOR_HOME } const out = prefix + art + CLEAR_FROM_CURSOR_DOWN try { stdout.write(out) } catch { /* ignore */ } } return { start() { t0 = Date.now() frame = 0 phase = 'Initializing…' lines.length = 0 needsFullClear = true lastCols = -1 lastRows = -1 if (tick) clearInterval(tick) draw() tick = setInterval(draw, TICK_MS) }, stop() { if (tick) { clearInterval(tick) tick = null } }, /** @param {string} msg */ setPhase(msg) { phase = msg }, /** @param {string} msg */ log(msg) { const { maxLogLines } = computeLayout() const m = String(msg) if ( lines.length > 0 && shouldReplaceLastLogForMbrStatus(lines[lines.length - 1], m) ) { lines[lines.length - 1] = m } else { lines.push(m) while (lines.length > maxLogLines) lines.shift() } }, prepareForKernel() { this.stop() try { stdout.write(CLEAR + SHOW_CURSOR + RESET) } catch { /* ignore */ } }, fail(msg) { this.stop() try { const cols = Math.max(24, Number(stdout.columns) || 80) const wrapped = wrapPlainText(String(msg), cols - 4) const body = wrapped .map((ln) => centerLine(MAGENTA + ln + RESET, cols)) .join('\n') stdout.write(CLEAR + SHOW_CURSOR + '\n' + body + '\n') } catch { /* ignore */ } } } }