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

nordisk/dwarves

dwarves-gui/src/main.rs

//! **dwarves-gui (binary)** — the thin HOST for the `dwarves_gui` library.
//!
//! The dwarves CI/CD forge shell mounted on the reusable [`facett_wasm_demo`] harness:
//! one shell, two hosts, from the same source —
//!
//! - **native:** `cargo run -p dwarves-gui`
//! - **wasm:**   `cargo xtask demo-wasm` (build) / `cargo xtask deploy-pages` (publish),
//!   served at <https://nordisk.codeberg.page/dwarves>
//!
//! ## This file declares NO modules
//! Every module lives in the LIBRARY (`src/lib.rs`) and this binary CONSUMES it. It used
//! to re-declare them all itself, which compiled each file TWICE into two separate crates
//! — a lib/bin TWIN whose two `build_native_app`s then silently DRIFTED (the lib copy lost
//! the fat/embedded funnel default and 9 whole tabs, and the compiler could not see it).
//! The twin is dead: there is ONE `dwarves_gui::build_native_app`, which `main` runs and
//! `tests/robot_scene.rs` drives, so the robot suite tests the shell that actually ships.

use facett_wasm_demo::FacetApp;

#[cfg(not(target_arch = "wasm32"))]
use dwarves_gui::build_native_app;

// `ForgeView` is mounted directly only by the wasm web host (`start`); the native tabbed
// shell references it through the library, so this import is wasm-only.
#[cfg(target_arch = "wasm32")]
use dwarves_gui::panel::ForgeView;

// The BUILD RECEIPT, in the BINARY's own root — a library is an rlib and the linker
// never pulls a member nothing references, so a receipt in the lib is a receipt in no
// artifact. `#[used]` does not help there; measured on dwarves-cli.
//
// Native-only: the wasm host is not a delivered artifact and carries no ELF section.
#[cfg(not(target_arch = "wasm32"))]
nornir_build_thing::embed_build_receipt!();

// ── native host ──────────────────────────────────────────────────────────────
/// **THE VARIANT — this artifact's own `[[bin]]` name.**
///
/// `dwarves-ui`, `dwarves-ui-gpu` and `dwarves-solo` are three `[[bin]]`s off THIS ONE
/// `main.rs`, so every "which build am I" answer in this file has to come from a value
/// that is per-TARGET. `CARGO_BIN_NAME` is the only one there is.
///
/// It is NOT `cfg!(feature = "gpu")`. Feature resolution is per-PACKAGE: building
/// `dwarves-ui` and `dwarves-ui-gpu` in one cargo invocation turns `gpu` on for the
/// package, so BOTH binaries would answer "gpu". Proven on korp 2026-08-27 with
/// `cfg!(feature = "map3d-gpu")`, which named "gpu" for both artifacts of the pair.
///
/// MEASURED on t14s 2026-08-27, which is why this constant exists: `dwarves-ui` and
/// `dwarves-ui-gpu` in `/usr/local/bin` were byte-identical (both sha256
/// `6eb9ec98717b270e…`) and NOTHING either file printed could tell them apart.
#[cfg(not(target_arch = "wasm32"))]
const VARIANT: &str = env!("CARGO_BIN_NAME");

/// **Does THIS artifact own the GPU lane?** True iff its own name ends in `-gpu`.
///
/// The renderer axis is declared by the BIN NAME (`dwarves-ui` vs `dwarves-ui-gpu`), and
/// after the measurement in the `gpu` wiring block below that is the only place it can be
/// read from: the `gpu` cargo feature is true in BOTH artifacts whenever they are built
/// in one invocation, so a `cfg!` cannot answer this. `dwarves_gui::gpu_lane_armed_for`
/// holds the rule so it is testable without a build of either binary.
#[cfg(not(target_arch = "wasm32"))]
// A build WITHOUT the `gpu` feature has no lane to gate, so the constant is unread
// there. It is still computed and still correct — the `allow` is scoped to that build
// so the gate itself can never be silently deleted as dead code.
#[cfg_attr(not(feature = "gpu"), allow(dead_code))]
const GPU_LANE: bool = dwarves_gui::gpu_lane_armed_for(VARIANT);

