Gunnar

nordisk/holger-egui

src/main.rs

//! `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))
        }),
    )
}