Files
bare-operating-system/packages/bare-os-booter/lib/boot/boot-splash.js
T
2026-08-18 18:11:28 -04:00

458 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<typeof setInterval> | 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 */
}
}
}
}