#[cfg(not(target_arch = "wasm32"))]
fn main() {
    // ── THE PANIC RECORD, installed before anything can panic ────────────────────
    //
    // MEASURED on t14s 2026-08-27: `dwarves-ui` crashed and left **nothing** — no panic
    // message, no backtrace, no file, no journal line. The owner had to report the crash
    // himself, and there was no artifact on that machine to look at afterwards.
    //
    // Three reasons, each sufficient on its own, and all three are what the hook fixes:
    // a GUI's stderr goes nowhere (desktop launcher / unit / double-click), so the
    // default hook's message is emitted into a closed stream; `RUST_BACKTRACE` is unset
    // for such a launch, so even a captured stderr would have said "run with
    // RUST_BACKTRACE=1" instead of naming a frame; and nothing recorded WHICH build
    // crashed.
    //
    // It is FIRST in `main` on purpose — before `--version`, before arg parsing, before
    // any robot mount — because a panic in argument handling is still a crash an
    // operator has to be able to see. It costs one file-less env read at startup.
    //
    // `install_crash_hook!` expands `CARGO_BIN_NAME` HERE, in the binary crate, so the
    // report names the VARIANT and not the package (see `VARIANT` above).
    let crash =
        nornir_crashlog::install_crash_hook!(env!("CARGO_PKG_VERSION"), env!("DWARVES_GIT_HASH"));

    // ── `--version` / `-V`: print the build stamp and EXIT before ANY boot. ─────────
    // The LAW: an operator must always be able to tell WHICH build is running. This runs
    // before every other mode (robot mounts, window bring-up) so it never boots first.
    //
    // The stamp names THIS ARTIFACT (`dwarves-ui-gpu v0.1.4 (…)`), not the package — see
    // `dwarves_gui::version_line_for`. Until 2026-08-27 all three bins printed
    // `dwarves v…`, which is why two byte-identical files on t14s could each be asked
    // what they were and answer the same thing.
    if std::env::args()
        .skip(1)
        .any(|a| a == "--version" || a == "-V")
    {
        println!("{}", dwarves_gui::version_line_for(VARIANT));
        return;
    }

    // Where a crash will be recorded, said OUT LOUD at startup — on stderr, beside the
    // `HOST →` line, because the operator who needs the report is the one who is not
    // sitting at the machine. It is below `--version` so the stamp stays the whole of
    // stdout (`robot_version_stamp` asserts exactly that), and this line is stderr
    // regardless.
    eprintln!("{}", crash.announce());

    // ── `DWARVES_UI_PANIC_PROBE` — the deliberate crash, so the record has a RED ──
    //
    // A crash record whose only evidence is "no crash has happened since we added it" is
    // not evidence. This env var makes the UI panic ON PURPOSE, in each of the three
    // places a UI actually dies, and `tests/crash_record.rs` drives the REAL binary
    // through all three and reads the file back off disk.
    //
    // It is not behind a cargo feature deliberately: the SHIPPED binary is the one that
    // has to be provable. It reads one env var that nobody sets, and the alternative — a
    // feature — would mean the artifact under test is not the artifact that ships, which
    // is the whole class of defect this lane is closing.
    //
    //   startup — panic on the main thread before the window opens
    //   thread  — panic on a NAMED worker while the process lives on
    //   render  — panic INSIDE eframe's own callback, i.e. on the winit event loop,
    //             which is where an egui UI really dies. This is the case a hook could
    //             plausibly miss (eframe/winit `catch_unwind` the callback), and the
    //             reason it does not is that a panic HOOK runs at panic time, before any
    //             unwinding or catching. Asserted, not assumed.
    let panic_probe = std::env::var("DWARVES_UI_PANIC_PROBE").unwrap_or_default();
    if panic_probe == "startup" {
        panic!("DWARVES_UI_PANIC_PROBE=startup — deliberate main-thread crash");
    }
    if panic_probe == "thread" {
        let h = std::thread::Builder::new()
            .name("dwarves-ui-worker".to_string())
            .spawn(|| panic!("DWARVES_UI_PANIC_PROBE=thread — deliberate worker crash"))
            .expect("spawn the panic probe thread");
        // A panicking thread does NOT end the process — which is exactly why the file
        // matters: the window would stay up with the failure invisible.
        let _ = h.join();
        std::process::exit(101);
    }

    // ── robot-remote MOUNT (feature-gated) ───────────────────────────────────────
    // `dwarves-gui --robot-remote <socket>` binds a `cmd_json::CmdJsonServer` over the
    // egui shell on `<socket>` (its own blocking I/O — NO thread::spawn) so edda's fleet
    // can drive dwarves WIRE-REMOTE, instead of opening the GUI window. OFF by default:
    // the shipped binary never mounts a socket.
    #[cfg(feature = "robot-remote")]
    {
        let mut args = std::env::args().skip(1);
        while let Some(a) = args.next() {
            // `dwarves-gui scenarios --emit` — print the recorded per-tab robot
            // SCENARIO LIBRARY as `serde_json` (the SAME `wire` form the fleet's
            // `CmdJsonClient` drives) to stdout and exit. This is the DATA seam edda's
            // `fleet` loads by member name (`$FLEET_SCENARIOS_DIR/dwarves.scenarios.json`)
            // so `RobotDriveMember` drives dwarves' real recorded steps instead of the
            // bare liveness probe — WITHOUT the fleet build-depping this binary. The
            // `--emit` token is optional (the subcommand's only job is to emit).
            if a == "scenarios" {
                let scenarios = dwarves_gui::scenarios::dwarves_tab_scenarios();
                match serde_json::to_string_pretty(&scenarios) {
                    Ok(json) => {
                        println!("{json}");
                        return;
                    }
                    Err(e) => {
                        eprintln!("dwarves-gui: failed to serialize scenarios: {e}");
                        std::process::exit(1);
                    }
                }
            }
            if a == "--robot-remote" {
                let Some(path) = args.next() else {
                    eprintln!("dwarves-gui: --robot-remote needs a <socket> path");
                    std::process::exit(2);
                };
                if let Err(e) =
                    dwarves_gui::robot_remote::serve_robot_remote(std::path::Path::new(&path))
                {
                    eprintln!("dwarves-gui: robot-remote server failed: {e}");
                    std::process::exit(1);
                }
                return;
            }
        }
    }

    // ── `--robot-mode`: the robot modes, decided in ONE place ───────────────────
    // `dwarves_gui::robot_mode::requested()` owns the whole decision (see that module
    // for why): `headless` drives the recorded tab tour OFF-SCREEN and EXITS NON-ZERO on
    // a red step; `watch` opens a REAL, paced eframe window and replays the same steps so
    // a human watches the robot navigate every tab; an unknown mode is REFUSED.
    //
    // The refusal is the point. This dispatch used to accept `watch` and silently ignore
    // every other value, so `--robot-mode headless` — the documented headless
    // invocation — fell through to the GUI bring-up below and opened a window on the
    // operator's display. A headless flag that opens a window is worse than no flag.
    #[cfg(any(feature = "robot-remote", feature = "watch"))]
    {
        use dwarves_gui::robot_mode::{self, RobotRun};
        match robot_mode::requested() {
            Err(e) => {
                eprintln!("dwarves-gui: {e}");
                std::process::exit(2);
            }
            Ok(Some(RobotRun::Headless)) => {
                // `--robot-dump <dir>`: the LIVE tour — the same off-screen shell against
                // the REAL forge at `$DWARVES_SERVER`, every pane's `state_json` written
                // to <dir>. Without it: the recorded tour over the hermetic shell.
                let run = match robot_mode::dump_dir() {
                    Some(dir) => {
                        let endpoint = std::env::var("DWARVES_SERVER")
                            .unwrap_or_else(|_| dwarves_embed::DEFAULT_ENDPOINT.to_string());
                        robot_mode::run_headless_live(&endpoint, &dir)
                    }
                    None => robot_mode::run_headless(),
                };
                match run {
                    // A red step must be a red EXIT. The drive prints every failure
                    // above; exiting 0 here would be the `nornir-bench` silent green
                    // ("passed": false, exit 0) rebuilt in the UI harness.
                    Ok(0) => return,
                    Ok(red) => {
                        eprintln!("dwarves-gui: robot-mode headless FAILED — {red} red step(s)");
                        std::process::exit(1);
                    }
                    Err(e) => {
                        eprintln!("dwarves-gui: robot-mode headless failed: {e}");
                        std::process::exit(1);
                    }
                }
            }
            Ok(Some(RobotRun::Watch(scenario))) => {
                // The visible window lives behind `watch` (it links eframe/winit). Asked
                // for without that feature, SAY so and exit — never fall through to the
                // normal GUI, which would look like the watch replay and drive nothing.
                #[cfg(feature = "watch")]
                {
                    if let Err(e) = dwarves_gui::robot_watch::run_robot_watch(scenario.as_deref()) {
                        eprintln!("dwarves-gui: robot-watch failed: {e}");
                        std::process::exit(1);
                    }
                    return;
                }
                #[cfg(not(feature = "watch"))]
                {
                    let _ = scenario;
                    eprintln!(
                        "dwarves-gui: --robot-mode watch needs the `watch` feature \
                         (rebuild with `--features watch`); refusing to open the plain GUI instead"
                    );
                    std::process::exit(2);
                }
            }
            Ok(None) => {}
        }
    }

    // A build with NEITHER robot feature has no drive to reach. Refuse the flags loudly
    // rather than opening the GUI, so `--robot-mode headless` against the wrong binary is
    // a visible error and not a window nobody asked for.
    #[cfg(not(any(feature = "robot-remote", feature = "watch")))]
    {
        if std::env::args()
            .skip(1)
            .any(|a| a == "--robot-mode" || a.starts_with("--robot-mode=") || a == "--robot-watch")
        {
            eprintln!(
                "dwarves-gui: this binary was built without the robot drive \
                 (rebuild with `--features robot-remote` for headless, `--features watch` \
                 for the visible replay); refusing to open the GUI instead"
            );
            std::process::exit(2);
        }
    }

    // ── HOST-ONLY. The UI knows ONE server address: the dwarves forge. ──────────
    //
    //   "dwarves UI skall ALDRIG default byggas med servern compiled in. dwarves UI
    //    skall alltid default vara thin och nyttja en dwarves server."
    //
    // The suite-host-embedded-auth LAW: nornir is EMBEDDED in the dwarves server (the
    // host). A UI is a client — it dials ONLY dwarves (`:7900`) and the host reaches its
    // embedded nornir by a DIRECT in-process call. The UI must NEVER dial nornir (`:7878`)
    // itself; that port is for EXTERNAL nornir consumers only. So there is exactly one
    // client knob here — `DWARVES_SERVER` (or `--server <url>`) — and NO `NORNIR_SERVER`:
    // the UI does not know nornir's address and never opens a socket to it.
    //
    // `--server <url>` is the runtime selector (the pattern doc's contract); the env var
    // `DWARVES_SERVER` is the same knob for a service unit. The build never decides for you.
    let flag_server = {
        let mut it = std::env::args();
        let mut found = None;
        while let Some(a) = it.next() {
            if a == "--server" {
                found = it.next();
            } else if let Some(v) = a.strip_prefix("--server=") {
                found = Some(v.to_string());
            }
        }
        found.filter(|v| !v.trim().is_empty())
    };

    // ── SOLO: the server is IN THIS PROCESS ──────────────────────────────────────
    // `dwarves-solo` is the `role = "solo"` atom of the fleet build matrix: *"the UI
    // with its server compiled in. One binary, nothing to install."* Under
    // `--features solo` this starts `dwarves::server::serve_all` — the EXACT function
    // `dwarves-server` calls, not a second bring-up — on a background runtime before the
    // window opens, so the UI's normal client seam finds a forge already listening.
    //
    // This does NOT weaken *"dwarves UI skall ALDRIG default byggas med servern compiled
    // in"*. That rule is about the DEFAULT, and it still holds: `dwarves-ui` is declared
    // without this feature and is thin. Solo is a third, opt-in artifact with its own bin
    // name, which is exactly the distinction the role axis exists to make legible — you
    // cannot get one when you asked for the other, because they are different files.
    //
    // It returns the endpoint the forge it just started is ACTUALLY listening on, and
    // that matters: the first solo probe (2026-08-03, Xvfb, alternate ports) started the
    // embedded forge on `:17900` and then printed `HOST → dwarves http://127.0.0.1:7900`
    // — the UI was dialling the compiled-in default while its own server sat on another
    // port. Silent on the default ports, wrong everywhere else, and precisely the
    // question a solo build is supposed to have no answer to.
    #[cfg(feature = "solo")]
    let embedded_forge = Some(start_embedded_forge());
    #[cfg(not(feature = "solo"))]
    let embedded_forge: Option<String> = None;

    // Precedence, and each step is a different KIND of answer:
    //   `--server`      an operator said so on this run
    //   $DWARVES_SERVER an operator said so in the unit/environment
    //   embedded forge  this process IS the forge (solo) — beats a compiled constant
    //   DEFAULT_ENDPOINT the compiled-in default, which is a guess
    // A solo user who explicitly points at a remote gets the remote; nobody gets a
    // constant when the running process knows the real address.
    let forge_endpoint = flag_server
        .or_else(|| {
            std::env::var("DWARVES_SERVER")
                .ok()
                .filter(|e| !e.trim().is_empty())
        })
        .or(embedded_forge)
        .unwrap_or_else(|| dwarves_embed::DEFAULT_ENDPOINT.to_string());
    let jera_ledger = dwarves_embed::default_ledger_root();

    // **Say which world this is, in the window title.** ONE forge endpoint; the
    // warehouse/Mímir data is served THROUGH that host (its embedded nornir), never by a
    // second address the UI dials on its own.
    let mode = format!("HOST → dwarves {forge_endpoint}");
    eprintln!("dwarves-gui: {mode}");

    let app = build_native_app(forge_endpoint.clone(), jera_ledger);

    // The window opens BIGGER than the harness default (1100x720). The owner, 2026-08-25:
    // "lägg till att fönstret kan bli större" — the Data view now shows a graph wall AND a
    // source panel side by side, and 1100 points is not enough for both. Still freely
    // resizable by the operator; this only moves the size it STARTS at.
    // The window title names THE ARTIFACT, not the family. It said `dwarves-ui —` for
    // every bin off this `main.rs`, so a `dwarves-ui-gpu` window titled itself
    // `dwarves-ui` — a screenshot of the GPU build was indistinguishable from the CPU
    // one, which is half of why the t14s duplicate went unnoticed for a day.
    if let Err(e) = facett_wasm_demo::NativeDemo::new(format!("{VARIANT} — {mode}"))
        .with_inner_size([1600.0, 1000.0])
        .run(move |_cc| {
            // `render` — the panic probe INSIDE eframe's creation callback. This body
            // runs on the winit event loop, under `eframe::run_native`, which is the
            // stack an egui UI dies on. Placed here rather than in an `update` because
            // this is the one eframe callback `main` owns directly, and it is already
            // inside the event loop.
            if std::env::var("DWARVES_UI_PANIC_PROBE").as_deref() == Ok("render") {
                panic!(
                    "DWARVES_UI_PANIC_PROBE=render — deliberate crash inside eframe's event loop"
                );
            }
            // The navigation graph over the whole window (facett navgraph §10, plain egui
            // harvest): one idempotent call, a no-op outside dev mode (`debug_assertions`
            // or `FACETT_DEVID=1`). `Ctrl+Shift+G` cycles the overlay, `Ctrl+G <n> Enter`
            // focuses node n; `FACETT_NAVGRAPH=labels|next|all` starts it on.
            facett_core::navgraph::overlay(&_cc.egui_ctx);
            // ── the `gpu` atom's ONE wiring point (`dwarves-ui-gpu`) ──────────────
            // The feature LINKS the instanced cloud renderer; it does not arm it. The
            // renderer needs the live `RenderState` and only the host has one, so
            // without this call the UNIVERSE lane compiles in and never paints —
            // `paint_scene_opts` fails safe to the CPU dot painter and reports
            // `gpu_cloud == false`. That is exactly the shape of a green that means
            // nothing, so the install sits here, in the `CreationContext`.
            //
            // ⚠ THE FEATURE IS NOT THE ARTIFACT. MEASURED on oden 2026-08-27, Xvfb :96,
            // REAL window, both bins from ONE
            // `cargo build --bin dwarves-ui --bin dwarves-ui-gpu --features gpu`:
            // the PLAIN `dwarves-ui` (sha256 186d2caf…) printed
            //   `dwarves-ui-gpu: instanced cloud renderer installed …`
            // — it linked the renderer, armed it, and named itself after the other
            // binary. Feature resolution is per-PACKAGE, so `dwarves-ui-gpu`'s
            // `required-features = ["gpu"]` turns `gpu` on for `dwarves-ui` too whenever
            // the two are built together. `#[cfg(feature = "gpu")]` therefore CANNOT
            // mean "this artifact is the GPU one", and the declaration's own guard
            // (`build_variants.rs::the_gpu_and_cpu_ui_atoms_differ_only_in_the_renderer`)
            // says so itself: it guards the DECLARATION, never the artifact.
            //
            // So the arming is gated on [`GPU_LANE`] — derived from `CARGO_BIN_NAME`, the
            // only per-TARGET fact there is. The CPU half stays the CPU half even when
            // the feature leaked into its compilation, which is the first time the pair's
            // claim about the target box is true at RUN time and not only on paper.
            #[cfg(feature = "gpu")]
            if !GPU_LANE {
                eprintln!(
                    "{VARIANT}: the `gpu` feature is linked into THIS artifact (cargo \
                     unifies features per package, so building it beside dwarves-ui-gpu \
                     turns `gpu` on here too) — NOT arming the UNIVERSE GPU lane, because \
                     the artifact is not the -gpu variant. universe_gpu_cloud stays false."
                );
            } else {
                match _cc.wgpu_render_state.as_ref() {
                    Some(rs) => {
                        facett_core::render::gpu::graphcloud::cloud::install_cloud_renderer(rs);
                        // the 🏛 code room's 3-D path: world-space text, quads, lines, depth
                        facett_core::render::gpu::scene3d::install_scene3d_renderer(rs);
                        eprintln!(
                            "{VARIANT}: instanced cloud renderer installed \
                             (UNIVERSE star-field on the GPU)"
                        );
                    }
                    // Say it out loud rather than falling back in silence: a binary NAMED
                    // -gpu that quietly paints on the CPU is the overclaim the name exists
                    // to prevent.
                    None => eprintln!(
                        "{VARIANT}: NO wgpu RenderState on this host — the UNIVERSE \
                         lane will paint on the CPU (universe_gpu_cloud=false)"
                    ),
                }
            }
            FacetApp::boxed(Box::new(app))
        })
    {
        eprintln!("dwarves-gui: failed to start the native window: {e}");
        std::process::exit(1);
    }
}

