1 use anyhow::{anyhow, Context, Result};
2 use serde::{Deserialize, Serialize};
3 use std::collections::BTreeMap;
4 use std::env;
5 use std::ffi::OsStr;
6 use std::path::{Path, PathBuf};
7 use std::process::{Child, Command, Output, Stdio};
8 use std::time::Duration;
9
10 pub mod mcp;
11
12 const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id=";
13 const CONTROLKIT_METHOD_NOT_FOUND: i64 = -32601;
14 const CONTROLKIT_RUNNER_INFO_TIMEOUT: Duration = Duration::from_secs(2);
15 const ANDROIDKIT_CALL_TIMEOUT: Duration = Duration::from_secs(10);
16 const ANDROIDKIT_START_TIMEOUT: Duration = Duration::from_secs(10);
17 const ANDROIDKIT_START_PROBE_TIMEOUT: Duration = Duration::from_millis(500);
18 const ANDROIDKIT_START_RETRY_DELAY: Duration = Duration::from_millis(100);
19
20 pub fn encode_base64(data: impl AsRef<[u8]>) -> String {
21 use base64::{engine::general_purpose::STANDARD, Engine};
22
23 STANDARD.encode(data)
24 }
25
26 pub fn extract_controlkit_elements(root: &serde_json::Value) -> Vec<serde_json::Value> {
27 let mut elements = Vec::new();
28 collect_controlkit_elements(root, &mut elements);
29 elements
30 }
31
32 /// Normalize actionable AndroidKit nodes into the public element-list shape.
33 pub fn extract_androidkit_elements(root: &serde_json::Value) -> Vec<serde_json::Value> {
34 let mut elements = Vec::new();
35 if let Some(hierarchy) = root.get("hierarchy").and_then(serde_json::Value::as_array) {
36 for element in hierarchy {
37 collect_androidkit_elements(element, &mut elements);
38 }
39 } else {
40 collect_androidkit_elements(root, &mut elements);
41 }
42 elements
43 }
44
45 fn collect_androidkit_elements(element: &serde_json::Value, elements: &mut Vec<serde_json::Value>) {
46 let Some(object) = element.as_object() else {
47 return;
48 };
49
50 let rect = object.get("rect").and_then(serde_json::Value::as_object);
51 let has_visible_rect = object
52 .get("visible")
53 .and_then(serde_json::Value::as_bool)
54 .unwrap_or(false)
55 && rect
56 .and_then(|rect| {
57 Some((
58 rect.get("x")?.as_f64()?,
59 rect.get("y")?.as_f64()?,
60 rect.get("width")?.as_f64()?,
61 rect.get("height")?.as_f64()?,
62 ))
63 })
64 .is_some_and(|(_, _, width, height)| width > 0.0 && height > 0.0);
65 let text = object
66 .get("text")
67 .and_then(serde_json::Value::as_str)
68 .unwrap_or_default();
69 let description = object
70 .get("contentDescription")
71 .and_then(serde_json::Value::as_str)
72 .unwrap_or_default();
73 let resource_id = object
74 .get("resourceId")
75 .and_then(serde_json::Value::as_str)
76 .unwrap_or_default();
77 let hint = object
78 .get("hint")
79 .and_then(serde_json::Value::as_str)
80 .unwrap_or_default();
81
82 if has_visible_rect
83 && [text, description, resource_id, hint]
84 .iter()
85 .any(|value| !value.is_empty())
86 {
87 elements.push(serde_json::json!({
88 "type": object.get("role").cloned().unwrap_or(serde_json::Value::Null),
89 "label": if !text.is_empty() { text } else { description },
90 "name": resource_id,
91 "value": text,
92 "placeholderValue": hint,
93 "rawIdentifier": resource_id,
94 "rect": object.get("rect").cloned().unwrap_or(serde_json::Value::Null),
95 "enabled": object.get("enabled").cloned().unwrap_or(serde_json::Value::Bool(false)),
96 "selected": object.get("selected").cloned().unwrap_or(serde_json::Value::Bool(false)),
97 "hittable": object.get("clickable").cloned().unwrap_or(serde_json::Value::Bool(false)),
98 }));
99 }
100
101 if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
102 for child in children {
103 collect_androidkit_elements(child, elements);
104 }
105 }
106 }
107
108 fn collect_controlkit_elements(element: &serde_json::Value, elements: &mut Vec<serde_json::Value>) {
109 let object = match element.as_object() {
110 Some(object) => object,
111 None => return,
112 };
113
114 let rect = object.get("rect").and_then(serde_json::Value::as_object);
115 let has_visible_rect = rect
116 .and_then(|rect| {
117 Some((
118 rect.get("x")?.as_f64()?,
119 rect.get("y")?.as_f64()?,
120 rect.get("width")?.as_f64()?,
121 rect.get("height")?.as_f64()?,
122 ))
123 })
124 .is_some_and(|(x, y, width, height)| x >= 0.0 && y >= 0.0 && width > 0.0 && height > 0.0);
125 let has_identity = ["label", "name", "value", "rawIdentifier"]
126 .iter()
127 .any(|key| {
128 object
129 .get(*key)
130 .and_then(serde_json::Value::as_str)
131 .is_some_and(|value| !value.is_empty())
132 });
133
134 if has_visible_rect && has_identity {
135 let keys = [
136 "type",
137 "label",
138 "name",
139 "value",
140 "placeholderValue",
141 "rawIdentifier",
142 "rect",
143 "depth",
144 "enabled",
145 "selected",
146 "hittable",
147 ];
148 let element = keys
149 .iter()
150 .filter_map(|key| {
151 object
152 .get(*key)
153 .map(|value| ((*key).to_string(), value.clone()))
154 })
155 .collect();
156 elements.push(serde_json::Value::Object(element));
157 }
158
159 if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
160 for child in children {
161 collect_controlkit_elements(child, elements);
162 }
163 }
164 }
165
166 #[derive(Debug, Clone)]
167 pub struct ControlKit {
168 client: reqwest::Client,
169 base_url: String,
170 }
171
172 impl Default for ControlKit {
173 fn default() -> Self {
174 Self::new(12004)
175 }
176 }
177
178 impl ControlKit {
179 pub fn new(port: u16) -> Self {
180 Self::with_host("127.0.0.1", port)
181 }
182
183 pub fn with_host(host: &str, port: u16) -> Self {
184 let host = if host.contains(':') && !host.starts_with('[') {
185 format!("[{host}]")
186 } else {
187 host.to_string()
188 };
189 Self {
190 client: reqwest::Client::new(),
191 base_url: format!("http://{host}:{port}"),
192 }
193 }
194
195 pub async fn health(&self) -> Result<()> {
196 let response = self
197 .client
198 .get(format!("{}/health", self.base_url))
199 .timeout(Duration::from_secs(5))
200 .send()
201 .await
202 .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?;
203
204 if !response.status().is_success() {
205 return Err(anyhow!(
206 "ControlKit health check returned HTTP {}",
207 response.status()
208 ));
209 }
210
211 Ok(())
212 }
213
214 pub async fn call(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
215 let body = self.send(method, params).await?;
216
217 if let Some(error) = body.get("error") {
218 // `send` cannot recurse; this guard only avoids repeating an unsupported probe.
219 let should_probe_runner =
220 is_controlkit_method_not_found(error) && method != "device.info";
221 let (runner_info, runner_info_error) = if should_probe_runner {
222 match tokio::time::timeout(
223 CONTROLKIT_RUNNER_INFO_TIMEOUT,
224 self.send("device.info", serde_json::json!({})),
225 )
226 .await
227 {
228 Ok(Ok(body)) => match body.get("result") {
229 Some(result) => (Some(result.clone()), None),
230 None => {
231 let reason = body
232 .get("error")
233 .map(|error| format!("device.info returned {error}"))
234 .unwrap_or_else(|| {
235 "device.info response did not contain a result".to_string()
236 });
237 (None, Some(reason))
238 }
239 },
240 Ok(Err(error)) => (None, Some(error.to_string())),
241 Err(_) => (
242 None,
243 Some(format!(
244 "device.info timed out after {} seconds",
245 CONTROLKIT_RUNNER_INFO_TIMEOUT.as_secs()
246 )),
247 ),
248 }
249 } else {
250 (None, None)
251 };
252 return Err(anyhow!(controlkit_rpc_error(
253 method,
254 error,
255 runner_info.as_ref(),
256 runner_info_error.as_deref()
257 )));
258 }
259
260 body.get("result")
261 .cloned()
262 .ok_or_else(|| anyhow!("ControlKit response for '{method}' did not contain a result"))
263 }
264
265 async fn send(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
266 let response = self
267 .client
268 .post(format!("{}/rpc", self.base_url))
269 .timeout(Duration::from_secs(30))
270 .json(&serde_json::json!({
271 "jsonrpc": "2.0",
272 "method": method,
273 "params": params,
274 "id": 1,
275 }))
276 .send()
277 .await
278 .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?;
279
280 let status = response.status();
281 let body: serde_json::Value = response
282 .json()
283 .await
284 .context("failed to decode ControlKit JSON-RPC response")?;
285
286 if !status.is_success() {
287 return Err(anyhow!("ControlKit returned HTTP {}: {}", status, body));
288 }
289
290 Ok(body)
291 }
292 }
293
294 fn is_controlkit_method_not_found(error: &serde_json::Value) -> bool {
295 let Some(code) = error.get("code") else {
296 return false;
297 };
298
299 // Some runners serialise the JSON-RPC code as an integral float (e.g. `-32601.0`),
300 // which `as_i64` rejects, so fall back to an exact `as_f64` comparison for that case.
301 code.as_i64() == Some(CONTROLKIT_METHOD_NOT_FOUND)
302 || code.as_f64() == Some(CONTROLKIT_METHOD_NOT_FOUND as f64)
303 }
304
305 fn controlkit_rpc_error(
306 method: &str,
307 error: &serde_json::Value,
308 runner_info: Option<&serde_json::Value>,
309 runner_info_error: Option<&str>,
310 ) -> String {
311 let code = error
312 .get("code")
313 .and_then(serde_json::Value::as_i64)
314 .unwrap_or_default();
315 let message = error
316 .get("message")
317 .and_then(serde_json::Value::as_str)
318 .unwrap_or("unknown JSON-RPC error");
319
320 if !is_controlkit_method_not_found(error) {
321 return format!("ControlKit method '{method}' failed ({code}): {message}");
322 }
323
324 let runner = runner_info
325 .and_then(|info| info.get("runner"))
326 .and_then(serde_json::Value::as_str)
327 .unwrap_or("The connected ControlKit runner");
328 let protocol = runner_info
329 .and_then(|info| info.get("protocolVersion"))
330 .and_then(serde_json::Value::as_u64)
331 .map(|version| format!(" protocol {version}"))
332 .unwrap_or_default();
333
334 let runner_info_note = runner_info_error
335 .map(|error| format!(" Runner metadata could not be read: {error}."))
336 .unwrap_or_default();
337
338 format!(
339 "{runner}{protocol} does not implement ControlKit method '{method}'.{runner_info_note} Rebuild or upgrade xcrs-controlkit, restart its runner, and retry."
340 )
341 }
342
343 #[derive(Debug, Clone)]
344 pub struct XcodeCommandLineTools {
345 xcrun_path: PathBuf,
346 xcodebuild_path: PathBuf,
347 }
348
349 impl Default for XcodeCommandLineTools {
350 fn default() -> Self {
351 Self {
352 xcrun_path: PathBuf::from("xcrun"),
353 xcodebuild_path: PathBuf::from("xcodebuild"),
354 }
355 }
356 }
357
358 impl XcodeCommandLineTools {
359 pub fn new() -> Self {
360 Self::default()
361 }
362
363 pub fn with_paths(xcrun_path: impl Into<PathBuf>, xcodebuild_path: impl Into<PathBuf>) -> Self {
364 Self {
365 xcrun_path: xcrun_path.into(),
366 xcodebuild_path: xcodebuild_path.into(),
367 }
368 }
369
370 pub fn simctl(&self) -> Simctl<'_> {
371 Simctl { tools: self }
372 }
373
374 pub fn xcodebuild(&self) -> Xcodebuild<'_> {
375 Xcodebuild { tools: self }
376 }
377
378 pub fn devicectl(&self) -> Devicectl<'_> {
379 Devicectl { tools: self }
380 }
381
382 fn run_xcrun<I, S>(&self, args: I) -> Result<String>
383 where
384 I: IntoIterator<Item = S>,
385 S: AsRef<OsStr>,
386 {
387 run_command(&self.xcrun_path, args)
388 }
389
390 fn run_xcodebuild<I, S>(&self, args: I) -> Result<String>
391 where
392 I: IntoIterator<Item = S>,
393 S: AsRef<OsStr>,
394 {
395 run_command(&self.xcodebuild_path, args)
396 }
397 }
398
399 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
400 pub enum ApplePlatform {
401 #[serde(rename = "iOS")]
402 Ios,
403 #[serde(rename = "tvOS")]
404 Tvos,
405 #[serde(rename = "macOS")]
406 Macos,
407 #[serde(rename = "watchOS")]
408 Watchos,
409 #[serde(rename = "visionOS")]
410 Visionos,
411 #[serde(rename = "unknown")]
412 Unknown,
413 }
414
415 impl ApplePlatform {
416 fn from_runtime_identifier(runtime_identifier: &str) -> Self {
417 if runtime_identifier.contains(".iOS-") {
418 Self::Ios
419 } else if runtime_identifier.contains(".tvOS-") {
420 Self::Tvos
421 } else if runtime_identifier.contains(".macOS-") {
422 Self::Macos
423 } else if runtime_identifier.contains(".watchOS-") {
424 Self::Watchos
425 } else if runtime_identifier.contains(".visionOS-") || runtime_identifier.contains(".xrOS-")
426 {
427 Self::Visionos
428 } else {
429 Self::Unknown
430 }
431 }
432 }
433
434 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
435 pub struct Simulator {
436 pub platform: ApplePlatform,
437 pub runtime_identifier: String,
438 pub name: String,
439 pub udid: String,
440 pub state: String,
441 pub is_available: bool,
442 }
443
444 impl Simulator {
445 pub fn is_booted(&self) -> bool {
446 self.state == "Booted"
447 }
448 }
449
450 #[derive(Debug, Deserialize)]
451 struct SimctlDeviceList {
452 devices: BTreeMap<String, Vec<SimctlDevice>>,
453 }
454
455 #[derive(Debug, Deserialize)]
456 struct SimctlDevice {
457 name: String,
458 udid: String,
459 state: String,
460 #[serde(rename = "isAvailable")]
461 is_available: Option<bool>,
462 }
463
464 pub struct Simctl<'a> {
465 tools: &'a XcodeCommandLineTools,
466 }
467
468 impl Simctl<'_> {
469 pub fn list_simulators(&self) -> Result<Vec<Simulator>> {
470 let output = self
471 .tools
472 .run_xcrun(["simctl", "list", "devices", "--json"])?;
473 parse_simctl_devices(&output)
474 }
475
476 pub fn find_simulator_by_name(&self, name: &str) -> Result<Simulator> {
477 self.list_simulators()?
478 .into_iter()
479 .find(|simulator| simulator.name == name)
480 .ok_or_else(|| anyhow!("Apple simulator named '{name}' was not found"))
481 }
482
483 pub fn find_simulator_by_udid(&self, udid: &str) -> Result<Simulator> {
484 self.list_simulators()?
485 .into_iter()
486 .find(|simulator| simulator.udid == udid)
487 .ok_or_else(|| anyhow!("Apple simulator with UDID '{udid}' was not found"))
488 }
489
490 pub fn boot(&self, udid: &str) -> Result<()> {
491 if self.find_simulator_by_udid(udid)?.is_booted() {
492 return Ok(());
493 }
494
495 self.tools.run_xcrun(["simctl", "boot", udid])?;
496 Ok(())
497 }
498
499 pub fn shutdown(&self, udid: &str) -> Result<()> {
500 if !self.find_simulator_by_udid(udid)?.is_booted() {
501 return Ok(());
502 }
503
504 self.tools.run_xcrun(["simctl", "shutdown", udid])?;
505 Ok(())
506 }
507
508 pub fn install_app(&self, udid: &str, app_path: impl AsRef<Path>) -> Result<()> {
509 let app_path = app_path.as_ref();
510 ensure_path_exists(app_path)?;
511
512 let path = app_path
513 .to_str()
514 .ok_or_else(|| anyhow!("app path is not valid UTF-8: {}", app_path.display()))?;
515
516 self.tools.run_xcrun(["simctl", "install", udid, path])?;
517 Ok(())
518 }
519
520 pub fn uninstall_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
521 self.tools
522 .run_xcrun(["simctl", "uninstall", udid, bundle_id])?;
523 Ok(())
524 }
525
526 pub fn launch_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
527 self.tools
528 .run_xcrun(["simctl", "launch", udid, bundle_id])?;
529 Ok(())
530 }
531
532 pub fn terminate_app(&self, udid: &str, bundle_id: &str) -> Result<()> {
533 self.tools
534 .run_xcrun(["simctl", "terminate", udid, bundle_id])?;
535 Ok(())
536 }
537
538 pub fn open_url(&self, udid: &str, url: &str) -> Result<()> {
539 self.tools.run_xcrun(["simctl", "openurl", udid, url])?;
540 Ok(())
541 }
542
543 pub fn screenshot(&self, udid: &str) -> Result<Vec<u8>> {
544 let temporary_directory = tempfile::tempdir()?;
545 let screenshot_path = temporary_directory.path().join("screenshot.png");
546 let screenshot_path_string = screenshot_path
547 .to_str()
548 .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?;
549
550 self.tools
551 .run_xcrun(["simctl", "io", udid, "screenshot", screenshot_path_string])?;
552
553 std::fs::read(&screenshot_path)
554 .with_context(|| format!("failed to read {}", screenshot_path.display()))
555 }
556
557 pub fn app_container_path(&self, udid: &str, bundle_id: &str) -> Result<PathBuf> {
558 let output =
559 self.tools
560 .run_xcrun(["simctl", "get_app_container", udid, bundle_id, "app"])?;
561 let path = output.trim();
562 if path.is_empty() {
563 return Err(anyhow!(
564 "simctl returned an empty app container path for '{bundle_id}'"
565 ));
566 }
567
568 Ok(PathBuf::from(path))
569 }
570 }
571
572 pub struct Devicectl<'a> {
573 tools: &'a XcodeCommandLineTools,
574 }
575
576 impl Devicectl<'_> {
577 /// Captures a PNG screenshot from a physical device known to CoreDevice.
578 /// `device` accepts anything `devicectl --device` does: UDID, ECID,
579 /// serial number, user-provided name, or DNS name.
580 pub fn screenshot(&self, device: &str) -> Result<Vec<u8>> {
581 let temporary_directory = tempfile::tempdir()?;
582 let screenshot_path = temporary_directory.path().join("screenshot.png");
583 let screenshot_path_string = screenshot_path
584 .to_str()
585 .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?;
586
587 self.tools.run_xcrun([
588 "devicectl",
589 "device",
590 "capture",
591 "screenshot",
592 "--device",
593 device,
594 "--destination",
595 screenshot_path_string,
596 ])?;
597
598 std::fs::read(&screenshot_path)
599 .with_context(|| format!("failed to read {}", screenshot_path.display()))
600 }
601 }
602
603 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
604 pub struct AndroidDevice {
605 pub serial: String,
606 pub state: String,
607 pub product: Option<String>,
608 pub model: Option<String>,
609 pub device: Option<String>,
610 pub transport_id: Option<String>,
611 }
612
613 impl AndroidDevice {
614 pub fn is_connected(&self) -> bool {
615 self.state == "device"
616 }
617 }
618
619 /// The automation platform a normalized `device_list` entry belongs to.
620 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
621 #[serde(rename_all = "snake_case")]
622 pub enum DevicePlatform {
623 Apple,
624 Android,
625 }
626
627 /// The kind of device a normalized `device_list` entry represents. Apple physical
628 /// devices are not represented here: this crate has no reliable Apple
629 /// device-discovery command, so only Apple simulators are ever listed.
630 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
631 #[serde(rename_all = "snake_case")]
632 pub enum DeviceKind {
633 Simulator,
634 Emulator,
635 PhysicalDevice,
636 }
637
638 /// A device entry normalized across Apple simulators and Android devices/emulators
639 /// for the cross-platform `device_list` tool.
640 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
641 pub struct NormalizedDevice {
642 pub platform: DevicePlatform,
643 pub kind: DeviceKind,
644 /// Simulator UDID (Apple) or device serial (Android).
645 pub identifier: String,
646 pub name: String,
647 pub state: String,
648 /// The tool used to reach this device: "simctl" or "adb".
649 pub transport: String,
650 }
651
652 impl NormalizedDevice {
653 pub fn from_simulator(simulator: &Simulator) -> Self {
654 Self {
655 platform: DevicePlatform::Apple,
656 kind: DeviceKind::Simulator,
657 identifier: simulator.udid.clone(),
658 name: simulator.name.clone(),
659 state: simulator.state.clone(),
660 transport: "simctl".to_string(),
661 }
662 }
663
664 pub fn from_android_device(device: &AndroidDevice) -> Self {
665 let kind = if device.serial.starts_with("emulator-") {
666 DeviceKind::Emulator
667 } else {
668 DeviceKind::PhysicalDevice
669 };
670 let name = device
671 .model
672 .clone()
673 .or_else(|| device.device.clone())
674 .or_else(|| device.product.clone())
675 .unwrap_or_else(|| device.serial.clone());
676 Self {
677 platform: DevicePlatform::Android,
678 kind,
679 identifier: device.serial.clone(),
680 name,
681 state: device.state.clone(),
682 transport: "adb".to_string(),
683 }
684 }
685 }
686
687 #[derive(Debug, Clone)]
688 pub struct AndroidDebugBridge {
689 adb_path: PathBuf,
690 }
691
692 /// Client for an XCRS AndroidKit instrumentation runner forwarded over adb.
693 #[derive(Debug, Clone)]
694 pub struct AndroidKit {
695 client: reqwest::Client,
696 adb_path: PathBuf,
697 }
698
699 impl Default for AndroidKit {
700 fn default() -> Self {
701 Self {
702 client: reqwest::Client::new(),
703 adb_path: discover_adb_path(),
704 }
705 }
706 }
707
708 impl AndroidKit {
709 pub const PACKAGE_NAME: &'static str = "xyz.smbcloud.xcrs.androidkit";
710
711 pub fn new() -> Self {
712 Self::default()
713 }
714
715 pub async fn call(
716 &self,
717 serial: String,
718 method: &str,
719 params: serde_json::Value,
720 ) -> Result<serde_json::Value> {
721 self.call_with_timeout(serial, method, params, ANDROIDKIT_CALL_TIMEOUT)
722 .await
723 }
724
725 fn with_adb_path(adb_path: impl Into<PathBuf>) -> Self {
726 Self {
727 client: reqwest::Client::new(),
728 adb_path: adb_path.into(),
729 }
730 }
731
732 async fn call_with_timeout(
733 &self,
734 serial: String,
735 method: &str,
736 params: serde_json::Value,
737 timeout: Duration,
738 ) -> Result<serde_json::Value> {
739 let bridge = AndroidDebugBridge::with_path(self.adb_path.clone());
740 let local_port = tokio::task::spawn_blocking({
741 let serial = serial.clone();
742 move || bridge.forward_androidkit(&serial)
743 })
744 .await
745 .context("AndroidKit forwarding task failed")??;
746
747 let response = self
748 .client
749 .post(format!("http://127.0.0.1:{local_port}/rpc"))
750 .timeout(timeout)
751 .json(&serde_json::json!({
752 "jsonrpc": "2.0",
753 "id": 1,
754 "method": method,
755 "params": params,
756 }))
757 .send()
758 .await
759 .with_context(|| format!("failed to connect to XCRS AndroidKit for {serial}"));
760
761 let cleanup_bridge = AndroidDebugBridge::with_path(self.adb_path.clone());
762 let cleanup_serial = serial.clone();
763 let cleanup = tokio::task::spawn_blocking(move || {
764 cleanup_bridge.remove_forward(&cleanup_serial, local_port)
765 })
766 .await
767 .context("AndroidKit forwarding cleanup task failed")?;
768 cleanup.context("failed to remove AndroidKit adb forward")?;
769
770 let response = response?;
771 let status = response.status();
772 let body: serde_json::Value = response
773 .json()
774 .await
775 .context("failed to decode AndroidKit JSON-RPC response")?;
776 if !status.is_success() {
777 return Err(anyhow!("AndroidKit returned HTTP {status}: {body}"));
778 }
779 if let Some(error) = body.get("error") {
780 let code = error
781 .get("code")
782 .and_then(serde_json::Value::as_i64)
783 .unwrap_or_default();
784 let message = error
785 .get("message")
786 .and_then(serde_json::Value::as_str)
787 .unwrap_or("unknown JSON-RPC error");
788 return Err(anyhow!(
789 "AndroidKit method '{method}' failed ({code}): {message}"
790 ));
791 }
792 body.get("result")
793 .cloned()
794 .ok_or_else(|| anyhow!("AndroidKit response for '{method}' did not contain a result"))
795 }
796
797 async fn wait_until_ready(&self, serial: &str) -> Result<()> {
798 let deadline = tokio::time::Instant::now() + ANDROIDKIT_START_TIMEOUT;
799 loop {
800 match self
801 .call_with_timeout(
802 serial.to_string(),
803 "device.info",
804 serde_json::json!({}),
805 ANDROIDKIT_START_PROBE_TIMEOUT,
806 )
807 .await
808 {
809 Ok(_) => return Ok(()),
810 Err(error) if tokio::time::Instant::now() >= deadline => {
811 return Err(error).with_context(|| {
812 format!(
813 "AndroidKit did not become ready on {serial} within {} seconds",
814 ANDROIDKIT_START_TIMEOUT.as_secs()
815 )
816 });
817 }
818 Err(_) => tokio::time::sleep(ANDROIDKIT_START_RETRY_DELAY).await,
819 }
820 }
821 }
822 }
823
824 impl Default for AndroidDebugBridge {
825 fn default() -> Self {
826 Self {
827 adb_path: discover_adb_path(),
828 }
829 }
830 }
831
832 impl AndroidDebugBridge {
833 pub fn new() -> Self {
834 Self::default()
835 }
836
837 pub fn with_path(adb_path: impl Into<PathBuf>) -> Self {
838 Self {
839 adb_path: adb_path.into(),
840 }
841 }
842
843 pub fn list_devices(&self) -> Result<Vec<AndroidDevice>> {
844 let output = run_command(&self.adb_path, ["devices", "-l"])?;
845 parse_adb_devices(&output)
846 }
847
848 pub fn resolve_device(&self, serial: Option<&str>) -> Result<AndroidDevice> {
849 let devices = self.list_devices()?;
850 if let Some(serial) = serial {
851 let device = devices
852 .into_iter()
853 .find(|device| device.serial == serial)
854 .ok_or_else(|| anyhow!("Android device '{serial}' was not found"))?;
855 if !device.is_connected() {
856 return Err(anyhow!(
857 "Android device '{}' is {}",
858 device.serial,
859 device.state
860 ));
861 }
862 return Ok(device);
863 }
864
865 let connected_devices = devices
866 .into_iter()
867 .filter(AndroidDevice::is_connected)
868 .collect::<Vec<_>>();
869 match connected_devices.as_slice() {
870 [device] => Ok(device.clone()),
871 [] => Err(anyhow!(
872 "no authorized Android device is connected; check `adb devices -l`"
873 )),
874 _ => Err(anyhow!(
875 "multiple Android devices are connected; provide a device serial"
876 )),
877 }
878 }
879
880 pub fn screenshot(&self, serial: &str) -> Result<Vec<u8>> {
881 self.run_for_device_bytes(serial, ["exec-out", "screencap", "-p"])
882 }
883
884 pub fn launch_app(&self, serial: &str, package_name: &str) -> Result<()> {
885 validate_android_package_name(package_name)?;
886 let component = [
887 "android.intent.category.LEANBACK_LAUNCHER",
888 "android.intent.category.LAUNCHER",
889 ]
890 .into_iter()
891 .find_map(|category| {
892 let output = self
893 .run_shell(
894 serial,
895 &format!(
896 "cmd package resolve-activity --brief -a android.intent.action.MAIN -c {category} {}",
897 shell_escape(package_name)
898 ),
899 )
900 .ok()?;
901 parse_resolved_android_activity(&output)
902 })
903 .ok_or_else(|| anyhow!("no launchable activity found for Android app '{package_name}'"))?;
904
905 let output = self.run_shell(
906 serial,
907 &format!("am start -W -n {}", shell_escape(&component)),
908 )?;
909 if output.lines().any(|line| line.starts_with("Error:")) {
910 return Err(anyhow!(
911 "failed to launch Android app '{package_name}': {}",
912 output.trim()
913 ));
914 }
915 Ok(())
916 }
917
918 pub fn terminate_app(&self, serial: &str, package_name: &str) -> Result<()> {
919 validate_android_package_name(package_name)?;
920 self.run_shell(
921 serial,
922 &format!("am force-stop {}", shell_escape(package_name)),
923 )?;
924 Ok(())
925 }
926
927 pub fn open_url(&self, serial: &str, url: &str) -> Result<()> {
928 if url.is_empty() || url.contains(['\0', '\n', '\r']) {
929 return Err(anyhow!("URL must be a non-empty single-line value"));
930 }
931 self.run_shell(
932 serial,
933 &format!(
934 "am start -a android.intent.action.VIEW -d {}",
935 shell_escape(url)
936 ),
937 )?;
938 Ok(())
939 }
940
941 pub fn tap(&self, serial: &str, x: i32, y: i32) -> Result<()> {
942 self.run_shell(serial, &format!("input tap {x} {y}"))?;
943 Ok(())
944 }
945
946 pub fn type_text(&self, serial: &str, text: &str) -> Result<()> {
947 let text = encode_adb_input_text(text)?;
948 self.run_shell(serial, &format!("input text {}", shell_escape(&text)))?;
949 Ok(())
950 }
951
952 pub fn swipe(
953 &self,
954 serial: &str,
955 x1: i32,
956 y1: i32,
957 x2: i32,
958 y2: i32,
959 duration_ms: u32,
960 ) -> Result<()> {
961 if duration_ms > 10_000 {
962 return Err(anyhow!("swipe duration must not exceed 10000 milliseconds"));
963 }
964 self.run_shell(
965 serial,
966 &format!("input swipe {x1} {y1} {x2} {y2} {duration_ms}"),
967 )?;
968 Ok(())
969 }
970
971 pub fn press_button(&self, serial: &str, button: &str) -> Result<()> {
972 let keycode = match button {
973 "home" => 3,
974 "back" => 4,
975 "enter" => 66,
976 "recents" => 187,
977 _ => {
978 return Err(anyhow!(
979 "button must be one of home, back, enter, or recents"
980 ))
981 }
982 };
983 self.run_shell(serial, &format!("input keyevent {keycode}"))?;
984 Ok(())
985 }
986
987 pub fn install_androidkit(&self, serial: &str, apk_path: &Path) -> Result<()> {
988 if !apk_path.is_file() {
989 return Err(anyhow!(
990 "AndroidKit APK does not exist: {}",
991 apk_path.display()
992 ));
993 }
994 run_command(
995 &self.adb_path,
996 [
997 "-s",
998 serial,
999 "install",
1000 "-r",
1001 apk_path
1002 .to_str()
1003 .ok_or_else(|| anyhow!("AndroidKit APK path is not valid UTF-8"))?,
1004 ],
1005 )?;
1006 Ok(())
1007 }
1008
1009 pub async fn start_androidkit(&self, serial: &str) -> Result<()> {
1010 self.run_shell(
1011 serial,
1012 "nohup am instrument -w -r xyz.smbcloud.xcrs.androidkit/.AndroidKitInstrumentation >/dev/null 2>&1 &",
1013 )
1014 .with_context(|| format!("failed to start AndroidKit on {serial}"))?;
1015 AndroidKit::with_adb_path(self.adb_path.clone())
1016 .wait_until_ready(serial)
1017 .await
1018 .with_context(|| format!("failed to start AndroidKit on {serial}"))
1019 }
1020
1021 pub fn stop_androidkit(&self, serial: &str) -> Result<()> {
1022 self.run_shell(
1023 serial,
1024 &format!("am force-stop {}", AndroidKit::PACKAGE_NAME),
1025 )?;
1026 Ok(())
1027 }
1028
1029 fn forward_androidkit(&self, serial: &str) -> Result<u16> {
1030 let output = run_command(
1031 &self.adb_path,
1032 [
1033 "-s",
1034 serial,
1035 "forward",
1036 "tcp:0",
1037 "localabstract:xcrs-androidkit",
1038 ],
1039 )?;
1040 output
1041 .trim()
1042 .parse::<u16>()
1043 .with_context(|| format!("adb returned an invalid AndroidKit forward port: {output:?}"))
1044 }
1045
1046 fn remove_forward(&self, serial: &str, local_port: u16) -> Result<()> {
1047 run_command(
1048 &self.adb_path,
1049 [
1050 "-s",
1051 serial,
1052 "forward",
1053 "--remove",
1054 &format!("tcp:{local_port}"),
1055 ],
1056 )?;
1057 Ok(())
1058 }
1059
1060 fn run_shell(&self, serial: &str, command: &str) -> Result<String> {
1061 run_command(&self.adb_path, ["-s", serial, "shell", command])
1062 }
1063
1064 fn run_for_device_bytes<I, S>(&self, serial: &str, args: I) -> Result<Vec<u8>>
1065 where
1066 I: IntoIterator<Item = S>,
1067 S: AsRef<OsStr>,
1068 {
1069 let output = Command::new(&self.adb_path)
1070 .arg("-s")
1071 .arg(serial)
1072 .args(args)
1073 .output()
1074 .with_context(|| format!("failed to run {}", self.adb_path.display()))?;
1075 command_output_to_bytes(&self.adb_path, output)
1076 }
1077 }
1078
1079 fn discover_adb_path() -> PathBuf {
1080 let executable = if cfg!(windows) { "adb.exe" } else { "adb" };
1081 let mut candidates = ["ANDROID_SDK_ROOT", "ANDROID_HOME"]
1082 .into_iter()
1083 .filter_map(env::var_os)
1084 .map(PathBuf::from)
1085 .map(|path| path.join("platform-tools").join(executable))
1086 .collect::<Vec<_>>();
1087
1088 if let Some(home) = env::var_os("HOME").map(PathBuf::from) {
1089 candidates.push(
1090 home.join("Library")
1091 .join("Android")
1092 .join("sdk")
1093 .join("platform-tools")
1094 .join(executable),
1095 );
1096 candidates.push(
1097 home.join("Android")
1098 .join("Sdk")
1099 .join("platform-tools")
1100 .join(executable),
1101 );
1102 }
1103 if let Some(local_app_data) = env::var_os("LOCALAPPDATA").map(PathBuf::from) {
1104 candidates.push(
1105 local_app_data
1106 .join("Android")
1107 .join("Sdk")
1108 .join("platform-tools")
1109 .join(executable),
1110 );
1111 }
1112
1113 candidates
1114 .into_iter()
1115 .find(|path| path.is_file())
1116 .unwrap_or_else(|| PathBuf::from(executable))
1117 }
1118
1119 fn parse_adb_devices(output: &str) -> Result<Vec<AndroidDevice>> {
1120 let mut devices = Vec::new();
1121 for line in output.lines().map(str::trim) {
1122 if line.is_empty() || line.starts_with("List of devices attached") || line.starts_with('*')
1123 {
1124 continue;
1125 }
1126
1127 let fields = line.split_whitespace().collect::<Vec<_>>();
1128 let serial = fields
1129 .first()
1130 .ok_or_else(|| anyhow!("adb device row did not contain a serial: {line}"))?;
1131 let state = fields
1132 .get(1)
1133 .ok_or_else(|| anyhow!("adb device row did not contain a state: {line}"))?;
1134 let (state, property_start) = if *state == "no" && fields.get(2) == Some(&"permissions") {
1135 ("no permissions", 3)
1136 } else {
1137 (*state, 2)
1138 };
1139 let properties = fields[property_start..]
1140 .iter()
1141 .filter_map(|field| field.split_once(':'))
1142 .collect::<BTreeMap<_, _>>();
1143
1144 devices.push(AndroidDevice {
1145 serial: (*serial).to_string(),
1146 state: state.to_string(),
1147 product: properties.get("product").map(|value| (*value).to_string()),
1148 model: properties.get("model").map(|value| (*value).to_string()),
1149 device: properties.get("device").map(|value| (*value).to_string()),
1150 transport_id: properties
1151 .get("transport_id")
1152 .map(|value| (*value).to_string()),
1153 });
1154 }
1155 Ok(devices)
1156 }
1157
1158 fn validate_android_package_name(package_name: &str) -> Result<()> {
1159 if package_name.is_empty()
1160 || !package_name
1161 .chars()
1162 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_'))
1163 {
1164 return Err(anyhow!(
1165 "Android package name must contain only ASCII letters, digits, dots, and underscores"
1166 ));
1167 }
1168 Ok(())
1169 }
1170
1171 fn encode_adb_input_text(text: &str) -> Result<String> {
1172 if text.is_empty() || text.contains(['\0', '\n', '\r']) {
1173 return Err(anyhow!("text must be a non-empty single-line value"));
1174 }
1175 if text.contains("%s") {
1176 return Err(anyhow!(
1177 "text containing the literal sequence '%s' is not supported by adb input"
1178 ));
1179 }
1180 Ok(text.replace(' ', "%s"))
1181 }
1182
1183 fn shell_escape(value: &str) -> String {
1184 format!("'{}'", value.replace('\'', "'\\''"))
1185 }
1186
1187 #[derive(Debug, Clone)]
1188 pub enum XcodeProject {
1189 Project(PathBuf),
1190 Workspace(PathBuf),
1191 }
1192
1193 #[derive(Debug, Clone)]
1194 pub struct BuildIosApp {
1195 pub project: XcodeProject,
1196 pub scheme: String,
1197 pub simulator_udid: String,
1198 pub derived_data_path: PathBuf,
1199 pub configuration: Option<String>,
1200 }
1201
1202 pub struct Xcodebuild<'a> {
1203 tools: &'a XcodeCommandLineTools,
1204 }
1205
1206 impl Xcodebuild<'_> {
1207 pub fn build_ios_app(&self, request: &BuildIosApp) -> Result<()> {
1208 let mut args = Vec::new();
1209 match &request.project {
1210 XcodeProject::Project(project_path) => {
1211 ensure_path_exists(project_path)?;
1212 args.push("-project".to_string());
1213 args.push(path_to_string(project_path)?);
1214 }
1215 XcodeProject::Workspace(workspace_path) => {
1216 ensure_path_exists(workspace_path)?;
1217 args.push("-workspace".to_string());
1218 args.push(path_to_string(workspace_path)?);
1219 }
1220 }
1221
1222 args.push("-scheme".to_string());
1223 args.push(request.scheme.clone());
1224 args.push("-destination".to_string());
1225 args.push(format!(
1226 "{IOS_SIMULATOR_DESTINATION_PREFIX}{}",
1227 request.simulator_udid
1228 ));
1229 args.push("-derivedDataPath".to_string());
1230 args.push(path_to_string(&request.derived_data_path)?);
1231
1232 if let Some(configuration) = &request.configuration {
1233 args.push("-configuration".to_string());
1234 args.push(configuration.clone());
1235 }
1236
1237 args.push("build".to_string());
1238
1239 self.tools.run_xcodebuild(args)?;
1240 Ok(())
1241 }
1242 }
1243
1244 #[derive(Debug, Clone, Serialize)]
1245 pub struct IosAppTestResult {
1246 pub simulator: Simulator,
1247 pub bundle_id: String,
1248 pub app_container_path: PathBuf,
1249 }
1250
1251 #[derive(Debug, Clone)]
1252 pub struct IosAppTest {
1253 pub simulator_name: Option<String>,
1254 pub simulator_udid: Option<String>,
1255 pub app_path: PathBuf,
1256 pub bundle_id: String,
1257 pub terminate_before_launch: bool,
1258 }
1259
1260 impl XcodeCommandLineTools {
1261 pub fn run_ios_app_test(&self, request: &IosAppTest) -> Result<IosAppTestResult> {
1262 if request.simulator_name.is_none() && request.simulator_udid.is_none() {
1263 return Err(anyhow!(
1264 "either simulator_name or simulator_udid must be provided"
1265 ));
1266 }
1267
1268 let simctl = self.simctl();
1269 let simulator = match (&request.simulator_udid, &request.simulator_name) {
1270 (Some(udid), _) => simctl.find_simulator_by_udid(udid)?,
1271 (None, Some(name)) => simctl.find_simulator_by_name(name)?,
1272 (None, None) => unreachable!("validated above"),
1273 };
1274
1275 simctl.boot(&simulator.udid)?;
1276 simctl.install_app(&simulator.udid, &request.app_path)?;
1277
1278 if request.terminate_before_launch {
1279 simctl.terminate_app(&simulator.udid, &request.bundle_id)?;
1280 }
1281
1282 simctl.launch_app(&simulator.udid, &request.bundle_id)?;
1283 let app_container_path = simctl.app_container_path(&simulator.udid, &request.bundle_id)?;
1284
1285 Ok(IosAppTestResult {
1286 simulator: simctl.find_simulator_by_udid(&simulator.udid)?,
1287 bundle_id: request.bundle_id.clone(),
1288 app_container_path,
1289 })
1290 }
1291
1292 pub fn device_tunnel_address(&self, device_udid: &str) -> Result<String> {
1293 let output = self.run_xcrun([
1294 "devicectl",
1295 "device",
1296 "info",
1297 "details",
1298 "--device",
1299 device_udid,
1300 ])?;
1301
1302 parse_device_tunnel_address(&output)
1303 .ok_or_else(|| anyhow!("device {device_udid} did not report a tunnel IP address"))
1304 }
1305
1306 pub fn build_controlkit_device_runner(
1307 &self,
1308 request: &BuildControlKitDeviceRunner,
1309 ) -> Result<PathBuf> {
1310 ensure_path_exists(&request.project_path)?;
1311 let args = [
1312 "build-for-testing".to_string(),
1313 "-project".to_string(),
1314 path_to_string(&request.project_path)?,
1315 "-scheme".to_string(),
1316 request.scheme.clone(),
1317 "-configuration".to_string(),
1318 request.configuration.clone(),
1319 "-destination".to_string(),
1320 format!("id={}", request.device_udid),
1321 "-derivedDataPath".to_string(),
1322 path_to_string(&request.derived_data_path)?,
1323 ];
1324 self.run_xcodebuild(args)?;
1325
1326 let products_path = request.derived_data_path.join("Build/Products");
1327 let entries = std::fs::read_dir(&products_path)
1328 .with_context(|| format!("failed to read {}", products_path.display()))?;
1329 let mut test_run_paths = entries
1330 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
1331 .filter(|path| {
1332 path.extension()
1333 .is_some_and(|extension| extension == "xctestrun")
1334 })
1335 .collect::<Vec<_>>();
1336 test_run_paths.sort();
1337
1338 test_run_paths.into_iter().next().ok_or_else(|| {
1339 anyhow!(
1340 "Xcode did not create an .xctestrun file in {}",
1341 products_path.display()
1342 )
1343 })
1344 }
1345
1346 pub fn start_controlkit_device_runner(
1347 &self,
1348 request: &StartControlKitDeviceRunner,
1349 ) -> Result<RunningControlKitDeviceRunner> {
1350 ensure_path_exists(&request.xctestrun_path)?;
1351 set_xctestrun_environment(
1352 &request.xctestrun_path,
1353 "CONTROLKIT_LISTEN_HOST",
1354 &request.listen_host,
1355 )?;
1356 set_xctestrun_environment(
1357 &request.xctestrun_path,
1358 "CONTROLKIT_LISTEN_PORT",
1359 &request.listen_port.to_string(),
1360 )?;
1361
1362 let tunnel_host = self.device_tunnel_address(&request.device_udid)?;
1363 let log_file = std::fs::OpenOptions::new()
1364 .create(true)
1365 .truncate(true)
1366 .write(true)
1367 .open(&request.log_path)
1368 .with_context(|| format!("failed to open {}", request.log_path.display()))?;
1369 let error_log_file = log_file
1370 .try_clone()
1371 .with_context(|| format!("failed to clone {}", request.log_path.display()))?;
1372
1373 let child = Command::new(&self.xcodebuild_path)
1374 .arg("test-without-building")
1375 .arg("-xctestrun")
1376 .arg(&request.xctestrun_path)
1377 .arg("-destination")
1378 .arg(format!("id={}", request.device_udid))
1379 .stdin(Stdio::null())
1380 .stdout(Stdio::from(log_file))
1381 .stderr(Stdio::from(error_log_file))
1382 .spawn()
1383 .with_context(|| {
1384 format!(
1385 "failed to start ControlKit runner for device {}",
1386 request.device_udid
1387 )
1388 })?;
1389
1390 Ok(RunningControlKitDeviceRunner {
1391 process: child,
1392 tunnel_host,
1393 listen_port: request.listen_port,
1394 log_path: request.log_path.clone(),
1395 })
1396 }
1397 }
1398
1399 #[derive(Debug, Clone)]
1400 pub struct BuildControlKitDeviceRunner {
1401 pub project_path: PathBuf,
1402 pub scheme: String,
1403 pub configuration: String,
1404 pub device_udid: String,
1405 pub derived_data_path: PathBuf,
1406 }
1407
1408 #[derive(Debug, Clone)]
1409 pub struct StartControlKitDeviceRunner {
1410 pub device_udid: String,
1411 pub xctestrun_path: PathBuf,
1412 pub listen_host: String,
1413 pub listen_port: u16,
1414 pub log_path: PathBuf,
1415 }
1416
1417 #[derive(Debug)]
1418 pub struct RunningControlKitDeviceRunner {
1419 pub process: Child,
1420 pub tunnel_host: String,
1421 pub listen_port: u16,
1422 pub log_path: PathBuf,
1423 }
1424
1425 fn set_xctestrun_environment(path: &Path, name: &str, value: &str) -> Result<()> {
1426 let key = format!(":TestConfigurations:0:TestTargets:0:EnvironmentVariables:{name}");
1427 let set_status = Command::new("/usr/libexec/PlistBuddy")
1428 .arg("-c")
1429 .arg(format!("Set {key} {value}"))
1430 .arg(path)
1431 .status()
1432 .with_context(|| format!("failed to update {}", path.display()))?;
1433
1434 if set_status.success() {
1435 return Ok(());
1436 }
1437
1438 let add_status = Command::new("/usr/libexec/PlistBuddy")
1439 .arg("-c")
1440 .arg(format!("Add {key} string {value}"))
1441 .arg(path)
1442 .status()
1443 .with_context(|| format!("failed to update {}", path.display()))?;
1444
1445 if add_status.success() {
1446 return Ok(());
1447 }
1448
1449 Err(anyhow!(
1450 "failed to set {name} in XCTest plan {}",
1451 path.display()
1452 ))
1453 }
1454
1455 fn parse_device_tunnel_address(output: &str) -> Option<String> {
1456 output
1457 .lines()
1458 .find_map(|line| line.split_once("Tunnel IP Address:"))
1459 .map(|(_, address)| address.trim().to_string())
1460 .filter(|address| !address.is_empty())
1461 }
1462
1463 pub fn parse_simctl_devices(output: &str) -> Result<Vec<Simulator>> {
1464 let list: SimctlDeviceList =
1465 serde_json::from_str(output).context("failed to parse simctl device list JSON")?;
1466
1467 let mut simulators = Vec::new();
1468 for (runtime_identifier, devices) in list.devices {
1469 for device in devices {
1470 simulators.push(Simulator {
1471 platform: ApplePlatform::from_runtime_identifier(&runtime_identifier),
1472 runtime_identifier: runtime_identifier.clone(),
1473 name: device.name,
1474 udid: device.udid,
1475 state: device.state,
1476 is_available: device.is_available.unwrap_or(true),
1477 });
1478 }
1479 }
1480
1481 Ok(simulators)
1482 }
1483
1484 fn ensure_path_exists(path: &Path) -> Result<()> {
1485 if !path.exists() {
1486 return Err(anyhow!("path does not exist: {}", path.display()));
1487 }
1488
1489 Ok(())
1490 }
1491
1492 fn path_to_string(path: &Path) -> Result<String> {
1493 path.to_str()
1494 .map(ToOwned::to_owned)
1495 .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))
1496 }
1497
1498 fn run_command<I, S>(program: &Path, args: I) -> Result<String>
1499 where
1500 I: IntoIterator<Item = S>,
1501 S: AsRef<OsStr>,
1502 {
1503 let output = Command::new(program)
1504 .args(args)
1505 .output()
1506 .with_context(|| format!("failed to run {}", program.display()))?;
1507
1508 command_output_to_result(program, output)
1509 }
1510
1511 fn command_output_to_result(program: &Path, output: Output) -> Result<String> {
1512 if output.status.success() {
1513 return String::from_utf8(output.stdout)
1514 .with_context(|| format!("{} wrote invalid UTF-8 to stdout", program.display()));
1515 }
1516
1517 let stderr = String::from_utf8_lossy(&output.stderr);
1518 let stdout = String::from_utf8_lossy(&output.stdout);
1519 let message = [stderr.trim(), stdout.trim()]
1520 .into_iter()
1521 .filter(|part| !part.is_empty())
1522 .collect::<Vec<_>>()
1523 .join("\n");
1524
1525 if message.is_empty() {
1526 return Err(anyhow!(
1527 "{} exited with status {}",
1528 program.display(),
1529 output.status
1530 ));
1531 }
1532
1533 Err(anyhow!(
1534 "{} exited with status {}: {}",
1535 program.display(),
1536 output.status,
1537 message
1538 ))
1539 }
1540
1541 fn command_output_to_bytes(program: &Path, output: Output) -> Result<Vec<u8>> {
1542 if output.status.success() {
1543 return Ok(output.stdout);
1544 }
1545
1546 let stderr = String::from_utf8_lossy(&output.stderr);
1547 let stdout = String::from_utf8_lossy(&output.stdout);
1548 let message = [stderr.trim(), stdout.trim()]
1549 .into_iter()
1550 .filter(|part| !part.is_empty())
1551 .collect::<Vec<_>>()
1552 .join("\n");
1553
1554 if message.is_empty() {
1555 return Err(anyhow!(
1556 "{} exited with status {}",
1557 program.display(),
1558 output.status
1559 ));
1560 }
1561
1562 Err(anyhow!(
1563 "{} exited with status {}: {}",
1564 program.display(),
1565 output.status,
1566 message
1567 ))
1568 }
1569
1570 fn parse_resolved_android_activity(output: &str) -> Option<String> {
1571 output
1572 .lines()
1573 .map(str::trim)
1574 .rev()
1575 .find(|line| !line.is_empty() && line.contains('/') && !line.contains(char::is_whitespace))
1576 .map(str::to_string)
1577 }
1578
1579 #[cfg(test)]
1580 mod tests {
1581 use super::*;
1582
1583 #[test]
1584 fn parses_simctl_devices() {
1585 let output = r#"{
1586 "devices": {
1587 "com.apple.CoreSimulator.SimRuntime.iOS-26-0": [
1588 {
1589 "lastBootedAt": "2026-07-30T08:00:00Z",
1590 "dataPath": "/tmp/device",
1591 "dataPathSize": 1,
1592 "logPath": "/tmp/logs",
1593 "udid": "00000000-0000-0000-0000-000000000000",
1594 "isAvailable": true,
1595 "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-16",
1596 "state": "Booted",
1597 "name": "Test-iOS-26"
1598 }
1599 ]
1600 }
1601 }"#;
1602
1603 let devices = parse_simctl_devices(output).expect("devices should parse");
1604
1605 assert_eq!(devices.len(), 1);
1606 assert_eq!(devices[0].platform, ApplePlatform::Ios);
1607 assert_eq!(devices[0].name, "Test-iOS-26");
1608 assert_eq!(devices[0].state, "Booted");
1609 assert!(devices[0].is_booted());
1610 }
1611
1612 #[test]
1613 fn parses_xros_simulator_runtime() {
1614 let output = r#"{
1615 "devices": {
1616 "com.apple.CoreSimulator.SimRuntime.xrOS-26-5": [
1617 {
1618 "udid": "00000000-0000-0000-0000-000000000001",
1619 "isAvailable": true,
1620 "state": "Shutdown",
1621 "name": "Apple Vision Pro"
1622 }
1623 ]
1624 }
1625 }"#;
1626
1627 let devices = parse_simctl_devices(output).expect("devices should parse");
1628
1629 assert_eq!(devices.len(), 1);
1630 assert_eq!(devices[0].platform, ApplePlatform::Visionos);
1631 assert_eq!(devices[0].name, "Apple Vision Pro");
1632 }
1633
1634 #[test]
1635 fn parses_macos_runtime() {
1636 let output = r#"{
1637 "devices": {
1638 "com.apple.CoreSimulator.SimRuntime.macOS-26-0": [
1639 {
1640 "udid": "00000000-0000-0000-0000-000000000002",
1641 "isAvailable": true,
1642 "state": "Booted",
1643 "name": "Mac"
1644 }
1645 ]
1646 }
1647 }"#;
1648
1649 let devices = parse_simctl_devices(output).expect("devices should parse");
1650
1651 assert_eq!(devices.len(), 1);
1652 assert_eq!(devices[0].platform, ApplePlatform::Macos);
1653 }
1654
1655 #[test]
1656 fn formats_ipv6_controlkit_url() {
1657 let controlkit = ControlKit::with_host("fdb4:e020:7377::1", 12006);
1658 assert_eq!(controlkit.base_url, "http://[fdb4:e020:7377::1]:12006");
1659 }
1660
1661 #[test]
1662 fn reports_actionable_controlkit_method_mismatch() {
1663 let error = serde_json::json!({
1664 "code": -32601,
1665 "message": "Method not found"
1666 });
1667 let runner_info = serde_json::json!({
1668 "runner": "XCRSControlKit",
1669 "protocolVersion": 1
1670 });
1671
1672 let message = controlkit_rpc_error("device.dump.ui", &error, Some(&runner_info), None);
1673
1674 assert!(message.contains("XCRSControlKit protocol 1"));
1675 assert!(message.contains("device.dump.ui"));
1676 assert!(message.contains("Rebuild or upgrade"));
1677 }
1678
1679 #[test]
1680 fn recognizes_integral_float_controlkit_method_not_found_code() {
1681 let error = serde_json::json!({
1682 "code": -32601.0,
1683 "message": "Method not found"
1684 });
1685
1686 assert!(is_controlkit_method_not_found(&error));
1687 let message = controlkit_rpc_error("device.dump.ui", &error, None, None);
1688 assert!(message.contains("does not implement ControlKit method 'device.dump.ui'"));
1689 }
1690
1691 #[test]
1692 fn reports_when_controlkit_runner_metadata_is_unavailable() {
1693 let error = serde_json::json!({
1694 "code": -32601,
1695 "message": "Method not found"
1696 });
1697
1698 let message = controlkit_rpc_error(
1699 "device.dump.ui",
1700 &error,
1701 None,
1702 Some("device.info returned a JSON-RPC error"),
1703 );
1704
1705 assert!(message.contains("The connected ControlKit runner"));
1706 assert!(message.contains("Runner metadata could not be read"));
1707 assert!(message.contains("device.info returned a JSON-RPC error"));
1708 assert!(!message.contains("unknown ControlKit runner"));
1709 }
1710
1711 #[test]
1712 fn extracts_identified_visible_controlkit_elements() {
1713 let hierarchy = serde_json::json!({
1714 "type": "Application",
1715 "rect": { "x": 0, "y": 0, "width": 1920, "height": 1080 },
1716 "children": [
1717 {
1718 "type": "Button",
1719 "label": "Play",
1720 "rawIdentifier": "play-button",
1721 "rect": { "x": 100, "y": 200, "width": 80, "height": 40 },
1722 "enabled": true,
1723 "selected": false,
1724 "hittable": true,
1725 "children": []
1726 },
1727 {
1728 "type": "Image",
1729 "label": "",
1730 "rect": { "x": 0, "y": 0, "width": 40, "height": 40 },
1731 "children": []
1732 }
1733 ]
1734 });
1735
1736 let elements = extract_controlkit_elements(&hierarchy);
1737
1738 assert_eq!(elements.len(), 1);
1739 assert_eq!(elements[0]["label"], serde_json::json!("Play"));
1740 assert_eq!(elements[0]["hittable"], serde_json::json!(true));
1741 }
1742
1743 #[test]
1744 fn normalizes_visible_androidkit_elements() {
1745 let hierarchy = serde_json::json!({
1746 "hierarchy": [{
1747 "role": "android.widget.Button",
1748 "text": "Continue",
1749 "hint": "",
1750 "contentDescription": "",
1751 "resourceId": "com.example:id/continue",
1752 "enabled": true,
1753 "clickable": true,
1754 "selected": false,
1755 "visible": true,
1756 "rect": { "x": 10, "y": 20, "width": 100, "height": 40 }
1757 }]
1758 });
1759
1760 let elements = extract_androidkit_elements(&hierarchy);
1761
1762 assert_eq!(elements.len(), 1);
1763 assert_eq!(elements[0]["label"], serde_json::json!("Continue"));
1764 assert_eq!(
1765 elements[0]["rawIdentifier"],
1766 serde_json::json!("com.example:id/continue")
1767 );
1768 }
1769
1770 #[test]
1771 fn parses_device_tunnel_address() {
1772 let output = "• Device Name: iPhone\n• Tunnel IP Address: fd55:33ce:ad87::1\n";
1773 assert_eq!(
1774 parse_device_tunnel_address(output).as_deref(),
1775 Some("fd55:33ce:ad87::1")
1776 );
1777 }
1778
1779 #[test]
1780 fn parses_adb_devices() {
1781 let output = r#"List of devices attached
1782 emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1
1783 SERIAL123 unauthorized usb:0-1 transport_id:2
1784 SERIAL456 no permissions (user denied) usb:0-2 transport_id:3
1785 "#;
1786
1787 let devices = parse_adb_devices(output).expect("devices should parse");
1788
1789 assert_eq!(devices.len(), 3);
1790 assert_eq!(devices[0].serial, "emulator-5554");
1791 assert_eq!(devices[0].state, "device");
1792 assert_eq!(devices[0].model.as_deref(), Some("sdk_gphone64_arm64"));
1793 assert_eq!(devices[1].serial, "SERIAL123");
1794 assert_eq!(devices[1].state, "unauthorized");
1795 assert_eq!(devices[2].serial, "SERIAL456");
1796 assert_eq!(devices[2].state, "no permissions");
1797 }
1798
1799 #[test]
1800 fn encodes_adb_input_spaces() {
1801 let text = encode_adb_input_text("hello android").expect("text should encode");
1802 assert_eq!(text, "hello%sandroid");
1803 }
1804
1805 #[test]
1806 fn rejects_literal_adb_space_escape() {
1807 let error = encode_adb_input_text("literal%svalue").expect_err("text should be rejected");
1808 assert!(error.to_string().contains("literal sequence"));
1809 }
1810
1811 #[test]
1812 fn parses_resolved_android_activity() {
1813 let output = "priority=0 preferredOrder=0 match=0x108000\n\
1814 ai.splitfire.KaroKowe/.MainActivity\n";
1815
1816 assert_eq!(
1817 parse_resolved_android_activity(output).as_deref(),
1818 Some("ai.splitfire.KaroKowe/.MainActivity")
1819 );
1820 }
1821
1822 #[test]
1823 fn escapes_android_shell_arguments() {
1824 assert_eq!(shell_escape("don't"), "'don'\\''t'");
1825 }
1826
1827 #[test]
1828 fn normalizes_apple_simulator_as_simulator_kind() {
1829 let simulator = Simulator {
1830 platform: ApplePlatform::Ios,
1831 runtime_identifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-0".to_string(),
1832 name: "Test-iOS-26".to_string(),
1833 udid: "00000000-0000-0000-0000-000000000000".to_string(),
1834 state: "Booted".to_string(),
1835 is_available: true,
1836 };
1837
1838 let normalized = NormalizedDevice::from_simulator(&simulator);
1839
1840 assert_eq!(normalized.platform, DevicePlatform::Apple);
1841 assert_eq!(normalized.kind, DeviceKind::Simulator);
1842 assert_eq!(normalized.identifier, simulator.udid);
1843 assert_eq!(normalized.name, "Test-iOS-26");
1844 assert_eq!(normalized.state, "Booted");
1845 assert_eq!(normalized.transport, "simctl");
1846 }
1847
1848 #[test]
1849 fn normalizes_android_emulator_serial_as_emulator_kind() {
1850 let device = AndroidDevice {
1851 serial: "emulator-5554".to_string(),
1852 state: "device".to_string(),
1853 product: Some("sdk_gphone64_arm64".to_string()),
1854 model: Some("sdk_gphone64_arm64".to_string()),
1855 device: Some("emu64a".to_string()),
1856 transport_id: Some("1".to_string()),
1857 };
1858
1859 let normalized = NormalizedDevice::from_android_device(&device);
1860
1861 assert_eq!(normalized.platform, DevicePlatform::Android);
1862 assert_eq!(normalized.kind, DeviceKind::Emulator);
1863 assert_eq!(normalized.identifier, "emulator-5554");
1864 assert_eq!(normalized.name, "sdk_gphone64_arm64");
1865 assert_eq!(normalized.transport, "adb");
1866 }
1867
1868 #[test]
1869 fn normalizes_android_physical_serial_as_physical_device_kind() {
1870 let device = AndroidDevice {
1871 serial: "R58N123ABCD".to_string(),
1872 state: "device".to_string(),
1873 product: None,
1874 model: None,
1875 device: None,
1876 transport_id: None,
1877 };
1878
1879 let normalized = NormalizedDevice::from_android_device(&device);
1880
1881 assert_eq!(normalized.kind, DeviceKind::PhysicalDevice);
1882 // Falls back to the serial when no product/model/device property is reported.
1883 assert_eq!(normalized.name, "R58N123ABCD");
1884 }
1885 }