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());