/// **Start the dwarves forge INSIDE this process** — the `dwarves-solo` half.
///
/// # Why this calls the library and not a copy
///
/// The body is three lines because the serve leg is
/// [`dwarves::server::serve_all`], which is the same function `dwarves-server`'s `main`
/// calls. It lived inside `src/bin/dwarves-server.rs` until 2026-08-03 and was moved to
/// the library for exactly this: a `fn` in a `[[bin]]` is unreachable from another
/// crate, so solo could only ever have been a *copy* of the bring-up — two fat processes
/// that would drift the first time either changed (LAW 5, and the `dwarves-full` /
/// `dwarves-server` split is what that drift looks like in this repo).
///
/// # What it does about the port
///
/// It does not invent an address. `serve_all` reads `DWARVES_SERVER_ADDR` (default
/// `127.0.0.1:7900`) and, on the fat build, `NORNIR_SERVER_ADDR` (default
/// `127.0.0.1:7878`) — the same knobs the daemon reads — and the UI's client seam
/// already defaults to `dwarves_embed::DEFAULT_ENDPOINT`, so the two halves agree
/// without a solo-specific setting.
///
/// Then it WAITS for the port to accept, up to ~5 s. Without that the window opens and
/// fires its first RPC into a socket that is not bound yet, and every pane renders its
/// "cannot reach the forge" state on a forge that is coming up — a false red that is
/// indistinguishable from a real one.
///
/// If `:7900` is already taken (a `dwarves-server` is running), `serve_all` fails to
/// bind and says so on stderr; the wait then falls through and the UI talks to whoever
/// owns the port. That is the honest outcome — it is a real forge — and it is reported
/// rather than silently assumed.
#[cfg(all(feature = "solo", not(target_arch = "wasm32")))]
fn start_embedded_forge() -> String {
    // `--real` / `$DWARVES_REAL_RUNNER` selects the cargo-building runner, exactly as on
    // the daemon; default is the hermetic DemoRunner. Same helper, so solo cannot end up
    // with a different default from the server it embeds.
    let real = dwarves::runner::real_runner_requested(
        std::env::args().any(|a| a == "--real"),
        std::env::var("DWARVES_REAL_RUNNER").ok().as_deref(),
    );
    // The address `serve_all` will bind, read from the SAME env knob it reads, so the
    // returned endpoint cannot disagree with what the forge does.
    let addr = std::env::var("DWARVES_SERVER_ADDR").unwrap_or_else(|_| "127.0.0.1:7900".into());
    let endpoint = format!("http://{addr}");
    eprintln!(
        "dwarves-solo: starting the EMBEDDED forge in this process — {}",
        dwarves::server::shape()
    );
    std::thread::spawn(move || {
        let rt = match tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
        {
            Ok(rt) => rt,
            Err(e) => {
                eprintln!("dwarves-solo: could not build the server runtime: {e}");
                return;
            }
        };
        if let Err(e) = rt.block_on(dwarves::server::serve_all(real)) {
            eprintln!("dwarves-solo: the embedded forge stopped: {e:#}");
        }
    });

    // Wait for the forge port to ACCEPT, not for a fixed sleep: a sleep is either a
    // wasted second or a race, and on a loaded box it is both.
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
    while std::time::Instant::now() < deadline {
        if std::net::TcpStream::connect(&addr).is_ok() {
            eprintln!("dwarves-solo: embedded forge is listening on {addr}");
            return endpoint;
        }
        std::thread::sleep(std::time::Duration::from_millis(50));
    }
    eprintln!(
        "dwarves-solo: the embedded forge did not accept on {addr} within 5s — the panes \
         will show their honest unreachable state until it does"
    );
    endpoint
}

