458 lines
12 KiB
JavaScript
458 lines
12 KiB
JavaScript
/**
|
||
* 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 */
|
||
}
|
||
}
|
||
}
|
||
}
|