mod capture;
pub(crate) mod options;
mod render;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use agent_desktop_core::{
AppError, Deadline, PlatformAdapter, WindowInfo, context::CommandContext,
};
use serde_json::{Value, json};
use crate::cli::Commands;
use options::DebugOptions;
pub(crate) struct DebugCapture {
file: File,
path: PathBuf,
before: Option<Value>,
window: Option<WindowInfo>,
strip_bounds: bool,
mode: &'static str,
}
impl DebugCapture {
pub(crate) fn prepare(
command: &mut Commands,
options: &DebugOptions,
adapter: &dyn PlatformAdapter,
context: &CommandContext,
) -> Result<Option<Self>, AppError> {
options.validate(command)?;
let Some(path) = options.screenshot.as_ref() else {
return Ok(None);
};
let (before, window, strip_bounds, mode) = match command {
Commands::Click(args) => {
let (window, before) = capture::click_before(args, adapter, context)?;
(Some(before), Some(window), false, "click")
}
Commands::Snapshot(args) => {
let strip = !args.tree.include_bounds;
args.tree.include_bounds = true;
let mode = if args.root.is_some() {
"drill-down"
} else if args.tree.skeleton {
"skeleton"
} else {
"snapshot"
};
(None, None, strip, mode)
}
_ => return Err(AppError::invalid_input("Unsupported debug command")),
};
let mut open = OpenOptions::new();
open.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
open.mode(0o600);
}
let file = open.open(path).map_err(|error| AppError::invalid_input(format!(
"Cannot create debug output '{}': {error}. Choose a new .html path in an existing directory.", path.display()
)))?;
Ok(Some(Self {
file,
path: path.clone(),
before,
window,
strip_bounds,
mode,
}))
}
pub(crate) fn finish(
mut self,
result: &mut Result<Value, AppError>,
adapter: &dyn PlatformAdapter,
context: &CommandContext,
) {
let mut artifact = json!({
"mode": self.mode,
"ok": result.is_ok(),
"notice": "Local debug artifact: contains sensitive screenshots and accessibility labels. Captures are sequential observations, not a recording or proof of click delivery."
});
if let Some(before) = self.before.take() {
artifact["before"] = before;
}
let captured = if let Some(window) = self.window.as_ref() {
Deadline::standard()
.map_err(AppError::from)
.and_then(|deadline| capture::capture_frame(window, adapter, deadline))
} else if let Ok(data) = result.as_ref() {
capture::snapshot_frame(data, adapter, context)
} else {
Err(AppError::invalid_input(
"Command failed before a snapshot was available",
))
};
let warning = match captured {
Ok(frame) => {
artifact["after"] = frame;
None
}
Err(error) => {
let warning = error.to_string();
artifact["warning"] = json!(warning);
Some(warning)
}
};
match result.as_ref() {
Ok(data) => {
if self.mode == "click" {
artifact["result"] = data.clone();
}
if let Some(id) = data.get("snapshot_id") {
artifact["snapshot_id"] = id.clone();
}
}
Err(error) => {
artifact["error"] =
serde_json::to_value(agent_desktop_core::ErrorPayload::from_app_error(error))
.unwrap_or_else(|_| json!({"message": error.to_string()}))
}
}
let write_result = render::html(&artifact).and_then(|html| {
self.file.write_all(html.as_bytes())?;
self.file.flush()?;
Ok(())
});
let metadata = match write_result {
Ok(()) => {
let mut value = json!({"path": self.path, "format": "html"});
if let Some(warning) = warning {
value["warning"] = json!(warning);
}
value
}
Err(error) => json!({"warning": format!("Debug artifact write failed: {error}")}),
};
if let Ok(data) = result {
if self.strip_bounds
&& let Some(tree) = data.get_mut("tree")
{
strip_bounds(tree);
}
data["debug"] = metadata;
} else {
eprintln!("agent-desktop debug: {metadata}");
}
}
}
fn strip_bounds(node: &mut Value) {
if let Some(object) = node.as_object_mut() {
object.remove("bounds");
if let Some(children) = object.get_mut("children").and_then(Value::as_array_mut) {
for child in children {
strip_bounds(child);
}
}
}
}
#[cfg(test)]
mod tests;