// On wasm the entry point is `start` (below); `main` still has to exist to link.
#[cfg(target_arch = "wasm32")]
fn main() {}

// ── web host ─────────────────────────────────────────────────────────────────
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn start() {
    // Config from the page URL (`?server=…&token=…`) — never hardcoded, no secret baked
    // into the bundle. Default endpoint is the dwarves-server forge on cicd.g11.se:7900.
    let (server, token) = web_config();
    facett_wasm_demo::WebDemo::new("demo_canvas").mount(move |_cc, _hash| {
        // The navigation graph (facett navgraph §10), browser half: Ctrl+G is taken from
        // the find bar and `?nav=labels` starts the overlay on. Dev mode only.
        facett_core::navgraph::web::install(&_cc.egui_ctx);
        // The LIVE web forge view: fixture first (never blank), then a grpc-web
        // `History` fetch replaces the roster with the server's real runs.
        FacetApp::boxed(Box::new(ForgeView::live(server, token)))
    });
}

/// The default forge grpc-web endpoint when the page carries no `?server=` override — the
/// `dwarves-server` node on cicd.g11.se (`:7900` serves grpc-web content-negotiated).
#[cfg(target_arch = "wasm32")]
const DEFAULT_FORGE_ENDPOINT: &str = "https://cicd.g11.se:7900";

