Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

dwarves-gui/examples/console_window.rs

//! **The Console pane in a REAL window, over a REAL guest.**
//!
//! `PaneKind::Console` is the one pane in the ⚒ scene that had never been looked at.
//! Every other claim about it was a state assertion, and a state assertion cannot see
//! the thing the design is actually about: that the **serial and the graphical console
//! are both up at the same time**, one above the other, with no toggle between them.
//! Two widgets that exist in `state_json` can still be laid out so that only one of
//! them is ever on screen, and no `cargo test` in this tree can tell.
//!
//! So this opens a real window and shows the real pane. Both halves are fed from a
//! guest that actually ran:
//!
//! ```text
//! DWARVES_CONSOLE_SERIAL=<file>   the guest's serial, as QEMU wrote it
//! DWARVES_CONSOLE_FRAME=<file>    a binary P6 PPM, as QMP `screendump` wrote it
//! ```
//!
//! Neither is synthesised. Without them the example REFUSES to open a window rather
//! than draw a placeholder — a picture of invented output is worse than no picture,
//! because it looks exactly like the real one.
//!
//! # The recipe (never on the operator's own screen)
//!
//! ```text
//! Xvfb :99 -screen 0 1920x1200x24 -nolisten tcp &
//!
//! qemu-system-x86_64 -machine q35,accel=kvm -m 1024 -smp 2 -display none \
//!   -drive if=pflash,format=raw,unit=0,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd \
//!   -drive if=pflash,format=raw,unit=1,file=$W/vars.fd \
//!   -cdrom <an appliance .iso> \
//!   -serial file:$W/serial.log \
//!   -qmp unix:$W/qmp.sock,server=on,wait=off &
//! # then, over QMP: {"execute":"screendump","arguments":{"filename":"$W/shot.ppm"}}
//!
//! env -u WAYLAND_DISPLAY DISPLAY=:99 \
//!   DWARVES_CONSOLE_SERIAL=$W/serial.log DWARVES_CONSOLE_FRAME=$W/shot.ppm \
//!   ROBOTUI_WATCH=1 ROBOTUI_WATCH_SHOT=$W/console.png \
//!   ROBOTUI_WATCH_REPORT=$W/console.json \
//!   cargo run -p dwarves-gui --features watch --example console_window
//! ```
//!
//! `ROBOTUI_WATCH_SHOT` is the window's own presented frame; `<shot>.compositor.png`
//! beside it is what the X server saw. Two captures of one window, and they have to
//! agree — a toolkit screenshot of a window nothing ever mapped would not.
//!
//! Xvfb, always: a GUI must never be popped onto whoever is at the keyboard.

use std::collections::BTreeMap;
use std::path::PathBuf;

use dwarves_gui::build_scene::{
    Act, Axis, BuildScene, Forge, Menu, Outcome, Phase, Query, Report, Row, Screen, Target,
};
use facett_core::Facet;
use nornir_build_thing::contract::Coordinate;
use nornir_build_thing::variant::ArtifactKind;
use nornir_robotui::{run_scenario_mode, Action, Assert, RunMode, Scenario, Step, WatchConfig};

/// The row and column the window lands on. Named once so the scenario below and the
/// forge cannot drift apart.
const ROW: &str = "appliance";
const COLUMN: &str = "iso";

/// A forge over ONE already-finished build, so the pane below has a subject.
///
/// It runs nothing: the guest ran before this process started, and its output is read
/// off disk. That is the point — the window is showing a console, not producing one.
struct Landed {
    outcome: Outcome,
    lines: Vec<String>,
    screen: Screen,
}

impl Forge for Landed {
    fn axis(&mut self, _pick: &dwarves_gui::build_scene::AxisPick) -> Axis {
        // No manifests behind this, so no dependency picture — said out loud rather
        // than drawn empty.
        Axis::Absent
    }

    fn resolve(&mut self, _query: &Query) -> Result<Menu, String> {
        Ok(Menu {
            coordinate: Coordinate::parse("a-build-thing:0.1.0").expect("a literal coordinate"),
            how: "en färdig artefakt".to_string(),
            rows: vec![Row {
                name: ROW.to_string(),
                badges: vec!["ORIGINAL".to_string()],
                targets: vec![Target {
                    name: COLUMN.to_string(),
                    kind: ArtifactKind::Iso,
                    pane: None,
                    declared: true,
                    variant: None,
                    settings: Vec::new(),
                }],
            }],
        })
    }

    fn act(&mut self, _act: Act) {}

