1 use anyhow::{anyhow, Result};
2 use clap::{Parser, Subcommand};
3 use std::path::PathBuf;
4 use xcrs::{
5 AndroidDebugBridge, AndroidKit, HostCropRequest, HostInspect, HostInspectRect,
6 HostInspectScale, HostOcrRequest, HostProbeAxis, HostProbeRequest, IosAppTest,
7 XcodeCommandLineTools,
8 };
9
10 #[derive(Debug, Parser)]
11 #[command(name = "xcrs")]
12 #[command(version)]
13 #[command(about = "Cross-platform mobile and TV app automation")]
14 struct Cli {
15 /// Run as a standalone Model Context Protocol server over stdio.
16 #[arg(long)]
17 mcp: bool,
18 #[command(subcommand)]
19 command: Option<Commands>,
20 }
21
22 #[derive(Debug, Subcommand)]
23 enum Commands {
24 #[command(subcommand)]
25 Simulator(SimulatorCommand),
26 #[command(subcommand)]
27 AndroidKit(AndroidKitCommand),
28 /// Inspect the macOS host: on-screen windows, image text, crops, and pixel probes.
29 #[command(subcommand)]
30 Host(HostCommand),
31 IosAppTest {
32 #[arg(long, conflicts_with = "simulator_udid")]
33 simulator_name: Option<String>,
34 #[arg(long, conflicts_with = "simulator_name")]
35 simulator_udid: Option<String>,
36 #[arg(long)]
37 app_path: PathBuf,
38 #[arg(long)]
39 bundle_id: String,
40 #[arg(long)]
41 terminate_before_launch: bool,
42 #[arg(long)]
43 json: bool,
44 },
45 }
46
47 #[derive(Debug, Subcommand)]
48 enum AndroidKitCommand {
49 Install {
50 #[arg(long)]
51 serial: String,
52 #[arg(long)]
53 apk: PathBuf,
54 },
55 Start {
56 #[arg(long)]
57 serial: String,
58 },
59 Status {
60 #[arg(long)]
61 serial: String,
62 #[arg(long)]
63 json: bool,
64 },
65 Stop {
66 #[arg(long)]
67 serial: String,
68 },
69 }
70
71 #[derive(Debug, Subcommand)]
72 enum HostCommand {
73 /// List the host's on-screen windows, ordered by layer and then id.
74 Windows {
75 /// Only windows owned by this application name.
76 #[arg(long)]
77 owner: Option<String>,
78 #[arg(long)]
79 json: bool,
80 },
81 /// Read the text in an image with the platform's Vision text recognizer.
82 Ocr {
83 #[arg(long)]
84 image: PathBuf,
85 /// Recognition language, repeatable; omit for the platform's own default.
86 #[arg(long = "language")]
87 languages: Vec<String>,
88 /// Pixels per caller unit, e.g. 2 to pass coordinates in Retina points.
89 #[arg(long, default_value_t = HostInspectScale::PIXELS, value_parser = parse_scale)]
90 scale: HostInspectScale,
91 #[arg(long)]
92 json: bool,
93 },
94 /// Crop an image to a rect and write the result out as a PNG.
95 Crop {
96 #[arg(long)]
97 image: PathBuf,
98 /// Crop rect as x,y,width,height in caller units.
99 #[arg(long, value_parser = parse_rect)]
100 rect: HostInspectRect,
101 /// Destination path for the cropped PNG.
102 #[arg(long)]
103 out: PathBuf,
104 #[arg(long, default_value_t = HostInspectScale::PIXELS, value_parser = parse_scale)]
105 scale: HostInspectScale,
106 #[arg(long)]
107 json: bool,
108 },
109 /// Read one line of an image as a run of colors.
110 Probe {
111 #[arg(long)]
112 image: PathBuf,
113 /// Probe a horizontal line: the row at --position.
114 #[arg(long, conflicts_with = "column")]
115 row: bool,
116 /// Probe a vertical line: the column at --position.
117 #[arg(long, conflicts_with = "row")]
118 column: bool,
119 /// Row or column to read, in caller units.
120 #[arg(long)]
121 position: f64,
122 /// First unit of the probed line; defaults to the image edge.
123 #[arg(long)]
124 from: Option<f64>,
125 /// Last unit of the probed line; defaults to the image edge.
126 #[arg(long)]
127 to: Option<f64>,
128 #[arg(long, default_value_t = HostInspectScale::PIXELS, value_parser = parse_scale)]
129 scale: HostInspectScale,
130 #[arg(long)]
131 json: bool,
132 },
133 }
134
135 fn parse_scale(value: &str) -> Result<HostInspectScale> {
136 value.parse()
137 }
138
139 fn parse_rect(value: &str) -> Result<HostInspectRect> {
140 value.parse()
141 }
142
143 /// Resolve the probe direction. Clap has already rejected `--row --column`;
144 /// this covers the remaining case of neither flag being given.
145 fn probe_axis(row: bool, column: bool) -> Result<HostProbeAxis> {
146 match (row, column) {
147 (true, false) => Ok(HostProbeAxis::Row),
148 (false, true) => Ok(HostProbeAxis::Column),
149 _ => Err(anyhow!("probe needs exactly one of --row or --column")),
150 }
151 }
152
153 #[derive(Debug, Subcommand)]
154 enum SimulatorCommand {
155 Find {
156 #[arg(long)]
157 name: String,
158 #[arg(long)]
159 json: bool,
160 },
161 List {
162 #[arg(long)]
163 json: bool,
164 },
165 }
166
167 #[tokio::main]
168 async fn main() -> Result<()> {
169 let cli = Cli::parse();
170
171 if cli.mcp {
172 return xcrs::mcp::serve().await;
173 }
174
175 let command = cli
176 .command
177 .ok_or_else(|| anyhow!("a command is required unless --mcp is provided"))?;
178 let tools = XcodeCommandLineTools::new();
179
180 match command {
181 Commands::AndroidKit(AndroidKitCommand::Install { serial, apk }) => {
182 AndroidDebugBridge::new().install_androidkit(&serial, &apk)?;
183 println!("Installed XCRS AndroidKit on {serial}");
184 }
185 Commands::AndroidKit(AndroidKitCommand::Start { serial }) => {
186 AndroidDebugBridge::new().start_androidkit(&serial).await?;
187 println!("Started XCRS AndroidKit on {serial}");
188 }
189 Commands::AndroidKit(AndroidKitCommand::Status { serial, json }) => {
190 let info = AndroidKit::new()
191 .call(serial.clone(), "device.info", serde_json::json!({}))
192 .await?;
193 if json {
194 println!("{}", serde_json::to_string_pretty(&info)?);
195 } else {
196 println!("XCRS AndroidKit is ready on {serial}");
197 }
198 }
199 Commands::AndroidKit(AndroidKitCommand::Stop { serial }) => {
200 AndroidDebugBridge::new().stop_androidkit(&serial)?;
201 println!("Stopped XCRS AndroidKit on {serial}");
202 }
203 Commands::Host(HostCommand::Windows { owner, json }) => {
204 let windows = HostInspect::new().windows(owner.as_deref())?;
205 if json {
206 println!("{}", serde_json::to_string_pretty(&windows)?);
207 } else if windows.windows.is_empty() {
208 println!("No on-screen windows");
209 } else {
210 for window in windows.windows {
211 println!(
212 "{} {} {} {}",
213 window.id, window.owner, window.title, window.bounds
214 );
215 }
216 }
217 }
218 Commands::Host(HostCommand::Ocr {
219 image,
220 languages,
221 scale,
222 json,
223 }) => {
224 let result = HostInspect::new().recognize_text(&HostOcrRequest {
225 image,
226 scale,
227 languages,
228 })?;
229 if json {
230 println!("{}", serde_json::to_string_pretty(&result)?);
231 } else if result.results.is_empty() {
232 println!("No text found in {}", result.image.path);
233 } else {
234 for recognized in result.results {
235 println!("{:.2} {}", recognized.confidence, recognized.text);
236 }
237 }
238 }
239 Commands::Host(HostCommand::Crop {
240 image,
241 rect,
242 out,
243 scale,
244 json,
245 }) => {
246 let result = HostInspect::new().crop(&HostCropRequest {
247 image,
248 rect,
249 out,
250 scale,
251 })?;
252 if json {
253 println!("{}", serde_json::to_string_pretty(&result)?);
254 } else {
255 println!(
256 "Wrote {} ({}x{})",
257 result.out, result.pixel_width, result.pixel_height
258 );
259 }
260 }
261 Commands::Host(HostCommand::Probe {
262 image,
263 row,
264 column,
265 position,
266 from,
267 to,
268 scale,
269 json,
270 }) => {
271 let axis = probe_axis(row, column)?;
272 let result = HostInspect::new().probe(&HostProbeRequest {
273 image,
274 axis,
275 position,
276 from,
277 to,
278 scale,
279 })?;
280 if json {
281 println!("{}", serde_json::to_string_pretty(&result)?);
282 } else {
283 for run in result.runs {
284 println!("{} {} {} {}", run.start, run.end, run.width, run.color);
285 }
286 }
287 }
288 Commands::Simulator(SimulatorCommand::Find { name, json }) => {
289 let simulator = tools.simctl().find_simulator_by_name(&name)?;
290 if json {
291 println!("{}", serde_json::to_string_pretty(&simulator)?);
292 } else {
293 println!("{} {}", simulator.name, simulator.udid);
294 }
295 }
296 Commands::Simulator(SimulatorCommand::List { json }) => {
297 let simulators = tools.simctl().list_simulators()?;
298 if json {
299 println!("{}", serde_json::to_string_pretty(&simulators)?);
300 } else {
301 for simulator in simulators {
302 println!("{} {} {}", simulator.name, simulator.udid, simulator.state);
303 }
304 }
305 }
306 Commands::IosAppTest {
307 simulator_name,
308 simulator_udid,
309 app_path,
310 bundle_id,
311 terminate_before_launch,
312 json,
313 } => {
314 let result = tools.run_ios_app_test(&IosAppTest {
315 simulator_name,
316 simulator_udid,
317 app_path,
318 bundle_id,
319 terminate_before_launch,
320 })?;
321
322 if json {
323 println!("{}", serde_json::to_string_pretty(&result)?);
324 } else {
325 println!(
326 "Launched {} on {} ({})",
327 result.bundle_id, result.simulator.name, result.simulator.udid
328 );
329 }
330 }
331 }
332
333 Ok(())
334 }
335
336 #[cfg(test)]
337 mod tests {
338 use super::*;
339
340 fn host(arguments: &[&str]) -> HostCommand {
341 match Cli::try_parse_from(arguments)
342 .unwrap_or_else(|error| panic!("failed to parse {arguments:?}: {error}"))
343 .command
344 {
345 Some(Commands::Host(command)) => command,
346 other => panic!("expected a host command, got {other:?}"),
347 }
348 }
349
350 #[test]
351 fn parses_the_host_windows_command() {
352 let HostCommand::Windows { owner, json } = host(&["xcrs", "host", "windows", "--json"])
353 else {
354 panic!("expected the windows command");
355 };
356
357 assert_eq!(owner, None);
358 assert!(json);
359 }
360
361 #[test]
362 fn filters_windows_by_owner() {
363 let HostCommand::Windows { owner, json } =
364 host(&["xcrs", "host", "windows", "--owner", "Xcode"])
365 else {
366 panic!("expected the windows command");
367 };
368
369 assert_eq!(owner.as_deref(), Some("Xcode"));
370 assert!(!json);
371 }
372
373 #[test]
374 fn defaults_the_host_scale_to_pixels() {
375 let HostCommand::Ocr {
376 languages, scale, ..
377 } = host(&["xcrs", "host", "ocr", "--image", "shot.png"])
378 else {
379 panic!("expected the ocr command");
380 };
381
382 assert!(languages.is_empty());
383 assert_eq!(scale, HostInspectScale::PIXELS);
384 }
385
386 #[test]
387 fn collects_repeated_languages_and_the_scale() {
388 let HostCommand::Ocr {
389 image,
390 languages,
391 scale,
392 ..
393 } = host(&[
394 "xcrs",
395 "host",
396 "ocr",
397 "--image",
398 "shot.png",
399 "--language",
400 "en-US",
401 "--language",
402 "sv-SE",
403 "--scale",
404 "2",
405 ])
406 else {
407 panic!("expected the ocr command");
408 };
409
410 assert_eq!(image, PathBuf::from("shot.png"));
411 assert_eq!(languages, ["en-US", "sv-SE"]);
412 assert_eq!(scale.pixels_per_unit(), 2.0);
413 }
414
415 #[test]
416 fn parses_the_host_crop_rect() {
417 let HostCommand::Crop {
418 image, rect, out, ..
419 } = host(&[
420 "xcrs",
421 "host",
422 "crop",
423 "--image",
424 "shot.png",
425 "--rect",
426 "1,2,30,40",
427 "--out",
428 "crop.png",
429 ])
430 else {
431 panic!("expected the crop command");
432 };
433
434 assert_eq!(image, PathBuf::from("shot.png"));
435 assert_eq!(rect, HostInspectRect::new(1.0, 2.0, 30.0, 40.0));
436 assert_eq!(out, PathBuf::from("crop.png"));
437 }
438
439 #[test]
440 fn rejects_a_crop_rect_that_is_not_four_numbers() {
441 let error = Cli::try_parse_from([
442 "xcrs", "host", "crop", "--image", "shot.png", "--rect", "1,2,3", "--out", "crop.png",
443 ])
444 .expect_err("a three part rect should not parse");
445
446 assert!(error.to_string().contains("x,y,w,h"), "{error}");
447 }
448
449 #[test]
450 fn parses_the_probe_bounds() {
451 let HostCommand::Probe {
452 image,
453 row,
454 column,
455 position,
456 from,
457 to,
458 scale,
459 json,
460 } = host(&[
461 "xcrs",
462 "host",
463 "probe",
464 "--image",
465 "shot.png",
466 "--row",
467 "--position",
468 "12",
469 "--from",
470 "4",
471 "--to",
472 "20",
473 ])
474 else {
475 panic!("expected the probe command");
476 };
477
478 assert_eq!(image, PathBuf::from("shot.png"));
479 assert_eq!(probe_axis(row, column).expect("row"), HostProbeAxis::Row);
480 assert_eq!(position, 12.0);
481 assert_eq!(from, Some(4.0));
482 assert_eq!(to, Some(20.0));
483 assert_eq!(scale, HostInspectScale::PIXELS);
484 assert!(!json);
485 }
486
487 #[test]
488 fn rejects_a_probe_with_both_axes() {
489 let error = Cli::try_parse_from([
490 "xcrs",
491 "host",
492 "probe",
493 "--image",
494 "shot.png",
495 "--row",
496 "--column",
497 "--position",
498 "12",
499 ])
500 .expect_err("--row and --column should conflict");
501
502 assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
503 }
504
505 #[test]
506 fn rejects_a_probe_without_an_axis() {
507 let error = probe_axis(false, false).expect_err("an axis is required");
508
509 assert!(error.to_string().contains("--row or --column"), "{error}");
510 }
511 }