use std::path::PathBuf;
use clap::{Args as ClapArgs, Subcommand};
use color_eyre::eyre::{self, Result};
use crate::shell::Shell;
use crate::{error, line, note, success};
use waterui_cli::{android, apple, capture, gesture};
#[derive(ClapArgs, Debug)]
pub struct Args {
#[command(subcommand)]
command: DeviceCommand,
}
#[derive(Subcommand, Debug)]
pub enum DeviceCommand {
Capture(CaptureArgs),
Tap(TapArgs),
Swipe(SwipeArgs),
Text(TextArgs),
Describe(DescribeArgs),
}
#[derive(ClapArgs, Debug)]
pub struct CaptureArgs {
#[arg(long, conflicts_with = "pid")]
id: Option<String>,
#[arg(long, conflicts_with = "id")]
pid: Option<i32>,
#[arg(long, requires = "pid")]
window: Option<usize>,
#[arg(long, requires = "pid", conflicts_with = "window")]
all_windows: bool,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, requires = "all_windows")]
output_dir: Option<PathBuf>,
}
#[derive(ClapArgs, Debug)]
pub struct TapArgs {
#[arg(long)]
id: String,
#[arg(long)]
x: u32,
#[arg(long)]
y: u32,
#[arg(long)]
diff: bool,
#[arg(long)]
diff_output: Option<PathBuf>,
#[arg(long, default_value = "500")]
delay: u32,
}
#[derive(ClapArgs, Debug)]
pub struct SwipeArgs {
#[arg(long)]
id: String,
#[arg(long, value_parser = parse_coords)]
from: (u32, u32),
#[arg(long, value_parser = parse_coords)]
to: (u32, u32),
#[arg(long)]
duration: Option<u32>,
#[arg(long)]
diff: bool,
#[arg(long)]
diff_output: Option<PathBuf>,
#[arg(long, default_value = "500")]
delay: u32,
}
#[derive(ClapArgs, Debug)]
pub struct TextArgs {
#[arg(long)]
id: String,
#[arg(long)]
input: String,
#[arg(long)]
diff: bool,
#[arg(long)]
diff_output: Option<PathBuf>,
#[arg(long, default_value = "500")]
delay: u32,
}
#[derive(ClapArgs, Debug)]
pub struct DescribeArgs {
#[arg(long)]
id: String,
}
fn parse_coords(s: &str) -> Result<(u32, u32), String> {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 2 {
return Err("Expected format: x,y (e.g., 100,200)".to_string());
}
let x = parts[0]
.trim()
.parse::<u32>()
.map_err(|_| "Invalid X coordinate")?;
let y = parts[1]
.trim()
.parse::<u32>()
.map_err(|_| "Invalid Y coordinate")?;
Ok((x, y))
}
pub async fn run(shell: &Shell, args: Args) -> Result<()> {
match args.command {
DeviceCommand::Capture(capture_args) => run_capture(shell, capture_args).await,
DeviceCommand::Tap(tap_args) => run_tap(shell, tap_args).await,
DeviceCommand::Swipe(swipe_args) => run_swipe(shell, swipe_args).await,
DeviceCommand::Text(text_args) => run_text(shell, text_args).await,
DeviceCommand::Describe(describe_args) => run_describe(shell, describe_args).await,
}
}
async fn run_capture(shell: &Shell, args: CaptureArgs) -> Result<()> {
if let Some(pid) = args.pid {
return run_capture_by_pid(
shell,
pid,
args.window,
args.all_windows,
args.output,
args.output_dir,
)
.await;
}
let device_id = args.id.as_deref().unwrap_or(gesture::LOCAL_DEVICE_ID);
if device_id == gesture::LOCAL_DEVICE_ID {
let output = args
.output
.unwrap_or_else(capture::generate_screenshot_filename);
match waterui_cli::apple::local::screenshot(&output).await {
Ok(()) => {
success!(
shell,
"Screenshot saved to {} (from macOS local)",
output.display()
);
return Ok(());
}
Err(e) => {
error!(shell, "Failed to capture screenshot: {e}");
return Err(e);
}
}
}
let platform = match capture::verify_device(device_id).await {
Ok(p) => p,
Err(e) => {
error!(shell, "Device not found: {e}");
return Err(e);
}
};
let output = args
.output
.unwrap_or_else(capture::generate_screenshot_filename);
let platform_name = match platform {
capture::DevicePlatform::Ios => "iOS simulator",
capture::DevicePlatform::Android => "Android device",
};
match capture::screenshot(device_id, &output).await {
Ok(()) => {
success!(
shell,
"Screenshot saved to {} (from {})",
output.display(),
platform_name
);
Ok(())
}
Err(e) => {
error!(shell, "Failed to capture screenshot: {e}");
Err(e)
}
}
}
async fn run_capture_by_pid(
shell: &Shell,
pid: i32,
window_index: Option<usize>,
all_windows: bool,
output: Option<PathBuf>,
output_dir: Option<PathBuf>,
) -> Result<()> {
use waterui_cli::apple::local::{list_windows_by_pid, screenshot_window};
let windows = list_windows_by_pid(pid)?;
if windows.is_empty() {
error!(shell, "No windows found for PID {pid}");
eyre::bail!("No windows found for PID {pid}");
}
let normal_windows: Vec<_> = windows.iter().filter(|w| w.layer == 0).collect();
if normal_windows.is_empty() {
error!(
shell,
"No normal windows found for PID {pid} (found {} auxiliary windows)",
windows.len()
);
eyre::bail!("No normal windows found for PID {pid}");
}
if all_windows {
let dir = output_dir.unwrap_or_else(|| PathBuf::from("."));
smol::fs::create_dir_all(&dir).await?;
for (i, window) in normal_windows.iter().enumerate() {
let filename = if window.name.is_empty() {
format!("window_{i}.png")
} else {
let safe_name: String = window
.name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
format!("window_{i}_{safe_name}.png")
};
let path = dir.join(&filename);
match screenshot_window(window.window_id, &path).await {
Ok(()) => {
success!(
shell,
"Window {i} \"{}\" saved to {}",
window.name,
path.display()
);
}
Err(e) => {
error!(shell, "Failed to capture window {i}: {e}");
}
}
}
note!(
shell,
"Captured {} windows for PID {pid}",
normal_windows.len()
);
Ok(())
} else {
let index = window_index.unwrap_or(0);
if index >= normal_windows.len() {
error!(
shell,
"Window index {index} out of range (found {} windows)",
normal_windows.len()
);
eyre::bail!(
"Window index {index} out of range (found {} windows)",
normal_windows.len()
);
}
let window = &normal_windows[index];
let output_path = output.unwrap_or_else(capture::generate_screenshot_filename);
match screenshot_window(window.window_id, &output_path).await {
Ok(()) => {
success!(
shell,
"Screenshot saved to {} (window \"{}\" from PID {pid})",
output_path.display(),
window.name
);
Ok(())
}
Err(e) => {
error!(shell, "Failed to capture screenshot: {e}");
Err(e)
}
}
}
}
const fn build_gesture_options(
diff: bool,
diff_output: Option<PathBuf>,
delay: u32,
) -> gesture::GestureOptions {
gesture::GestureOptions {
diff,
diff_output,
delay_ms: Some(delay),
}
}
fn print_diff_result(
shell: &Shell,
result: &gesture::GestureResult,
diff_output: Option<&std::path::Path>,
) {
if let Some(diff) = &result.diff {
if let Some(path) = diff_output {
success!(shell, "Diff image saved to {}", path.display());
}
note!(shell, "Diff result:\n{diff}");
}
}
async fn run_tap(shell: &Shell, args: TapArgs) -> Result<()> {
let device_id = &args.id;
gesture::verify_device(device_id).await?;
let options = build_gesture_options(args.diff, args.diff_output.clone(), args.delay);
match gesture::tap(device_id, args.x, args.y, &options).await {
Ok(result) => {
success!(shell, "Tap at ({}, {})", args.x, args.y);
print_diff_result(shell, &result, args.diff_output.as_deref());
Ok(())
}
Err(e) => {
error!(shell, "Failed to tap: {e}");
Err(e)
}
}
}
async fn run_swipe(shell: &Shell, args: SwipeArgs) -> Result<()> {
let device_id = &args.id;
gesture::verify_device(device_id).await?;
let options = build_gesture_options(args.diff, args.diff_output.clone(), args.delay);
match gesture::swipe(device_id, args.from, args.to, args.duration, &options).await {
Ok(result) => {
success!(
shell,
"Swipe from ({}, {}) to ({}, {})",
args.from.0,
args.from.1,
args.to.0,
args.to.1
);
print_diff_result(shell, &result, args.diff_output.as_deref());
Ok(())
}
Err(e) => {
error!(shell, "Failed to swipe: {e}");
Err(e)
}
}
}
async fn run_text(shell: &Shell, args: TextArgs) -> Result<()> {
let device_id = &args.id;
gesture::verify_device(device_id).await?;
let options = build_gesture_options(args.diff, args.diff_output.clone(), args.delay);
match gesture::text(device_id, &args.input, &options).await {
Ok(result) => {
success!(shell, "Text input: \"{}\"", args.input);
print_diff_result(shell, &result, args.diff_output.as_deref());
Ok(())
}
Err(e) => {
error!(shell, "Failed to input text: {e}");
Err(e)
}
}
}
async fn run_describe(shell: &Shell, args: DescribeArgs) -> Result<()> {
let device_id = &args.id;
if device_id == gesture::LOCAL_DEVICE_ID {
eyre::bail!("Describe is not supported for local macOS device");
}
let json = match capture::detect_platform(device_id) {
capture::DevicePlatform::Ios => apple::device::describe(device_id).await?,
capture::DevicePlatform::Android => android::device::describe(device_id).await?,
};
if shell.is_json() {
let _ = shell.json_raw(&json);
} else {
print_ui_elements_readable(shell, &json)?;
}
Ok(())
}
fn print_ui_elements_readable(shell: &Shell, json: &str) -> Result<()> {
let elements: Vec<serde_json::Value> = serde_json::from_str(json)?;
line!(shell, "UI Elements ({} found):", elements.len());
line!(shell, "{}", "-".repeat(80));
for (i, elem) in elements.iter().enumerate() {
let label = elem.get("AXLabel").and_then(|v| v.as_str()).unwrap_or("-");
let elem_type = elem.get("type").and_then(|v| v.as_str()).unwrap_or("-");
let value = elem.get("AXValue").and_then(|v| v.as_str()).unwrap_or("");
let frame = elem.get("frame");
let (x, y, w, h) = frame.map_or((0.0, 0.0, 0.0, 0.0), |frame| {
(
frame
.get("x")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0),
frame
.get("y")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0),
frame
.get("width")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0),
frame
.get("height")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0),
)
});
let center_x = x + w / 2.0;
let center_y = y + h / 2.0;
if label != "-" || !value.is_empty() {
let display_value = if value.is_empty() { label } else { value };
let label_suffix = if value.is_empty() || label == "-" {
String::new()
} else {
format!(" ({label})")
};
line!(
shell,
"[{}] {} \"{}\"{}",
i,
elem_type,
display_value,
label_suffix
);
line!(
shell,
" tap: --x {center_x:.0} --y {center_y:.0} (frame: {x:.0},{y:.0} {w:.0}x{h:.0})"
);
}
}
Ok(())
}