ajhahn.de
← FlashOS
Python 152 lines
#!/usr/bin/env python3
# boot_demo_typing.py — replays the FlashOS boot + fsh session for the VHS
# recorder that renders assets/boot_demo.gif.
#
# Three cadences, so the demo reads like a real console session:
#   * TYPED lines — what a user types at a prompt: the username at `login:`,
#     the masked password, and each `$ ` command (`help`, `ls`). The prompt
#     prints instantly, then the typed text appears key-by-key.
#   * COMMAND OUTPUT — a `$ ` command's result (the `help` text, the `ls`
#     listing): dumped as one BLOCK, the whole thing at once, the way a real
#     console paints command output — not line by line.
#   * PRINTED lines — boot `[ OK ]` status and the login/Password handshake:
#     emitted whole, one line at a time, as they scroll past at boot.
#
# Provenance: the boot block mirrors the current kernel's Mini-UART output
# (console_ui boot tags, `color` on — only the inner `OK` word is green, the
# brackets stay the default fg; no trailing periods on the `[ OK ]` lines).
# The post-login fsh session is RECONSTRUCTED from the programs' own byte
# output (login_elf's `login:` / masked `Password:`, fsh's homescreen banner
# + `help`, and `/bin/ls` of the initramfs root): the login gate blocks
# capturing the authenticated session under QEMU (the guest console receives
# no host stdin). Every byte emitted here is what those programs print — only
# the pacing and the green OK tint are added.
#
# The replayed content lives in scripts/boot_demo_session.txt (committed),
# not in boot.log: boot.log is gitignored and gets overwritten/truncated by
# each `picapture` run, which silently drops the reconstructed tail.

import sys, time, re, os

SESSION = os.path.join(os.path.dirname(__file__), "boot_demo_session.txt")
ZON = os.path.join(os.path.dirname(__file__), "..", "build.zig.zon")

BOOT_LINE = 0.16        # pause after a printed boot/handshake line
AFTER_PROMPT = 0.30     # pause after a prompt prints, before typing starts
KEY = 0.06              # seconds per typed character
AFTER_CMD = 0.45        # pause after a typed command line, before its output
BLOCK_PAUSE = 0.55      # pause after a command's output block is dumped
HOLD = 5.0              # hold the final live-prompt frame at the end

# Boot status tags carry color in the real console (console_ui palette,
# `color` on): only the inner `OK` word is tinted green, the brackets keep
# the default fg. session.txt holds the plain `[ OK ]` text; the green is
# added here at emit time so the file stays readable and the escape is
# spelled once, mirroring console_ui.writeTag.
GREEN = "\x1b[32m"
RESET = "\x1b[0m"


def colorize(line):
    return line.replace("[ OK ]", "[ " + GREEN + "OK" + RESET + " ]")

# A line a user types: a prompt prefix + the typed text. `login: ` (the
# username), `Password: ` (the kernel masks each keystroke with '*', so the
# masked `*****` types out char-by-char), and each `$ ` command. A bare `# `
# line is cat/help output, not a prompt, so it is not matched.
TYPED = re.compile(r'^(login: |Password: |\$ )(\S.*)$')


def zon_version():
    # Single-source the banner version from build.zig.zon (the one truth, the
    # same field fsh derives its homescreen version from via build_options) so
    # the demo's `FlashOS [v…]` line never drifts from the shipped release.
    with open(ZON, "r", encoding="utf-8") as f:
        m = re.search(r'\.version\s*=\s*"([^"]+)"', f.read())
    return m.group(1) if m else "?"


def out(s):
    sys.stdout.write(s)
    sys.stdout.flush()


def is_comment(line):
    return line.lstrip().startswith("#")


def type_command(prompt, text):
    out(prompt)                         # prompt — instant
    time.sleep(AFTER_PROMPT)
    for ch in text:                     # typed text — key by key
        out(ch)
        time.sleep(KEY)
    out("\r\n")
    time.sleep(AFTER_CMD)


def main():
    # Clear the screen (+ scrollback) and home the cursor before the first
    # boot byte, so the GIF opens on the boot output at the top-left — not on
    # the launch command the VHS tape typed (hidden) to start this replay.
    # It also gives the final `reboot` a clean loop seam: when the GIF wraps,
    # the screen is wiped just as a real machine reset would clear it.
    out("\x1b[2J\x1b[3J\x1b[H")
    with open(SESSION, "rb") as f:
        lines = [ln.rstrip("\r") for ln in
                 f.read().decode("utf-8", "replace").split("\n")]
    if lines and lines[-1] == "":
        lines.pop()
    version = zon_version()
    lines = [ln.replace("{{VERSION}}", version) for ln in lines]

    # The trailing line is the live prompt waiting for input — the `$ ` shell
    # prompt after the last command, or a re-spawned `login:`. Print it
    # without a newline and hold, so the GIF ends on the live prompt (cursor
    # sitting on it) rather than scrolling past it.
    final = None
    if lines and lines[-1].rstrip() in ("login:", "$"):
        final = lines.pop()

    i, n = 0, len(lines)
    while i < n:
        line = lines[i]
        m = TYPED.match(line)
        if m:
            type_command(m.group(1), m.group(2))
            i += 1
            # A `$ ` command's output is everything up to the next typed
            # line. It paints in runs: consecutive comment (`#`) lines form
            # one block and the file's actual command lines (an rc's `cd /`)
            # form their own — so `cat`ing a commented file shows its
            # comment header at once, then each command line as its own
            # entry, instead of one glued blob; a plain listing (`ls`, with
            # no comments) stays a single block. The `login:` handshake
            # (m.group(1) != "$ ") is left to the line-by-line path below.
            if m.group(1) == "$ ":
                block = []
                while i < n and not TYPED.match(lines[i]):
                    block.append(lines[i])
                    i += 1
                k = 0
                while k < len(block):
                    is_c = is_comment(block[k])
                    j = k
                    while j < len(block) and is_comment(block[j]) == is_c:
                        j += 1
                    out("\r\n".join(block[k:j]) + "\r\n")
                    time.sleep(BLOCK_PAUSE)
                    k = j
        else:
            out(colorize(line) + "\r\n")  # boot / handshake — whole line, OK tinted
            time.sleep(BOOT_LINE)
            i += 1

    if final is not None:
        out(final + " ")                # waiting prompt, no newline
    time.sleep(HOLD)


if __name__ == "__main__":
    main()