187 lines
4.3 KiB
JavaScript
187 lines
4.3 KiB
JavaScript
/**
|
||
* 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<typeof setInterval> | 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 */
|
||
}
|
||
}
|
||
}
|
||
}
|