//! Orchestrator for the bread ecosystem's UI screenshot tooling. //! //! Drives each target app's `--screenshot --output ` mode (see //! `bread-screenshots` for what that mode does inside the app) and reports //! pass/fail per view. One app per invocation, selected by `--app-name` //! (defaults to `--app-path`'s file stem, so `--app-path //! ./target/release/breadbox` needs no separate `--app-name`) — the view //! list for each app is looked up from [`TARGETS`] below. Flat output //! directory for now — no versioned `screenshots/vX.Y.Z/latest` structure //! or manifest file yet, since that's still not earning its complexity over //! a handful of apps. //! //! By default every capture runs inside a throwaway headless Sway instance //! (see [`isolation`]) rather than the operator's live desktop, so another //! window (or their own differently-themed real bar) can't leak into a //! capture. `--no-isolate` skips that and captures directly against whatever //! session bread-capture itself is running in — useful for debugging the //! capture sequence itself, since you can then actually watch it happen. mod isolation; use anyhow::{bail, Result}; use clap::Parser; use std::path::PathBuf; use std::process::ExitCode; use std::time::Duration; const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); /// Per-app (view name, output filename) lists. Keyed by the app's binary /// name — see `--app-name`. const TARGETS: &[(&str, &[(&str, &str)])] = &[ ( "breadbar", &[("bar", "breadbar-bar.png"), ("control-panel", "breadbar-control-panel.png")], ), ("breadbox", &[("launcher", "breadbox-launcher.png")]), ]; #[derive(Parser)] struct Cli { /// Path to the target app's binary (resolved via $PATH if not a path). #[arg(long)] app_path: String, /// Which app's view list to use (see `TARGETS`). Defaults to /// `--app-path`'s file stem, e.g. `./target/release/breadbox` -> `breadbox`. #[arg(long)] app_name: Option, /// Directory to write captured PNGs into. #[arg(long, default_value = "./screenshots")] out_dir: PathBuf, /// Capture directly against the current session instead of a headless, /// throwaway Sway instance. Off by default so captures can't pick up /// whatever else is on the operator's desktop. #[arg(long)] no_isolate: bool, /// Width of the isolated session's capture canvas. #[arg(long, default_value_t = 1920)] isolate_width: u32, /// Height of the isolated session's capture canvas. #[arg(long, default_value_t = 1080)] isolate_height: u32, } fn main() -> Result { let cli = Cli::parse(); let app_name = cli.app_name.clone().unwrap_or_else(|| { PathBuf::from(&cli.app_path) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| cli.app_path.clone()) }); let Some((_, views)) = TARGETS.iter().find(|(name, _)| *name == app_name) else { bail!( "no known view list for app '{app_name}' (known: {})", TARGETS.iter().map(|(n, _)| *n).collect::>().join(", ") ); }; // Bound, not dropped-and-discarded: `_isolation`'s teardown (kill the // compositor, remove its socket/config) must run via Drop regardless of // how this function returns below — returning an ExitCode rather than // calling `std::process::exit` (which skips destructors entirely) is // what makes that true on the failure path too. let _isolation = if cli.no_isolate { None } else { Some(isolation::Isolation::start(cli.isolate_width, cli.isolate_height)?) }; let width_str = cli.isolate_width.to_string(); let height_str = cli.isolate_height.to_string(); let mut failed = false; for (view, filename) in *views { let out_path = cli.out_dir.join(filename); let out_str = out_path.to_string_lossy(); let result = bread_utils::proc::run( &cli.app_path, &[ "--screenshot", view, "--output", &out_str, "--width", &width_str, "--height", &height_str, ], CAPTURE_TIMEOUT, ); if result.success { println!("ok {app_name}/{view} -> {}", out_path.display()); } else { failed = true; println!("FAIL {app_name}/{view}: {}", result.stderr.trim()); } } Ok(if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS }) }