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>;