@smbcloud / cli / commits / 2d01556

Add device controllable tools

Seto Elkahfi committed Jul 30, 2026 at 13:42 UTC 2d015565554086a497a3e4273097cb6f7b35970d
5 files changed +656 -3
Cargo.lock
+1
@@ -4001,6 +4001,7 @@ dependencies = [
4001 "anyhow",
4002 "base64",
4003 "clap 4.6.2",
4004 + "reqwest",
4005 "rmcp",
4006 "schemars",
4007 "serde",
crates/cli/src/mcp_xcrs.rs
+10 -1
@@ -8,5 +8,14 @@ xcrs::xcrs_mcp_tools!(
8 "smb_simulator_screenshot",
9 "smb_simulator_launch_app",
10 "smb_simulator_terminate_app",
11 - "smb_simulator_open_url"
11 + "smb_simulator_open_url",
12 + "smb_simulator_ui_dump",
13 + "smb_simulator_list_elements",
14 + "smb_simulator_tap",
15 + "smb_simulator_type_text",
16 + "smb_simulator_swipe",
17 + "smb_simulator_press_home",
18 + "smb_simulator_press_button",
19 + "smb_simulator_orientation_get",
20 + "smb_simulator_orientation_set"
21 );
crates/xcrs/Cargo.toml
+1
@@ -25,5 +25,6 @@ rmcp = { workspace = true }
25 schemars = { workspace = true }
26 serde = { workspace = true, features = ["derive"] }
27 serde_json = { workspace = true }
28 +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
29 tempfile = { workspace = true }
30 tokio = { workspace = true, features = ["full"] }
crates/xcrs/src/mcp.rs
+495 -2
@@ -41,6 +41,9 @@ pub struct SimulatorTargetArgs {
41 /// Simulator UDID.
42 #[serde(default)]
43 pub simulator_udid: Option<String>,
44 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
45 + #[serde(default)]
46 + pub devicekit_port: Option<u16>,
47 }
48
49 #[derive(Debug, Deserialize, JsonSchema)]
@@ -51,6 +54,9 @@ pub struct SimulatorAppArgs {
54 /// Simulator UDID.
55 #[serde(default)]
56 pub simulator_udid: Option<String>,
57 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
58 + #[serde(default)]
59 + pub devicekit_port: Option<u16>,
60 /// App bundle identifier.
61 pub bundle_id: String,
62 }
@@ -63,10 +69,109 @@ pub struct SimulatorOpenUrlArgs {
69 /// Simulator UDID.
70 #[serde(default)]
71 pub simulator_udid: Option<String>,
72 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
73 + #[serde(default)]
74 + pub devicekit_port: Option<u16>,
75 /// HTTP(S) URL or custom URL scheme.
76 pub url: String,
77 }
78
79 +#[derive(Debug, Deserialize, JsonSchema)]
80 +pub struct SimulatorTapArgs {
81 + /// Exact simulator name.
82 + #[serde(default)]
83 + pub simulator_name: Option<String>,
84 + /// Simulator UDID.
85 + #[serde(default)]
86 + pub simulator_udid: Option<String>,
87 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
88 + #[serde(default)]
89 + pub devicekit_port: Option<u16>,
90 + /// Horizontal screen coordinate.
91 + pub x: f32,
92 + /// Vertical screen coordinate.
93 + pub y: f32,
94 +}
95 +
96 +#[derive(Debug, Deserialize, JsonSchema)]
97 +pub struct SimulatorTextArgs {
98 + /// Exact simulator name.
99 + #[serde(default)]
100 + pub simulator_name: Option<String>,
101 + /// Simulator UDID.
102 + #[serde(default)]
103 + pub simulator_udid: Option<String>,
104 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
105 + #[serde(default)]
106 + pub devicekit_port: Option<u16>,
107 + /// Text to type into the focused field.
108 + pub text: String,
109 +}
110 +
111 +#[derive(Debug, Deserialize, JsonSchema)]
112 +pub struct SimulatorSwipeArgs {
113 + /// Exact simulator name.
114 + #[serde(default)]
115 + pub simulator_name: Option<String>,
116 + /// Simulator UDID.
117 + #[serde(default)]
118 + pub simulator_udid: Option<String>,
119 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
120 + #[serde(default)]
121 + pub devicekit_port: Option<u16>,
122 + /// Swipe start horizontal coordinate.
123 + pub x1: i32,
124 + /// Swipe start vertical coordinate.
125 + pub y1: i32,
126 + /// Swipe end horizontal coordinate.
127 + pub x2: i32,
128 + /// Swipe end vertical coordinate.
129 + pub y2: i32,
130 +}
131 +
132 +#[derive(Debug, Deserialize, JsonSchema)]
133 +pub struct SimulatorOrientationArgs {
134 + /// Exact simulator name.
135 + #[serde(default)]
136 + pub simulator_name: Option<String>,
137 + /// Simulator UDID.
138 + #[serde(default)]
139 + pub simulator_udid: Option<String>,
140 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
141 + #[serde(default)]
142 + pub devicekit_port: Option<u16>,
143 + /// Desired orientation: PORTRAIT or LANDSCAPE.
144 + pub orientation: String,
145 +}
146 +
147 +#[derive(Debug, Deserialize, JsonSchema)]
148 +pub struct SimulatorDeviceKitArgs {
149 + /// Exact simulator name.
150 + #[serde(default)]
151 + pub simulator_name: Option<String>,
152 + /// Simulator UDID.
153 + #[serde(default)]
154 + pub simulator_udid: Option<String>,
155 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
156 + #[serde(default)]
157 + pub devicekit_port: Option<u16>,
158 +}
159 +
160 +#[derive(Debug, Deserialize, JsonSchema)]
161 +pub struct SimulatorButtonArgs {
162 + /// Exact simulator name.
163 + #[serde(default)]
164 + pub simulator_name: Option<String>,
165 + /// Simulator UDID.
166 + #[serde(default)]
167 + pub simulator_udid: Option<String>,
168 + /// Local DeviceKit JSON-RPC port. Defaults to 12004.
169 + #[serde(default)]
170 + pub devicekit_port: Option<u16>,
171 + /// tvOS button: up, down, left, right, select, menu, home, or playPause.
172 + pub button: String,
173 +}
174 +
175 #[derive(Debug, Default)]
176 pub struct XcrsMcpServer;
177
@@ -86,7 +191,16 @@ macro_rules! xcrs_mcp_tools {
191 $simulator_screenshot_name:literal,
192 $simulator_launch_app_name:literal,
193 $simulator_terminate_app_name:literal,
89 - $simulator_open_url_name:literal
194 + $simulator_open_url_name:literal,
195 + $simulator_ui_dump_name:literal,
196 + $simulator_list_elements_name:literal,
197 + $simulator_tap_name:literal,
198 + $simulator_text_name:literal,
199 + $simulator_swipe_name:literal,
200 + $simulator_home_name:literal,
201 + $simulator_button_name:literal,
202 + $simulator_orientation_get_name:literal,
203 + $simulator_orientation_set_name:literal
204 ) => {
205 #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")]
206 impl $server {
@@ -114,6 +228,26 @@ macro_rules! xcrs_mcp_tools {
228 }
229 }
230
231 + fn devicekit_from_target(
232 + simulator_name: &Option<String>,
233 + simulator_udid: &Option<String>,
234 + devicekit_port: Option<u16>,
235 + ) -> ::std::result::Result<
236 + ($crate::Simulator, $crate::DeviceKit),
237 + ::rmcp::model::ErrorData,
238 + > {
239 + let target = $crate::mcp::SimulatorTargetArgs {
240 + simulator_name: simulator_name.clone(),
241 + simulator_udid: simulator_udid.clone(),
242 + devicekit_port,
243 + };
244 + let simulator = Self::simulator_from_target(&target)?;
245 + Ok((
246 + simulator,
247 + $crate::DeviceKit::new(devicekit_port.unwrap_or(12004)),
248 + ))
249 + }
250 +
251 #[::rmcp::tool(
252 name = $simulator_list_name,
253 description = "List all iOS simulators known to Xcode, including their runtime, UDID, availability, and current state."
@@ -245,6 +379,7 @@ macro_rules! xcrs_mcp_tools {
379 let target = $crate::mcp::SimulatorTargetArgs {
380 simulator_name: args.simulator_name,
381 simulator_udid: args.simulator_udid,
382 + devicekit_port: args.devicekit_port,
383 };
384 let simulator = Self::simulator_from_target(&target)?;
385 $crate::XcodeCommandLineTools::new()
@@ -279,6 +414,7 @@ macro_rules! xcrs_mcp_tools {
414 let target = $crate::mcp::SimulatorTargetArgs {
415 simulator_name: args.simulator_name,
416 simulator_udid: args.simulator_udid,
417 + devicekit_port: args.devicekit_port,
418 };
419 let simulator = Self::simulator_from_target(&target)?;
420 $crate::XcodeCommandLineTools::new()
@@ -313,6 +449,7 @@ macro_rules! xcrs_mcp_tools {
449 let target = $crate::mcp::SimulatorTargetArgs {
450 simulator_name: args.simulator_name,
451 simulator_udid: args.simulator_udid,
452 + devicekit_port: args.devicekit_port,
453 };
454 let simulator = Self::simulator_from_target(&target)?;
455 $crate::XcodeCommandLineTools::new()
@@ -328,6 +465,353 @@ macro_rules! xcrs_mcp_tools {
465 )),
466 ]))
467 }
468 +
469 + #[::rmcp::tool(
470 + name = $simulator_ui_dump_name,
471 + description = "Return the accessibility UI hierarchy from the foreground iOS app through DeviceKit."
472 + )]
473 + async fn simulator_ui_dump(
474 + &self,
475 + ::rmcp::handler::server::wrapper::Parameters(
476 + args,
477 + ): ::rmcp::handler::server::wrapper::Parameters<
478 + $crate::mcp::SimulatorDeviceKitArgs,
479 + >,
480 + ) -> ::std::result::Result<
481 + ::rmcp::model::CallToolResult,
482 + ::rmcp::model::ErrorData,
483 + > {
484 + let (simulator, devicekit) = Self::devicekit_from_target(
485 + &args.simulator_name,
486 + &args.simulator_udid,
487 + args.devicekit_port,
488 + )?;
489 + let result = devicekit
490 + .call("device.dump.ui", ::serde_json::json!({ "format": "json" }))
491 + .await
492 + .map_err(|error| {
493 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
494 + })?;
495 + Ok(::rmcp::model::CallToolResult::success(vec![
496 + ::rmcp::model::ContentBlock::json(&::serde_json::json!({
497 + "simulator": simulator,
498 + "ui": result,
499 + }))?,
500 + ]))
501 + }
502 +
503 + #[::rmcp::tool(
504 + name = $simulator_list_elements_name,
505 + description = "List actionable accessibility elements and coordinates from the foreground iOS app through DeviceKit."
506 + )]
507 + async fn simulator_list_elements(
508 + &self,
509 + ::rmcp::handler::server::wrapper::Parameters(
510 + args,
511 + ): ::rmcp::handler::server::wrapper::Parameters<
512 + $crate::mcp::SimulatorDeviceKitArgs,
513 + >,
514 + ) -> ::std::result::Result<
515 + ::rmcp::model::CallToolResult,
516 + ::rmcp::model::ErrorData,
517 + > {
518 + let (simulator, devicekit) = Self::devicekit_from_target(
519 + &args.simulator_name,
520 + &args.simulator_udid,
521 + args.devicekit_port,
522 + )?;
523 + let ui = devicekit
524 + .call("device.dump.ui", ::serde_json::json!({ "format": "json" }))
525 + .await
526 + .map_err(|error| {
527 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
528 + })?;
529 + let elements = $crate::extract_devicekit_elements(&ui);
530 + Ok(::rmcp::model::CallToolResult::success(vec![
531 + ::rmcp::model::ContentBlock::json(&::serde_json::json!({
532 + "simulator": simulator,
533 + "elements": elements,
534 + }))?,
535 + ]))
536 + }
537 +
538 + #[::rmcp::tool(
539 + name = $simulator_tap_name,
540 + description = "Tap the iOS simulator screen at the given coordinates through DeviceKit."
541 + )]
542 + async fn simulator_tap(
543 + &self,
544 + ::rmcp::handler::server::wrapper::Parameters(
545 + args,
546 + ): ::rmcp::handler::server::wrapper::Parameters<
547 + $crate::mcp::SimulatorTapArgs,
548 + >,
549 + ) -> ::std::result::Result<
550 + ::rmcp::model::CallToolResult,
551 + ::rmcp::model::ErrorData,
552 + > {
553 + let (simulator, devicekit) = Self::devicekit_from_target(
554 + &args.simulator_name,
555 + &args.simulator_udid,
556 + args.devicekit_port,
557 + )?;
558 + devicekit
559 + .call(
560 + "device.io.tap",
561 + ::serde_json::json!({ "x": args.x, "y": args.y }),
562 + )
563 + .await
564 + .map_err(|error| {
565 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
566 + })?;
567 + Ok(::rmcp::model::CallToolResult::success(vec![
568 + ::rmcp::model::ContentBlock::text(format!(
569 + "Tapped ({}, {}) on {}.",
570 + args.x, args.y, simulator.name
571 + )),
572 + ]))
573 + }
574 +
575 + #[::rmcp::tool(
576 + name = $simulator_text_name,
577 + description = "Type text into the focused iOS simulator field through DeviceKit."
578 + )]
579 + async fn simulator_text(
580 + &self,
581 + ::rmcp::handler::server::wrapper::Parameters(
582 + args,
583 + ): ::rmcp::handler::server::wrapper::Parameters<
584 + $crate::mcp::SimulatorTextArgs,
585 + >,
586 + ) -> ::std::result::Result<
587 + ::rmcp::model::CallToolResult,
588 + ::rmcp::model::ErrorData,
589 + > {
590 + let (simulator, devicekit) = Self::devicekit_from_target(
591 + &args.simulator_name,
592 + &args.simulator_udid,
593 + args.devicekit_port,
594 + )?;
595 + devicekit
596 + .call("device.io.text", ::serde_json::json!({ "text": args.text }))
597 + .await
598 + .map_err(|error| {
599 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
600 + })?;
601 + Ok(::rmcp::model::CallToolResult::success(vec![
602 + ::rmcp::model::ContentBlock::text(format!(
603 + "Typed text on {}.",
604 + simulator.name
605 + )),
606 + ]))
607 + }
608 +
609 + #[::rmcp::tool(
610 + name = $simulator_swipe_name,
611 + description = "Swipe between two screen coordinates on an iOS simulator through DeviceKit."
612 + )]
613 + async fn simulator_swipe(
614 + &self,
615 + ::rmcp::handler::server::wrapper::Parameters(
616 + args,
617 + ): ::rmcp::handler::server::wrapper::Parameters<
618 + $crate::mcp::SimulatorSwipeArgs,
619 + >,
620 + ) -> ::std::result::Result<
621 + ::rmcp::model::CallToolResult,
622 + ::rmcp::model::ErrorData,
623 + > {
624 + let (simulator, devicekit) = Self::devicekit_from_target(
625 + &args.simulator_name,
626 + &args.simulator_udid,
627 + args.devicekit_port,
628 + )?;
629 + devicekit
630 + .call(
631 + "device.io.swipe",
632 + ::serde_json::json!({
633 + "x1": args.x1,
634 + "y1": args.y1,
635 + "x2": args.x2,
636 + "y2": args.y2,
637 + }),
638 + )
639 + .await
640 + .map_err(|error| {
641 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
642 + })?;
643 + Ok(::rmcp::model::CallToolResult::success(vec![
644 + ::rmcp::model::ContentBlock::text(format!(
645 + "Swiped on {}.",
646 + simulator.name
647 + )),
648 + ]))
649 + }
650 +
651 + #[::rmcp::tool(
652 + name = $simulator_home_name,
653 + description = "Press the iOS simulator Home button through DeviceKit."
654 + )]
655 + async fn simulator_home(
656 + &self,
657 + ::rmcp::handler::server::wrapper::Parameters(
658 + args,
659 + ): ::rmcp::handler::server::wrapper::Parameters<
660 + $crate::mcp::SimulatorDeviceKitArgs,
661 + >,
662 + ) -> ::std::result::Result<
663 + ::rmcp::model::CallToolResult,
664 + ::rmcp::model::ErrorData,
665 + > {
666 + let (simulator, devicekit) = Self::devicekit_from_target(
667 + &args.simulator_name,
668 + &args.simulator_udid,
669 + args.devicekit_port,
670 + )?;
671 + devicekit
672 + .call("device.io.button", ::serde_json::json!({ "button": "home" }))
673 + .await
674 + .map_err(|error| {
675 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
676 + })?;
677 + Ok(::rmcp::model::CallToolResult::success(vec![
678 + ::rmcp::model::ContentBlock::text(format!(
679 + "Pressed Home on {}.",
680 + simulator.name
681 + )),
682 + ]))
683 + }
684 +
685 + #[::rmcp::tool(
686 + name = $simulator_button_name,
687 + description = "Press a DeviceKit remote button on an iOS or tvOS simulator. tvOS supports up, down, left, right, select, menu, home, and playPause."
688 + )]
689 + async fn simulator_button(
690 + &self,
691 + ::rmcp::handler::server::wrapper::Parameters(
692 + args,
693 + ): ::rmcp::handler::server::wrapper::Parameters<
694 + $crate::mcp::SimulatorButtonArgs,
695 + >,
696 + ) -> ::std::result::Result<
697 + ::rmcp::model::CallToolResult,
698 + ::rmcp::model::ErrorData,
699 + > {
700 + let (simulator, devicekit) = Self::devicekit_from_target(
701 + &args.simulator_name,
702 + &args.simulator_udid,
703 + args.devicekit_port,
704 + )?;
705 + let supported_buttons = [
706 + "up",
707 + "down",
708 + "left",
709 + "right",
710 + "select",
711 + "menu",
712 + "home",
713 + "playPause",
714 + ];
715 + if !supported_buttons.contains(&args.button.as_str()) {
716 + return Err(::rmcp::model::ErrorData::invalid_request(
717 + "button must be one of up, down, left, right, select, menu, home, or playPause",
718 + None,
719 + ));
720 + }
721 + devicekit
722 + .call(
723 + "device.io.button",
724 + ::serde_json::json!({ "button": args.button }),
725 + )
726 + .await
727 + .map_err(|error| {
728 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
729 + })?;
730 + Ok(::rmcp::model::CallToolResult::success(vec![
731 + ::rmcp::model::ContentBlock::text(format!(
732 + "Pressed {} on {}.",
733 + args.button, simulator.name
734 + )),
735 + ]))
736 + }
737 +
738 + #[::rmcp::tool(
739 + name = $simulator_orientation_get_name,
740 + description = "Read the current iOS simulator orientation through DeviceKit."
741 + )]
742 + async fn simulator_orientation_get(
743 + &self,
744 + ::rmcp::handler::server::wrapper::Parameters(
745 + args,
746 + ): ::rmcp::handler::server::wrapper::Parameters<
747 + $crate::mcp::SimulatorDeviceKitArgs,
748 + >,
749 + ) -> ::std::result::Result<
750 + ::rmcp::model::CallToolResult,
751 + ::rmcp::model::ErrorData,
752 + > {
753 + let (simulator, devicekit) = Self::devicekit_from_target(
754 + &args.simulator_name,
755 + &args.simulator_udid,
756 + args.devicekit_port,
757 + )?;
758 + let result = devicekit
759 + .call("device.io.orientation.get", ::serde_json::json!({}))
760 + .await
761 + .map_err(|error| {
762 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
763 + })?;
764 + Ok(::rmcp::model::CallToolResult::success(vec![
765 + ::rmcp::model::ContentBlock::json(&::serde_json::json!({
766 + "simulator": simulator,
767 + "orientation": result,
768 + }))?,
769 + ]))
770 + }
771 +
772 + #[::rmcp::tool(
773 + name = $simulator_orientation_set_name,
774 + description = "Set the iOS simulator orientation to PORTRAIT or LANDSCAPE through DeviceKit."
775 + )]
776 + async fn simulator_orientation_set(
777 + &self,
778 + ::rmcp::handler::server::wrapper::Parameters(
779 + args,
780 + ): ::rmcp::handler::server::wrapper::Parameters<
781 + $crate::mcp::SimulatorOrientationArgs,
782 + >,
783 + ) -> ::std::result::Result<
784 + ::rmcp::model::CallToolResult,
785 + ::rmcp::model::ErrorData,
786 + > {
787 + let (simulator, devicekit) = Self::devicekit_from_target(
788 + &args.simulator_name,
789 + &args.simulator_udid,
790 + args.devicekit_port,
791 + )?;
792 + let orientation = args.orientation.to_uppercase();
793 + if orientation != "PORTRAIT" && orientation != "LANDSCAPE" {
794 + return Err(::rmcp::model::ErrorData::invalid_request(
795 + "orientation must be PORTRAIT or LANDSCAPE",
796 + None,
797 + ));
798 + }
799 + devicekit
800 + .call(
801 + "device.io.orientation.set",
802 + ::serde_json::json!({ "orientation": orientation }),
803 + )
804 + .await
805 + .map_err(|error| {
806 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
807 + })?;
808 + Ok(::rmcp::model::CallToolResult::success(vec![
809 + ::rmcp::model::ContentBlock::text(format!(
810 + "Set orientation to {} on {}.",
811 + orientation, simulator.name
812 + )),
813 + ]))
814 + }
815 }
816 };
817 }
@@ -340,7 +824,16 @@ xcrs_mcp_tools!(
824 "xcrs_simulator_screenshot",
825 "xcrs_simulator_launch_app",
826 "xcrs_simulator_terminate_app",
343 - "xcrs_simulator_open_url"
827 + "xcrs_simulator_open_url",
828 + "xcrs_simulator_ui_dump",
829 + "xcrs_simulator_list_elements",
830 + "xcrs_simulator_tap",
831 + "xcrs_simulator_type_text",
832 + "xcrs_simulator_swipe",
833 + "xcrs_simulator_press_home",
834 + "xcrs_simulator_press_button",
835 + "xcrs_simulator_orientation_get",
836 + "xcrs_simulator_orientation_set"
837 );
838
839 #[rmcp::tool_handler(router = Self::xcrs_tool_router())]
crates/xcrs/src/xcrs.rs
+149
@@ -4,6 +4,7 @@ use std::collections::BTreeMap;
4 use std::ffi::OsStr;
5 use std::path::{Path, PathBuf};
6 use std::process::{Command, Output};
7 +use std::time::Duration;
8
9 pub mod mcp;
10
@@ -15,6 +16,121 @@ pub fn encode_base64(data: impl AsRef<[u8]>) -> String {
16 STANDARD.encode(data)
17 }
18
19 +pub fn extract_devicekit_elements(root: &serde_json::Value) -> Vec<serde_json::Value> {
20 + let mut elements = Vec::new();
21 + collect_devicekit_elements(root, &mut elements);
22 + elements
23 +}
24 +
25 +fn collect_devicekit_elements(element: &serde_json::Value, elements: &mut Vec<serde_json::Value>) {
26 + let object = match element.as_object() {
27 + Some(object) => object,
28 + None => return,
29 + };
30 +
31 + let rect = object.get("rect").and_then(serde_json::Value::as_object);
32 + let has_visible_rect = rect
33 + .and_then(|rect| {
34 + Some((
35 + rect.get("x")?.as_f64()?,
36 + rect.get("y")?.as_f64()?,
37 + rect.get("width")?.as_f64()?,
38 + rect.get("height")?.as_f64()?,
39 + ))
40 + })
41 + .is_some_and(|(x, y, width, height)| x >= 0.0 && y >= 0.0 && width > 0.0 && height > 0.0);
42 + let has_identity = ["label", "name", "value", "rawIdentifier"]
43 + .iter()
44 + .any(|key| {
45 + object
46 + .get(*key)
47 + .and_then(serde_json::Value::as_str)
48 + .is_some()
49 + });
50 +
51 + if has_visible_rect && has_identity {
52 + let keys = [
53 + "type",
54 + "label",
55 + "name",
56 + "value",
57 + "placeholderValue",
58 + "rawIdentifier",
59 + "rect",
60 + ];
61 + let element = keys
62 + .iter()
63 + .filter_map(|key| {
64 + object
65 + .get(*key)
66 + .map(|value| ((*key).to_string(), value.clone()))
67 + })
68 + .collect();
69 + elements.push(serde_json::Value::Object(element));
70 + }
71 +
72 + if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) {
73 + for child in children {
74 + collect_devicekit_elements(child, elements);
75 + }
76 + }
77 +}
78 +
79 +#[derive(Debug, Clone)]
80 +pub struct DeviceKit {
81 + client: reqwest::Client,
82 + base_url: String,
83 +}
84 +
85 +impl Default for DeviceKit {
86 + fn default() -> Self {
87 + Self::new(12004)
88 + }
89 +}
90 +
91 +impl DeviceKit {
92 + pub fn new(port: u16) -> Self {
93 + Self {
94 + client: reqwest::Client::new(),
95 + base_url: format!("http://127.0.0.1:{port}"),
96 + }
97 + }
98 +
99 + pub async fn call(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
100 + let response = self
101 + .client
102 + .post(format!("{}/rpc", self.base_url))
103 + .timeout(Duration::from_secs(30))
104 + .json(&serde_json::json!({
105 + "jsonrpc": "2.0",
106 + "method": method,
107 + "params": params,
108 + "id": 1,
109 + }))
110 + .send()
111 + .await
112 + .with_context(|| format!("failed to connect to DeviceKit at {}", self.base_url))?;
113 +
114 + let status = response.status();
115 + let body: serde_json::Value = response
116 + .json()
117 + .await
118 + .context("failed to decode DeviceKit JSON-RPC response")?;
119 +
120 + if !status.is_success() {
121 + return Err(anyhow!("DeviceKit returned HTTP {}: {}", status, body));
122 + }
123 +
124 + if let Some(error) = body.get("error") {
125 + return Err(anyhow!("DeviceKit method '{method}' failed: {error}"));
126 + }
127 +
128 + body.get("result")
129 + .cloned()
130 + .ok_or_else(|| anyhow!("DeviceKit response for '{method}' did not contain a result"))
131 + }
132 +}
133 +
134 #[derive(Debug, Clone)]
135 pub struct XcodeCommandLineTools {
136 xcrun_path: PathBuf,
@@ -67,8 +183,39 @@ impl XcodeCommandLineTools {
183 }
184 }
185
186 +#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187 +pub enum ApplePlatform {
188 + #[serde(rename = "iOS")]
189 + Ios,
190 + #[serde(rename = "tvOS")]
191 + Tvos,
192 + #[serde(rename = "watchOS")]
193 + Watchos,
194 + #[serde(rename = "visionOS")]
195 + Visionos,
196 + #[serde(rename = "unknown")]
197 + Unknown,
198 +}
199 +
200 +impl ApplePlatform {
201 + fn from_runtime_identifier(runtime_identifier: &str) -> Self {
202 + if runtime_identifier.contains(".iOS-") {
203 + Self::Ios
204 + } else if runtime_identifier.contains(".tvOS-") {
205 + Self::Tvos
206 + } else if runtime_identifier.contains(".watchOS-") {
207 + Self::Watchos
208 + } else if runtime_identifier.contains(".visionOS-") {
209 + Self::Visionos
210 + } else {
211 + Self::Unknown
212 + }
213 + }
214 +}
215 +
216 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
217 pub struct Simulator {
218 + pub platform: ApplePlatform,
219 pub runtime_identifier: String,
220 pub name: String,
221 pub udid: String,
@@ -318,6 +465,7 @@ pub fn parse_simctl_devices(output: &str) -> Result<Vec<Simulator>> {
465 for (runtime_identifier, devices) in list.devices {
466 for device in devices {
467 simulators.push(Simulator {
468 + platform: ApplePlatform::from_runtime_identifier(&runtime_identifier),
469 runtime_identifier: runtime_identifier.clone(),
470 name: device.name,
471 udid: device.udid,
@@ -414,6 +562,7 @@ mod tests {
562 let devices = parse_simctl_devices(output).expect("devices should parse");
563
564 assert_eq!(devices.len(), 1);
565 + assert_eq!(devices[0].platform, ApplePlatform::Ios);
566 assert_eq!(devices[0].name, "Test-iOS-26");
567 assert_eq!(devices[0].state, "Booted");
568 assert!(devices[0].is_booted());