use crate::{CommandGroup, InvokeCommandInput, InvokeCommandOutput, InvokeCommandResult, InvokeReport, InvokeReportField, invoke_command};
use auv_tracing::ArtifactMetadata;
use clap::Args;
use crate::artifact::{emit_png, emit_png_with_receipt};
pub fn group() -> CommandGroup {
CommandGroup::new("screen", "SCREEN")
.command(capture_region_invoke_command())
.command(find_screen_text_invoke_command())
.command(wait_for_screen_text_invoke_command())
.command(click_screen_text_invoke_command())
}
#[derive(Clone, Debug, Args, serde::Serialize, serde::Deserialize)]
#[command(after_long_help = "Examples:\n auv invoke screen.captureRegion --x 0 --y 0 --width 800 --height 600")]
struct CaptureRegionArgs {
#[arg(long)]
x: f64,
#[arg(long)]
y: f64,
#[arg(long)]
width: f64,
#[arg(long)]
height: f64,
#[arg(long)]
label: Option<String>,
}
#[invoke_command(
id = "screen.captureRegion",
group = "screen",
description = "Capture one display-contained region and emit its coordinate contract (macOS, Linux, and Windows).",
input = CaptureRegionArgs,
)]
async fn capture_region(input: InvokeCommandInput, args: CaptureRegionArgs) -> InvokeCommandResult {
reject_target_activation(&input, "screen.captureRegion")?;
if !args.x.is_finite() || !args.y.is_finite() {
return Err("screen.captureRegion requires finite --x and --y".to_string());
}
if !args.width.is_finite() || !args.height.is_finite() || args.width <= 0.0 || args.height <= 0.0 {
return Err("screen.captureRegion requires --width and --height greater than zero".to_string());
}
let region = auv_driver::Rect::new(args.x, args.y, args.width, args.height);
input.cancellation.check().map_err(|error| error.to_string())?;
if input.dry_run {
return Ok(InvokeCommandOutput::completed());
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
let (capture, artifact) = capture_screen_region_recorded(region).await?;
region_capture_output(&capture, artifact)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = region;
Err("screen.captureRegion is unavailable on this platform".to_string())
}
}
pub async fn capture_screen_region(region: auv_driver::Rect) -> Result<auv_driver::RegionCapture, String> {
capture_screen_region_recorded(region).await.map(|(capture, _)| capture)
}
async fn capture_screen_region_recorded(region: auv_driver::Rect) -> Result<(auv_driver::RegionCapture, Option<ArtifactMetadata>), String> {
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
let session = auv::local::open().map_err(|error| error.to_string())?;
let capture = session
.display()
.capture_region(auv_driver::CaptureOptions {
region: Some(region),
..auv_driver::CaptureOptions::default()
})
.map_err(|error| error.to_string())?;
let artifact = emit_png_with_receipt("auv.driver.screen_region_capture", &capture.capture.image).await;
Ok((capture, artifact))
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = region;
Err("screen.captureRegion is unavailable on this platform".to_string())
}
}
fn region_capture_output(capture: &auv_driver::RegionCapture, artifact: Option<ArtifactMetadata>) -> InvokeCommandResult {
let mut output = InvokeCommandOutput::from_result(&super::display_capture_result(&capture.display, &capture.capture))?;
output.report = Some(InvokeReport::new(
vec![
InvokeReportField::new("Display ID", capture.display.id.clone()),
InvokeReportField::new("Pixel size", format!("{}x{}", capture.capture.image.width(), capture.capture.image.height())),
],
Vec::new(),
));
Ok(output.with_artifacts(artifact))
}
pub async fn recorded_region_capture_output(capture: &auv_driver::RegionCapture) -> InvokeCommandResult {
let artifact = emit_png_with_receipt("auv.driver.screen_region_capture", &capture.capture.image).await;
region_capture_output(capture, artifact)
}
#[derive(Clone, Debug, Args, serde::Serialize, serde::Deserialize)]
#[command(
after_long_help = "Examples:\n # Find text on the current screen\n auv invoke screen.findText \"Settings\"\n\n # Emit OCR matches as JSON\n auv invoke screen.findText \"Settings\" --json"
)]
struct FindScreenTextArgs {
#[arg(value_name = "TEXT")]
query: String,
}
#[derive(Clone, Debug, Args, serde::Serialize, serde::Deserialize)]
#[command(after_long_help = "Examples:\n auv invoke screen.waitForText \"Ready\"")]
struct WaitForScreenTextArgs {
#[arg(value_name = "TEXT")]
query: String,
}
#[invoke_command(
id = "screen.findText",
group = "screen",
description = "Capture a screenshot and locate OCR text anchors in logical screen coordinates (macOS and Linux). Target activation is not yet available for this command.",
input = FindScreenTextArgs,
)]
async fn find_screen_text(input: InvokeCommandInput, args: FindScreenTextArgs) -> InvokeCommandResult {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
reject_target_activation(&input, "screen.findText")?;
if input.dry_run {
return Ok(InvokeCommandOutput::completed());
}
let matches = recognize_screen_text_with_cancellation(args.query, false, &input.cancellation).await?;
screen_text_matches_output(&matches)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = (input, args);
Err("screen text OCR is only available on macOS and Linux".to_string())
}
}
#[invoke_command(
id = "screen.waitForText",
group = "screen",
description = "Poll live-desktop OCR until a text anchor appears or the timeout expires (macOS and Linux). Target activation is not yet available for this command.",
input = WaitForScreenTextArgs,
)]
async fn wait_for_screen_text(input: InvokeCommandInput, args: WaitForScreenTextArgs) -> InvokeCommandResult {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
reject_target_activation(&input, "screen.waitForText")?;
if input.dry_run {
return Ok(InvokeCommandOutput::completed());
}
let matches = recognize_screen_text_with_cancellation(args.query, true, &input.cancellation).await?;
screen_text_matches_output(&matches)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = (input, args);
Err("screen text OCR is only available on macOS and Linux".to_string())
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub async fn recognize_screen_text(query: String, wait: bool) -> Result<auv_driver::OcrMatches, String> {
recognize_screen_text_with_cancellation(query, wait, &Default::default()).await
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
async fn recognize_screen_text_with_cancellation(
query: String,
wait: bool,
cancellation: &crate::InvokeCancellation,
) -> Result<auv_driver::OcrMatches, String> {
use auv_driver::{CaptureOptions, RatioRect, WaitOptions};
cancellation.check().map_err(|error| error.to_string())?;
let session = auv::local::open().map_err(|error| error.to_string())?;
let observe = || async {
cancellation.check().map_err(|error| error.to_string())?;
let capture = session.display().capture(CaptureOptions::default()).map_err(|error| error.to_string())?;
cancellation.check().map_err(|error| error.to_string())?;
let matches = session
.vision()
.find_text_in_capture(&capture.capture, &query, RatioRect::new(0.0, 0.0, 1.0, 1.0))
.map_err(|error| error.to_string())?;
cancellation.check().map_err(|error| error.to_string())?;
Ok((matches, capture))
};
let (matches, capture) = if wait {
crate::runner::wait_for_selected_text("screen.waitForText", &query, WaitOptions::default(), cancellation, observe, |(matches, _)| {
!matches.matches.is_empty()
})
.await?
} else {
observe().await?
};
emit_png("auv.driver.screen_ocr_source", &capture.capture.image);
Ok(matches)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
pub async fn recognize_screen_text(_query: String, _wait: bool) -> Result<auv_driver::OcrMatches, String> {
Err("screen text OCR is only available on macOS and Linux".to_string())
}
fn screen_text_matches_output(matches: &auv_driver::OcrMatches) -> InvokeCommandResult {
let mut output = InvokeCommandOutput::from_result(matches)?;
output.report = Some(crate::commands::ocr::match_report(&matches.matches, None));
Ok(output)
}
pub fn recorded_screen_text_matches_output(matches: &auv_driver::OcrMatches, capture: &auv_driver::Capture) -> InvokeCommandResult {
emit_png("auv.driver.screen_ocr_source", &capture.image);
screen_text_matches_output(matches)
}
#[derive(Clone, Debug, Args, serde::Serialize, serde::Deserialize)]
#[command(after_long_help = "Examples:\n auv invoke screen.clickText \"Continue\"")]
struct ClickScreenTextArgs {
#[arg(value_name = "TEXT")]
query: String,
}
#[invoke_command(
id = "screen.clickText",
group = "screen",
description = "Capture a screenshot, resolve an OCR text anchor, and click its projected logical point. Target activation is not yet available for this command.",
input = ClickScreenTextArgs,
)]
async fn click_screen_text(input: InvokeCommandInput, args: ClickScreenTextArgs) -> InvokeCommandResult {
#[cfg(target_os = "macos")]
{
reject_target_activation(&input, "screen.clickText")?;
let query = args.query;
if input.dry_run {
return Ok(super::input::validation_only_output());
}
let result = click_recognized_screen_text(query).await?;
screen_text_click_output(&result)
}
#[cfg(not(target_os = "macos"))]
{
let _ = (input, args);
Err("screen.clickText is only available on macOS".to_string())
}
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct ScreenTextClick {
pub matches: auv_driver::OcrMatches,
pub point: auv_driver::geometry::Point,
pub action: auv_driver::InputActionResult,
}
fn screen_text_click_output(result: &ScreenTextClick) -> InvokeCommandResult {
let mut output = InvokeCommandOutput::from_result(result)?;
output.report = Some(crate::commands::ocr::match_report(&result.matches.matches, Some(0)));
if let Some(report) = output.report.as_mut() {
report.fields.extend(super::input::input_action_report_fields(&result.action));
report.fields.push(InvokeReportField::new("Click point", format!("{:.0},{:.0}", result.point.x, result.point.y)));
}
Ok(output)
}
pub fn recorded_screen_text_click_output(result: &ScreenTextClick, capture: &auv_driver::Capture) -> InvokeCommandResult {
emit_png("auv.driver.screen_ocr_source", &capture.image);
screen_text_click_output(result)
}
pub async fn click_recognized_screen_text(query: String) -> Result<ScreenTextClick, String> {
#[cfg(target_os = "macos")]
{
let session = auv::local::open().map_err(|error| error.to_string())?;
let capture = session.display().capture(auv_driver::CaptureOptions::default()).map_err(|error| error.to_string())?;
let matches = session
.vision()
.find_text_in_capture(&capture.capture, &query, auv_driver::RatioRect::new(0.0, 0.0, 1.0, 1.0))
.map_err(|error| error.to_string())?;
let point = matches.best_match().ok_or_else(|| format!("screen.clickText did not find text {query:?}"))?.action_point();
let action = session
.input()
.click_at(point, auv_driver::MouseButton::Left, auv_driver::Click::Single, Default::default())
.map_err(|error| error.to_string())?;
super::input::emit_input_action_result(&action);
emit_png("auv.driver.screen_ocr_source", &capture.capture.image);
Ok(ScreenTextClick {
matches,
point,
action,
})
}
#[cfg(not(target_os = "macos"))]
{
let _ = query;
Err("screen.clickText is only available on macOS".to_string())
}
}
fn reject_target_activation(input: &InvokeCommandInput, command_id: &str) -> Result<(), String> {
if input.target.is_some() {
return Err(format!("{command_id} cannot use --target until typed app activation is available"));
}
Ok(())
}
#[cfg(test)]
#[path = "screen_test.rs"]
mod tests;