Replaces the breadbar-only hardcoded view list with a registry keyed by app name (--app-name, defaulting to --app-path's file stem) so wiring up each new app just means adding one entry, not touching the CLI shape. Also fixes a real leak found while testing breadbox against this: std::process::exit() on the failure path skipped every destructor, including Isolation's Drop — so a failed capture run (or, more subtly, *any* run against an app whose view list didn't match yet, which is exactly what happened testing this) permanently orphaned the headless Sway process and its wayland-N/.lock socket pair. main() now returns ExitCode instead of calling process::exit directly, so Drop always runs.
123 lines
4.5 KiB
Rust
123 lines
4.5 KiB
Rust
//! Orchestrator for the bread ecosystem's UI screenshot tooling.
|
|
//!
|
|
//! Drives each target app's `--screenshot <view> --output <path>` 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<String>,
|
|
|
|
/// 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<ExitCode> {
|
|
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::<Vec<_>>().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 })
|
|
}
|