/// Read `?server=<url>&token=<bearer>` from the page's query string. A missing `server`
/// falls back to [`DEFAULT_FORGE_ENDPOINT`]; a missing `token` is empty (the server's
/// open/dev mode). Anything unparyable ⇒ the defaults, so the demo always boots.
#[cfg(target_arch = "wasm32")]
fn web_config() -> (String, String) {
    let search = web_sys::window()
        .and_then(|w| w.location().search().ok())
        .unwrap_or_default();
    let query = search.trim_start_matches('?');
    let mut server = DEFAULT_FORGE_ENDPOINT.to_string();
    let mut token = String::new();
    for pair in query.split('&').filter(|s| !s.is_empty()) {
        let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
        let v = percent_decode(v);
        match k {
            "server" if !v.is_empty() => server = v,
            "token" => token = v,
            _ => {}
        }
    }
    (server, token)
}

/// Minimal percent-decode for the two query params (`%3A`→`:`, `+`→space, etc.) — enough
/// for a URL + a token without pulling a URL crate into the wasm bundle.
#[cfg(target_arch = "wasm32")]
fn percent_decode(s: &str) -> String {
    let s = s.replace('+', " ");
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' && i + 2 < bytes.len() {
            if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
                out.push(byte);
                i += 3;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// The browser-readable state hook: `window.__dwarves_state()` returns the panel's live
/// `state_json` (the dwarves forge-view shape), so a headless Chromium drive verifies
/// the shipped bundle without a human.
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(js_name = __dwarves_state)]
pub fn dwarves_state() -> String {
    facett_wasm_demo::latest_state()
}