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()
}