@smbcloud / cli / commits / c9949dd

Add simctl abstractions

Seto Elkahfi committed Jul 30, 2026 at 11:58 UTC c9949dd6ae649c8bae83162c92695dd62b24ee5e
6 files changed +233 -3
Cargo.lock
+2
@@ -3999,11 +3999,13 @@ name = "xcrs"
3999 version = "0.1.0"
4000 dependencies = [
4001 "anyhow",
4002 + "base64",
4003 "clap 4.6.2",
4004 "rmcp",
4005 "schemars",
4006 "serde",
4007 "serde_json",
4008 + "tempfile",
4009 "tokio",
4010 ]
4011
Cargo.toml
+1
@@ -5,6 +5,7 @@ members = ["crates/cli", "crates/smbcloud-*", "crates/xcrs"]
5 [workspace.dependencies]
6 anyhow = "1.0.58"
7 async-trait = "0.1.51"
8 +base64 = "0.22"
9 chrono = "0.4"
10 clap = "4.1.6"
11 clap_mangen = "0.2.10"
crates/cli/src/mcp_xcrs.rs
+5 -1
@@ -4,5 +4,9 @@ xcrs::xcrs_mcp_tools!(
4 SmbMcpServer,
5 "smb_simulator_list",
6 "smb_simulator_find",
7 - "smb_ios_app_test"
7 + "smb_ios_app_test",
8 + "smb_simulator_screenshot",
9 + "smb_simulator_launch_app",
10 + "smb_simulator_terminate_app",
11 + "smb_simulator_open_url"
12 );
crates/xcrs/Cargo.toml
+2
@@ -19,9 +19,11 @@ path = "src/main.rs"
19
20 [dependencies]
21 anyhow = { workspace = true }
22 +base64 = { workspace = true }
23 clap = { workspace = true, features = ["derive", "env"] }
24 rmcp = { workspace = true }
25 schemars = { workspace = true }
26 serde = { workspace = true, features = ["derive"] }
27 serde_json = { workspace = true }
28 +tempfile = { workspace = true }
29 tokio = { workspace = true, features = ["full"] }
crates/xcrs/src/mcp.rs
+203 -2
@@ -33,6 +33,40 @@ pub struct IosAppTestArgs {
33 pub terminate_before_launch: bool,
34 }
35
36 +#[derive(Debug, Deserialize, JsonSchema)]
37 +pub struct SimulatorTargetArgs {
38 + /// Exact simulator name.
39 + #[serde(default)]
40 + pub simulator_name: Option<String>,
41 + /// Simulator UDID.
42 + #[serde(default)]
43 + pub simulator_udid: Option<String>,
44 +}
45 +
46 +#[derive(Debug, Deserialize, JsonSchema)]
47 +pub struct SimulatorAppArgs {
48 + /// Exact simulator name.
49 + #[serde(default)]
50 + pub simulator_name: Option<String>,
51 + /// Simulator UDID.
52 + #[serde(default)]
53 + pub simulator_udid: Option<String>,
54 + /// App bundle identifier.
55 + pub bundle_id: String,
56 +}
57 +
58 +#[derive(Debug, Deserialize, JsonSchema)]
59 +pub struct SimulatorOpenUrlArgs {
60 + /// Exact simulator name.
61 + #[serde(default)]
62 + pub simulator_name: Option<String>,
63 + /// Simulator UDID.
64 + #[serde(default)]
65 + pub simulator_udid: Option<String>,
66 + /// HTTP(S) URL or custom URL scheme.
67 + pub url: String,
68 +}
69 +
70 #[derive(Debug, Default)]
71 pub struct XcrsMcpServer;
72
@@ -44,9 +78,42 @@ impl XcrsMcpServer {
78
79 #[macro_export]
80 macro_rules! xcrs_mcp_tools {
47 - ($server:ty, $simulator_list_name:literal, $simulator_find_name:literal, $ios_app_test_name:literal) => {
81 + (
82 + $server:ty,
83 + $simulator_list_name:literal,
84 + $simulator_find_name:literal,
85 + $ios_app_test_name:literal,
86 + $simulator_screenshot_name:literal,
87 + $simulator_launch_app_name:literal,
88 + $simulator_terminate_app_name:literal,
89 + $simulator_open_url_name:literal
90 + ) => {
91 #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")]
92 impl $server {
93 + fn simulator_from_target(
94 + target: &$crate::mcp::SimulatorTargetArgs,
95 + ) -> ::std::result::Result<$crate::Simulator, ::rmcp::model::ErrorData> {
96 + let tools = $crate::XcodeCommandLineTools::new();
97 + match (&target.simulator_udid, &target.simulator_name) {
98 + (Some(udid), _) => tools
99 + .simctl()
100 + .find_simulator_by_udid(udid)
101 + .map_err(|error| {
102 + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
103 + }),
104 + (None, Some(name)) => tools
105 + .simctl()
106 + .find_simulator_by_name(name)
107 + .map_err(|error| {
108 + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None)
109 + }),
110 + (None, None) => Err(::rmcp::model::ErrorData::invalid_request(
111 + "Provide either simulator_name or simulator_udid.",
112 + None,
113 + )),
114 + }
115 + }
116 +
117 #[::rmcp::tool(
118 name = $simulator_list_name,
119 description = "List all iOS simulators known to Xcode, including their runtime, UDID, availability, and current state."
@@ -131,6 +198,136 @@ macro_rules! xcrs_mcp_tools {
198 ::rmcp::model::ContentBlock::json(&result)?,
199 ]))
200 }
201 +
202 + #[::rmcp::tool(
203 + name = $simulator_screenshot_name,
204 + description = "Capture a PNG screenshot from an iOS simulator."
205 + )]
206 + async fn simulator_screenshot(
207 + &self,
208 + ::rmcp::handler::server::wrapper::Parameters(
209 + args,
210 + ): ::rmcp::handler::server::wrapper::Parameters<
211 + $crate::mcp::SimulatorTargetArgs,
212 + >,
213 + ) -> ::std::result::Result<
214 + ::rmcp::model::CallToolResult,
215 + ::rmcp::model::ErrorData,
216 + > {
217 + let simulator = Self::simulator_from_target(&args)?;
218 + let screenshot = $crate::XcodeCommandLineTools::new()
219 + .simctl()
220 + .screenshot(&simulator.udid)
221 + .map_err(|error| {
222 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
223 + })?;
224 + let encoded = $crate::encode_base64(screenshot);
225 + Ok(::rmcp::model::CallToolResult::success(vec![
226 + ::rmcp::model::ContentBlock::image(encoded, "image/png"),
227 + ]))
228 + }
229 +
230 + #[::rmcp::tool(
231 + name = $simulator_launch_app_name,
232 + description = "Launch an installed app on an iOS simulator by bundle identifier."
233 + )]
234 + async fn simulator_launch_app(
235 + &self,
236 + ::rmcp::handler::server::wrapper::Parameters(
237 + args,
238 + ): ::rmcp::handler::server::wrapper::Parameters<
239 + $crate::mcp::SimulatorAppArgs,
240 + >,
241 + ) -> ::std::result::Result<
242 + ::rmcp::model::CallToolResult,
243 + ::rmcp::model::ErrorData,
244 + > {
245 + let target = $crate::mcp::SimulatorTargetArgs {
246 + simulator_name: args.simulator_name,
247 + simulator_udid: args.simulator_udid,
248 + };
249 + let simulator = Self::simulator_from_target(&target)?;
250 + $crate::XcodeCommandLineTools::new()
251 + .simctl()
252 + .launch_app(&simulator.udid, &args.bundle_id)
253 + .map_err(|error| {
254 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
255 + })?;
256 + Ok(::rmcp::model::CallToolResult::success(vec![
257 + ::rmcp::model::ContentBlock::text(format!(
258 + "Launched {} on {}.",
259 + args.bundle_id, simulator.name
260 + )),
261 + ]))
262 + }
263 +
264 + #[::rmcp::tool(
265 + name = $simulator_terminate_app_name,
266 + description = "Terminate an installed app on an iOS simulator by bundle identifier."
267 + )]
268 + async fn simulator_terminate_app(
269 + &self,
270 + ::rmcp::handler::server::wrapper::Parameters(
271 + args,
272 + ): ::rmcp::handler::server::wrapper::Parameters<
273 + $crate::mcp::SimulatorAppArgs,
274 + >,
275 + ) -> ::std::result::Result<
276 + ::rmcp::model::CallToolResult,
277 + ::rmcp::model::ErrorData,
278 + > {
279 + let target = $crate::mcp::SimulatorTargetArgs {
280 + simulator_name: args.simulator_name,
281 + simulator_udid: args.simulator_udid,
282 + };
283 + let simulator = Self::simulator_from_target(&target)?;
284 + $crate::XcodeCommandLineTools::new()
285 + .simctl()
286 + .terminate_app(&simulator.udid, &args.bundle_id)
287 + .map_err(|error| {
288 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
289 + })?;
290 + Ok(::rmcp::model::CallToolResult::success(vec![
291 + ::rmcp::model::ContentBlock::text(format!(
292 + "Terminated {} on {}.",
293 + args.bundle_id, simulator.name
294 + )),
295 + ]))
296 + }
297 +
298 + #[::rmcp::tool(
299 + name = $simulator_open_url_name,
300 + description = "Open a URL or custom URL scheme on an iOS simulator."
301 + )]
302 + async fn simulator_open_url(
303 + &self,
304 + ::rmcp::handler::server::wrapper::Parameters(
305 + args,
306 + ): ::rmcp::handler::server::wrapper::Parameters<
307 + $crate::mcp::SimulatorOpenUrlArgs,
308 + >,
309 + ) -> ::std::result::Result<
310 + ::rmcp::model::CallToolResult,
311 + ::rmcp::model::ErrorData,
312 + > {
313 + let target = $crate::mcp::SimulatorTargetArgs {
314 + simulator_name: args.simulator_name,
315 + simulator_udid: args.simulator_udid,
316 + };
317 + let simulator = Self::simulator_from_target(&target)?;
318 + $crate::XcodeCommandLineTools::new()
319 + .simctl()
320 + .open_url(&simulator.udid, &args.url)
321 + .map_err(|error| {
322 + ::rmcp::model::ErrorData::internal_error(error.to_string(), None)
323 + })?;
324 + Ok(::rmcp::model::CallToolResult::success(vec![
325 + ::rmcp::model::ContentBlock::text(format!(
326 + "Opened {} on {}.",
327 + args.url, simulator.name
328 + )),
329 + ]))
330 + }
331 }
332 };
333 }
@@ -139,7 +336,11 @@ xcrs_mcp_tools!(
336 XcrsMcpServer,
337 "xcrs_simulator_list",
338 "xcrs_simulator_find",
142 - "xcrs_ios_app_test"
339 + "xcrs_ios_app_test",
340 + "xcrs_simulator_screenshot",
341 + "xcrs_simulator_launch_app",
342 + "xcrs_simulator_terminate_app",
343 + "xcrs_simulator_open_url"
344 );
345
346 #[rmcp::tool_handler(router = Self::xcrs_tool_router())]
crates/xcrs/src/xcrs.rs
+20
@@ -9,6 +9,12 @@ pub mod mcp;
9
10 const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id=";
11
12 +pub fn encode_base64(data: impl AsRef<[u8]>) -> String {
13 + use base64::{engine::general_purpose::STANDARD, Engine};
14 +
15 + STANDARD.encode(data)
16 +}
17 +
18 #[derive(Debug, Clone)]
19 pub struct XcodeCommandLineTools {
20 xcrun_path: PathBuf,
@@ -169,6 +175,20 @@ impl Simctl<'_> {
175 Ok(())
176 }
177
178 + pub fn screenshot(&self, udid: &str) -> Result<Vec<u8>> {
179 + let temporary_directory = tempfile::tempdir()?;
180 + let screenshot_path = temporary_directory.path().join("screenshot.png");
181 + let screenshot_path_string = screenshot_path
182 + .to_str()
183 + .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?;
184 +
185 + self.tools
186 + .run_xcrun(["simctl", "io", udid, "screenshot", screenshot_path_string])?;
187 +
188 + std::fs::read(&screenshot_path)
189 + .with_context(|| format!("failed to read {}", screenshot_path.display()))
190 + }
191 +
192 pub fn app_container_path(&self, udid: &str, bundle_id: &str) -> Result<PathBuf> {
193 let output =
194 self.tools