//! `holger-server-ui` — the holger operator/admin desktop UI (egui via facett).
//!
//! The bin was `holger-server-ui` until 2026-08-01; it is now named for the component it
//! drives (`server/cli` = `holger-server-cli` builds the daemons it talks to). The
//! PACKAGE is still `holger-server-ui` — it is published on crates.io.
//!
//! Usage:
//! ```text
//! holger-server-ui [ENDPOINT] [--token <TOKEN>] [--ca <CA.pem>] [--cert <CERT.pem> --key <KEY.pem>]
//! ```
//! `ENDPOINT` is the holger gRPC address (default `http://127.0.0.1:50051`). A
//! bearer token may be supplied with `--token <TOKEN>` or the `HOLGER_TOKEN`
//! environment variable (needed for write access on an auth-enabled server).
//!
//! TLS / mTLS: pass `--ca <CA.pem>` to verify the server against a custom CA,
//! and `--cert <CERT.pem> --key <KEY.pem>` to present a client certificate
//! (mTLS — the CN → role auth path). When any of these are present the UI
//! connects over TLS; otherwise it uses the plain (token-only) path. Use an
//! `https://` endpoint with TLS.
//!
//! The UI connects with `RemoteHolger`; because the view-model is generic over
//! `HolgerObject`, an embedded build could hand it a `LocalHolger` instead for
//! direct in-process core calls (no network).
use holger_server_ui::app::HolgerUiApp;
use holger_server_ui::data::UiData;
fn main() -> eframe::Result<()> {
// ── robot-remote MOUNT (feature-gated) ───────────────────────────────────
// `holger-server-ui --robot-remote <socket>` binds a `cmd_json::CmdJsonServer` over the
// egui operator shell on `<socket>` (its own blocking I/O — NO thread::spawn) so
// edda's fleet can drive holger WIRE-REMOTE, instead of opening the GUI window. The
// fleet dials `<dir>/holger.sock` (basename == the member name). OFF by default: the
// shipped binary never mounts a socket.
#[cfg(all(unix, feature = "robot-remote"))]
{
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
// `holger-server-ui 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 (`<dir>/holger.scenarios.json`) so
// `RobotDriveMember` drives holger's 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 = holger_server_ui::scenarios::holger_tab_scenarios();
match serde_json::to_string_pretty(&scenarios) {
Ok(json) => {
println!("{json}");
return Ok(());
}
Err(e) => {
eprintln!("holger-server-ui: failed to serialize scenarios: {e}");
std::process::exit(1);
}
}
}
if a == "--robot-remote" {
let Some(path) = args.next() else {
eprintln!("holger-server-ui: --robot-remote needs a <socket> path");
std::process::exit(2);
};
if let Err(e) =
holger_server_ui::robot_remote::serve_robot_remote(std::path::Path::new(&path))
{
eprintln!("holger-server-ui: robot-remote server failed: {e}");
std::process::exit(1);
}
return Ok(());
}
}
}
// Tiny hand-rolled arg parse: first non-flag positional is the endpoint;
// `--token <t>` (or $HOLGER_TOKEN) is the bearer token; `--ca/--cert/--key`
// are PEM paths for the TLS / mTLS path.
let mut endpoint = "http://127.0.0.1:50051".to_string();
let mut token = std::env::var("HOLGER_TOKEN").ok();
let mut ca_path: Option<String> = None;
let mut cert_path: Option<String> = None;
let mut key_path: Option<String> = None;
// 🎬 Demo mode: fill an in-process dev store with real crates (offline-safe
// fixture fallback) and ROBOT-DRIVE the UI through every view. `--demo-offline`
// forces the bundled fixture (no network). FULL TIER ONLY — the guided tour
// pulls from crates.io through `world-connector` (→ reqwest), which the
// read-only tiers do not link, so on those `--demo` is not a flag at all.
#[cfg(feature = "demo")]
let mut demo = false;
#[cfg(feature = "demo")]
let mut demo_offline = false;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--token" => token = args.next(),
"--ca" => ca_path = args.next(),
"--cert" => cert_path = args.next(),
"--key" => key_path = args.next(),
#[cfg(feature = "demo")]
"--demo" => demo = true,
#[cfg(feature = "demo")]
"--demo-offline" => {
demo = true;
demo_offline = true;
}
other => endpoint = other.to_string(),
}
}
// ── 🎬 Demo mode: self-contained, no server needed ───────────────────────
#[cfg(feature = "demo")]
if demo {
return run_demo(!demo_offline);
}
// Read any PEM material up front so a missing file fails before the GUI opens.
let read_pem = |path: &str| -> Result<Vec<u8>, ()> {
std::fs::read(path).map_err(|e| {
eprintln!("holger-server-ui: failed to read PEM file {path}: {e}");
})
};
// mTLS client identity needs both --cert and --key together.
let client_identity = match (cert_path.as_deref(), key_path.as_deref()) {
(Some(c), Some(k)) => match (read_pem(c), read_pem(k)) {
(Ok(cert), Ok(key)) => Some((cert, key)),
_ => std::process::exit(1),
},
(None, None) => None,
_ => {
eprintln!("holger-server-ui: --cert and --key must be supplied together for mTLS");
std::process::exit(1);
}
};
let ca = match ca_path.as_deref() {
Some(p) => match read_pem(p) {
Ok(bytes) => Some(bytes),
Err(()) => std::process::exit(1),
},
None => None,
};
// Any TLS material present → take the TLS path; otherwise the plain
// (token-only) connect path.
let use_tls = ca.is_some() || client_identity.is_some();
let connect = if use_tls {
UiData::connect_remote_with_tls(&endpoint, ca, client_identity, token.as_deref())
} else {
UiData::connect_remote_with_token(&endpoint, token.as_deref())
};
let data = match connect {
Ok(d) => d,
Err(e) => {
eprintln!("holger-server-ui: failed to connect to holger at {endpoint}: {e:#}");
std::process::exit(1);
}
};
let native_options = eframe::NativeOptions {
// LAW #4 — WebGPU, never OpenGL. Named explicitly rather than left to
// `Renderer::default()`, which resolves to whichever backend feature the manifest
// carries: holger shipped on glow for months with nothing in this file saying so.
renderer: eframe::Renderer::Wgpu,
viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([1100.0, 720.0])
.with_min_inner_size([720.0, 480.0])
.with_title("holger"),
..Default::default()
};
eframe::run_native(
"holger-server-ui",
native_options,
Box::new(move |cc| {
// Unified facett look & feel: the app installs the active preset's
// full egui Style every frame (and publishes the derived legacy
// palette so the facett Table facets re-theme coherently — COH-1).
// Prime it once here so the first frame is already themed; replaces
// the old per-crate `facett::Theme::sci_fi()`.
let mut app = HolgerUiApp::new(data);
// Hand the session's mTLS client identity to Tool #5 Airgap so an
// auth=mtls populate reuses the connect-time cert/key.
app.set_session_client_identity(
cert_path.map(std::path::PathBuf::from),
key_path.map(std::path::PathBuf::from),
);
app.apply_look(&cc.egui_ctx);
Ok(Box::new(app))
}),
)
}
/// 🎬 `holger-server-ui --demo` — the self-running guided tour. Fills a dev znippy store
/// with real crates (offline-safe fixture fallback), wires it as an in-process
/// `LocalHolger` (no server, no gRPC), then launches the UI with the hands-free
/// walk armed so it drives every view with paced, animated beats.
#[cfg(feature = "demo")]
fn run_demo(allow_network: bool) -> eframe::Result<()> {
use std::sync::Arc;
let store_dir = std::env::temp_dir().join(format!("holger-demo-store-{}", std::process::id()));
let fill = match holger_server_ui::demo::fill_demo_store(&store_dir, allow_network) {
Ok(f) => f,
Err(e) => {
eprintln!("holger-server-ui --demo: failed to fill demo store: {e:#}");
std::process::exit(1);
}
};
eprintln!(
"holger-server-ui --demo: filled {} packages across {} repos ({}) → store {}",
fill.packages.len(),
fill.repos.len(),
if fill.online { format!("{} real from crates.io", fill.real_count()) } else { "offline fixture".to_string() },
store_dir.display(),
);
let local = fill.local_holger();
let data = match UiData::new(Arc::new(local)) {
Ok(d) => d,
Err(e) => {
eprintln!("holger-server-ui --demo: failed to build view-model: {e:#}");
std::process::exit(1);
}
};
let native_options = eframe::NativeOptions {
// LAW #4 — WebGPU, never OpenGL (see the non-demo path above).
renderer: eframe::Renderer::Wgpu,
viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([1100.0, 720.0])
.with_min_inner_size([720.0, 480.0])
.with_title("holger — demo"),
..Default::default()
};
eframe::run_native(
"holger-server-ui-demo",
native_options,
Box::new(move |cc| {
let mut app = HolgerUiApp::new(data);
// Beat spacing: $HOLGER_DEMO_BEAT_MS overrides (default 2200ms).
let beat_ms = std::env::var("HOLGER_DEMO_BEAT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(2200);
app.enable_demo(beat_ms);
app.apply_look(&cc.egui_ctx);
Ok(Box::new(app))
}),
)
}