    fn poll(&mut self) -> Report {
        Report {
            phase: Some(Phase::Done),
            elapsed_ms: 0,
            outcome: Some(self.outcome.clone()),
            // The guest is up: that is what puts ↻ and ✕ in reach and is the state an
            // operator watches an install in.
            live: true,
            lines: self.lines.clone(),
            screen: Some(self.screen.clone()),
            note: None,
            menu: None,
        }
    }
}

/// Read a binary **P6** PPM — what QMP `screendump` writes — into a [`Screen`].
///
/// Strict on purpose. A truncated or mis-declared frame must fail here, where the
/// message can say so, rather than downstream where the pane would draw a band of
/// garbage that still looks like a screen.
fn read_ppm(path: &PathBuf) -> Result<Screen, String> {
    let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
    let mut fields: Vec<usize> = Vec::new();
    let mut at = 0usize;
    if !raw.starts_with(b"P6") {
        return Err(format!("{} is not a binary P6 PPM", path.display()));
    }
    at += 2;
    // width, height, maxval — whitespace-separated, `#` comments skipped.
    while fields.len() < 3 {
        while at < raw.len() && raw[at].is_ascii_whitespace() {
            at += 1;
        }
        if at < raw.len() && raw[at] == b'#' {
            while at < raw.len() && raw[at] != b'\n' {
                at += 1;
            }
            continue;
        }
        let start = at;
        while at < raw.len() && raw[at].is_ascii_digit() {
            at += 1;
        }
        if start == at {
            return Err(format!(
                "{}: header ended before three numbers",
                path.display()
            ));
        }
        let n: usize = std::str::from_utf8(&raw[start..at])
            .map_err(|e| e.to_string())?
            .parse()
            .map_err(|_| format!("{}: bad header number", path.display()))?;
        fields.push(n);
    }
    at += 1; // exactly one whitespace byte separates the header from the pixels
    let (w, h) = (fields[0], fields[1]);
    let want = w * h * 3;
    let rgb = raw.get(at..at + want).ok_or_else(|| {
        format!(
            "{}: {w}x{h} needs {want} pixel bytes, the file has {}",
            path.display(),
            raw.len() - at
        )
    })?;
    let mut rgba = Vec::with_capacity(w * h * 4);
    for px in rgb.chunks_exact(3) {
        rgba.extend_from_slice(&[px[0], px[1], px[2], 255]);
    }
    Ok(Screen {
        width: w,
        height: h,
        rgba,
    })
}

/// The guest's serial, as a line list. Control bytes that a terminal would have acted
/// on are dropped rather than rendered as boxes; nothing else is touched.
fn read_serial(path: &PathBuf) -> Result<Vec<String>, String> {
    let raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
    let text = String::from_utf8_lossy(&raw);
    let lines: Vec<String> = text
        .lines()
        .map(strip_ansi)
        .filter(|l| !l.trim().is_empty())
        .collect();
    if lines.is_empty() {
        return Err(format!("{} has no serial output in it", path.display()));
    }
    Ok(lines)
}

/// Drop the CSI/OSC escape sequences a terminal would have ACTED on rather than
/// shown. The firmware clears the screen and resizes the terminal a dozen times
/// before the kernel says a word, and those bytes are instructions, not output —
/// rendered literally they are the first thing on screen and none of it is the
/// guest's. Every printable byte is kept exactly as the guest wrote it.
fn strip_ansi(line: &str) -> String {
    const ESC: char = '\u{1b}';
    let mut out = String::with_capacity(line.len());
    let mut chars = line.chars().peekable();
    while let Some(c) = chars.next() {
        if c != ESC {
            if !c.is_control() {
                out.push(c);
            }
            continue;
        }
        // CSI is `ESC [ … <final byte in @..~>`; anything else is a two-byte escape.
        if chars.peek() == Some(&'[') {
            chars.next();
            for f in chars.by_ref() {
                if ('\u{40}'..='\u{7e}').contains(&f) {
                    break;
                }
            }
        } else {
            chars.next();
        }
    }
    out
}

fn required(var: &str) -> Result<PathBuf, String> {
    std::env::var(var)
        .map(PathBuf::from)
        .map_err(|_| format!("{var} is not set — see this example's header for the recipe"))
}

fn main() -> Result<(), String> {
    let serial = required("DWARVES_CONSOLE_SERIAL")?;
    let frame = required("DWARVES_CONSOLE_FRAME")?;
    let lines = read_serial(&serial)?;
    let screen = read_ppm(&frame)?;
    let artifact =
        std::env::var("DWARVES_CONSOLE_ARTIFACT").unwrap_or_else(|_| frame.display().to_string());
    let size = std::fs::metadata(&frame).ok().map(|m| m.len());

    eprintln!(
        "dwarves-gui: console_window — {} serial line(s), a {}x{} frame, on {}",
        lines.len(),
        screen.width,
        screen.height,
        nornir_robotui::DisplayBackend::detect().detail()
    );

    let forge = Landed {
        outcome: Outcome {
            ok: true,
            artifact,
            size_bytes: size,
            sha: None,
            receipt: None,
            proof: None,
            elapsed_ms: 0,
            ports: Vec::new(),
            note: Some("konsolen läses ur en gäst som redan kört".to_string()),
            // A replayed console applied no per-site parameters (ae7b994 added the
            // record to `Outcome`; this example, built by nothing by default, did not follow).
            applied: Vec::new(),
            // Replayed, not built: no cargo features were chosen here.
            features: None,
            // Replayed, not built here — so nothing was ever offered to a warehouse.
            // Said in the writer's own words rather than left blank, because a blank
            // register row is exactly the silence this field was added to end.
            warehouse_line: "build records: NOT SUBMITTED — this console is a replay, \
                             no build ran here"
                .to_string(),
            warehouse_red: false,
            warehouse_landed: false,
        },
        lines,
        screen,
    };

    // Two clicks, both real, and the second is what opens the pane. Asserting the
    // pane's own state here as well as capturing it means a screenshot of the wrong
    // pane cannot pass for the right one.
    let scenario = Scenario::new("console-window")
        // Steps 1 and 2 of the scene's own flow. The matrix does not exist before a
        // resolve, so a driver that clicked a row first would click nothing — which
        // is exactly what the first run of this example did.
        .step(Step::new("name the artifact").action(Action::TypeInto {
            label: "artefakt".to_string(),
            text: ROW.to_string(),
        }))
        .step(
            Step::new("resolve it")
                .action(Action::ClickLabel("sök".to_string()))
                .assert(Assert::StateContains {
                    pointer: "/rows".to_string(),
                    needle: ROW.to_string(),
                }),
        )
        .step(
            Step::new("pick the row")
                .action(Action::ClickLabel(ROW.to_string()))
                .assert(Assert::State {
                    pointer: "/row".to_string(),
                    expected: serde_json::json!(ROW),
                }),
        )
        .step(
            Step::new("pick the column")
                .action(Action::ClickLabel(COLUMN.to_string()))
                .assert(Assert::State {
                    pointer: "/pane".to_string(),
                    expected: serde_json::json!("console"),
                }),
        )
        // BOTH consoles, in one pane, at the same time. Two is the whole claim: a
        // toggle would put one here, and a `1` in the report is this window failing.
        .step(Step::new("both consoles are up").assert(Assert::State {
            pointer: "/consoles_up".to_string(),
            expected: serde_json::json!(2),
        }))
        .step(
            Step::new("the graphical half has a real frame").assert(Assert::StateContains {
                pointer: "/screen".to_string(),
                needle: "\"width\"".to_string(),
            }),
        );

    let mut cfg = WatchConfig::from_env();
    cfg.title = "dwarves ⚒ — console".to_string();
    // Tall enough that the serial box AND the framebuffer below it are both on screen
    // at once. That is the whole claim, so the window must not be the reason it holds.
    cfg.size = egui::vec2(1500.0, 1400.0);

    let outcome = run_scenario_mode(
        SceneApp(BuildScene::new(Box::new(forge))),
        |ui, app: &mut SceneApp, _targets| Facet::ui(&mut app.0, ui),
        &scenario,
        "dwarves-gui",
        RunMode::Watch,
        cfg,
    )
    .map_err(|e| e.to_string())?;
    match outcome {
        nornir_robotui::RunOutcome::Watched => {
            eprintln!("dwarves-gui: console_window — the watch window ran and closed");
        }
        nornir_robotui::RunOutcome::Headless(_) => {
            return Err(
                "this example only makes sense in watch mode: set ROBOTUI_WATCH=1".to_string(),
            )
        }
    }
    Ok(())
}

/// The scene as the driver's subject, exposing the same `state_json` the shell does.
struct SceneApp(BuildScene);

impl nornir_robotui::Observable for SceneApp {
    fn state_json(&self) -> serde_json::Value {
        let mut v = Facet::state_json(&self.0);
        // A driver reading the report wants the one fact this window exists to show,
        // without walking the whole blob for it.
        if let Some(map) = v.as_object_mut() {
            map.insert(
                "consoles_up".to_string(),
                serde_json::json!(map
                    .get("consoles")
                    .and_then(|c| c.as_array())
                    .map_or(0, Vec::len)),
            );
        }
        v
    }
}

/// Keeps `BTreeMap` in the imports honest — the scene's settings map type, named so a
/// reader of this example knows what `settings` on an [`Act::Build`] would carry.
#[allow(dead_code)]
type Settings = BTreeMap<String, String>;