5 files changed
+387
-2
crates/cli/src/cli/mod.rs
+49
@@ -3,6 +3,7 @@ use {
3
clap::{Parser, Subcommand},
4
smbcloud_network::environment::Environment,
5
spinners::Spinner,
6
+ std::path::PathBuf,
7
};
8
9
pub struct CommandResult {
@@ -96,9 +97,57 @@ pub enum Commands {
97
#[clap(subcommand)]
98
command: cloud_auth::cli::Commands,
99
},
100
+ #[clap(about = "Run and control XCRS ControlKit on Apple devices.")]
101
+ ControlKit {
102
+ #[clap(subcommand)]
103
+ command: ControlKitCommands,
104
+ },
105
#[clap(
106
about = "Migrate local .smb/config.toml deploy fields to the smbCloud server.",
107
display_order = 4
108
)]
109
Migrate {},
110
}
111
+
112
+#[derive(Subcommand)]
113
+pub enum ControlKitCommands {
114
+ #[clap(about = "Build a signed ControlKit XCTest runner for a physical device.")]
115
+ Build {
116
+ #[arg(long)]
117
+ project_path: PathBuf,
118
+ #[arg(long, default_value = "ControlKit")]
119
+ scheme: String,
120
+ #[arg(long, default_value = "Release")]
121
+ configuration: String,
122
+ #[arg(long)]
123
+ device_udid: String,
124
+ #[arg(long)]
125
+ derived_data_path: PathBuf,
126
+ },
127
+ #[clap(about = "Start a built ControlKit XCTest runner on a physical device.")]
128
+ Start {
129
+ #[arg(long)]
130
+ device_udid: String,
131
+ #[arg(long)]
132
+ xctestrun_path: PathBuf,
133
+ #[arg(long, default_value = "::")]
134
+ listen_host: String,
135
+ #[arg(long, default_value_t = 12004)]
136
+ listen_port: u16,
137
+ #[arg(long, default_value_t = 120)]
138
+ timeout_seconds: u64,
139
+ #[arg(long)]
140
+ log_path: Option<PathBuf>,
141
+ },
142
+ #[clap(about = "Call a running ControlKit JSON-RPC method.")]
143
+ Call {
144
+ #[arg(long)]
145
+ device_udid: String,
146
+ #[arg(long, default_value_t = 12004)]
147
+ port: u16,
148
+ #[arg(long)]
149
+ method: String,
150
+ #[arg(long, default_value = "{}")]
151
+ params: String,
152
+ },
153
+}
crates/cli/src/controlkit.rs
new
+119
@@ -0,0 +1,119 @@
1
+use {
2
+ crate::{
3
+ cli::{CommandResult, ControlKitCommands},
4
+ ui::{succeed_message, succeed_symbol},
5
+ },
6
+ anyhow::{anyhow, Context, Result},
7
+ spinners::{Spinner, Spinners},
8
+ std::{path::PathBuf, time::Duration},
9
+ xcrs::{
10
+ BuildControlKitDeviceRunner, ControlKit, StartControlKitDeviceRunner, XcodeCommandLineTools,
11
+ },
12
+};
13
+
14
+pub async fn process_controlkit(command: ControlKitCommands) -> Result<CommandResult> {
15
+ match command {
16
+ ControlKitCommands::Build {
17
+ project_path,
18
+ scheme,
19
+ configuration,
20
+ device_udid,
21
+ derived_data_path,
22
+ } => {
23
+ let test_run_path = XcodeCommandLineTools::new().build_controlkit_device_runner(
24
+ &BuildControlKitDeviceRunner {
25
+ project_path,
26
+ scheme,
27
+ configuration,
28
+ device_udid,
29
+ derived_data_path,
30
+ },
31
+ )?;
32
+ Ok(done_result(&format!(
33
+ "Built ControlKit runner: {}",
34
+ test_run_path.display()
35
+ )))
36
+ }
37
+ ControlKitCommands::Start {
38
+ device_udid,
39
+ xctestrun_path,
40
+ listen_host,
41
+ listen_port,
42
+ timeout_seconds,
43
+ log_path,
44
+ } => {
45
+ let tools = XcodeCommandLineTools::new();
46
+ let log_path = log_path.unwrap_or_else(|| {
47
+ std::env::temp_dir().join(format!("controlkit-{device_udid}.log"))
48
+ });
49
+ let mut runner =
50
+ tools.start_controlkit_device_runner(&StartControlKitDeviceRunner {
51
+ device_udid,
52
+ xctestrun_path,
53
+ listen_host,
54
+ listen_port,
55
+ log_path: log_path.clone(),
56
+ })?;
57
+ let controlkit = ControlKit::with_host(&runner.tunnel_host, runner.listen_port);
58
+ let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds);
59
+
60
+ loop {
61
+ if controlkit.health().await.is_ok() {
62
+ break;
63
+ }
64
+ if let Some(status) = runner.process.try_wait()? {
65
+ return Err(runner_failure(status.to_string(), &runner.log_path));
66
+ }
67
+ if tokio::time::Instant::now() >= deadline {
68
+ return Err(runner_failure(
69
+ format!("did not become healthy within {timeout_seconds} seconds"),
70
+ &runner.log_path,
71
+ ));
72
+ }
73
+ tokio::time::sleep(Duration::from_secs(1)).await;
74
+ }
75
+
76
+ Ok(done_result(&format!(
77
+ "ControlKit is running at http://[{}]:{} (PID {}, log {})",
78
+ runner.tunnel_host,
79
+ runner.listen_port,
80
+ runner.process.id(),
81
+ runner.log_path.display()
82
+ )))
83
+ }
84
+ ControlKitCommands::Call {
85
+ device_udid,
86
+ port,
87
+ method,
88
+ params,
89
+ } => {
90
+ let tools = XcodeCommandLineTools::new();
91
+ let tunnel_host = tools.device_tunnel_address(&device_udid)?;
92
+ let params = serde_json::from_str(¶ms)
93
+ .with_context(|| format!("invalid JSON for --params: {params}"))?;
94
+ let result = ControlKit::with_host(&tunnel_host, port)
95
+ .call(&method, params)
96
+ .await?;
97
+ Ok(done_result(&serde_json::to_string_pretty(&result)?))
98
+ }
99
+ }
100
+}
101
+
102
+fn runner_failure(reason: String, log_path: &PathBuf) -> anyhow::Error {
103
+ let log = std::fs::read_to_string(log_path).unwrap_or_default();
104
+ let tail = log.lines().rev().take(20).collect::<Vec<_>>();
105
+ let tail = tail.into_iter().rev().collect::<Vec<_>>().join("\n");
106
+ if tail.is_empty() {
107
+ anyhow!("ControlKit runner {reason}; see {}", log_path.display())
108
+ } else {
109
+ anyhow!("ControlKit runner {reason}:\n{tail}")
110
+ }
111
+}
112
+
113
+fn done_result(message: &str) -> CommandResult {
114
+ CommandResult {
115
+ spinner: Spinner::new(Spinners::SimpleDotsScrolling, succeed_message("Done")),
116
+ symbol: succeed_symbol(),
117
+ msg: succeed_message(message),
118
+ }
119
+}
crates/cli/src/lib.rs
+1
@@ -5,6 +5,7 @@ pub mod ci;
5
pub mod cli;
6
#[path = "cloud-auth/mod.rs"]
7
pub mod cloud_auth;
8
+pub mod controlkit;
9
#[path = "cloud-deploy/mod.rs"]
10
pub mod deploy;
11
pub mod interface;
crates/cli/src/main.rs
+3
@@ -7,6 +7,7 @@ use {
7
clear_smb_token,
8
cli::{Cli, CommandResult, Commands},
9
cloud_auth::process::process_cloud_auth,
10
+ controlkit::process_controlkit,
11
deploy::{process_deploy::process_deploy, process_migrate::process_migrate},
12
mail::process::process_mail,
13
project::{crud_create::process_project_init, process::process_project},
@@ -184,6 +185,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
185
| Some(Commands::Tenant { .. })
186
| Some(Commands::Migrate {})
187
| None => true,
188
+ Some(Commands::ControlKit { .. }) => false,
189
Some(Commands::Init {}) => true,
190
};
191
@@ -205,6 +207,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
207
Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await,
208
Some(Commands::Project { command }) => process_project(cli.environment, command).await,
209
Some(Commands::Tenant { command }) => process_tenant(cli.environment, command).await,
210
+ Some(Commands::ControlKit { command }) => process_controlkit(command).await,
211
Some(Commands::Migrate {}) => process_migrate(cli.environment).await,
212
None => process_deploy(cli.environment, None).await,
213
}
crates/xcrs/src/xcrs.rs
+215
-2
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
3
use std::collections::BTreeMap;
4
use std::ffi::OsStr;
5
use std::path::{Path, PathBuf};
6
-use std::process::{Command, Output};
6
+use std::process::{Child, Command, Output, Stdio};
7
use std::time::Duration;
8
9
pub mod mcp;
@@ -90,12 +90,40 @@ impl Default for ControlKit {
90
91
impl ControlKit {
92
pub fn new(port: u16) -> Self {
93
+ Self::with_host("127.0.0.1", port)
94
+ }
95
+
96
+ pub fn with_host(host: &str, port: u16) -> Self {
97
+ let host = if host.contains(':') && !host.starts_with('[') {
98
+ format!("[{host}]")
99
+ } else {
100
+ host.to_string()
101
+ };
102
Self {
103
client: reqwest::Client::new(),
95
- base_url: format!("http://127.0.0.1:{port}"),
104
+ base_url: format!("http://{host}:{port}"),
105
}
106
}
107
108
+ pub async fn health(&self) -> Result<()> {
109
+ let response = self
110
+ .client
111
+ .get(format!("{}/health", self.base_url))
112
+ .timeout(Duration::from_secs(5))
113
+ .send()
114
+ .await
115
+ .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?;
116
+
117
+ if !response.status().is_success() {
118
+ return Err(anyhow!(
119
+ "ControlKit health check returned HTTP {}",
120
+ response.status()
121
+ ));
122
+ }
123
+
124
+ Ok(())
125
+ }
126
+
127
pub async fn call(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
128
let response = self
129
.client
@@ -455,6 +483,176 @@ impl XcodeCommandLineTools {
483
app_container_path,
484
})
485
}
486
+
487
+ pub fn device_tunnel_address(&self, device_udid: &str) -> Result<String> {
488
+ let output = self.run_xcrun([
489
+ "devicectl",
490
+ "device",
491
+ "info",
492
+ "details",
493
+ "--device",
494
+ device_udid,
495
+ ])?;
496
+
497
+ parse_device_tunnel_address(&output)
498
+ .ok_or_else(|| anyhow!("device {device_udid} did not report a tunnel IP address"))
499
+ }
500
+
501
+ pub fn build_controlkit_device_runner(
502
+ &self,
503
+ request: &BuildControlKitDeviceRunner,
504
+ ) -> Result<PathBuf> {
505
+ ensure_path_exists(&request.project_path)?;
506
+ let args = [
507
+ "build-for-testing".to_string(),
508
+ "-project".to_string(),
509
+ path_to_string(&request.project_path)?,
510
+ "-scheme".to_string(),
511
+ request.scheme.clone(),
512
+ "-configuration".to_string(),
513
+ request.configuration.clone(),
514
+ "-destination".to_string(),
515
+ format!("id={}", request.device_udid),
516
+ "-derivedDataPath".to_string(),
517
+ path_to_string(&request.derived_data_path)?,
518
+ ];
519
+ self.run_xcodebuild(args)?;
520
+
521
+ let products_path = request.derived_data_path.join("Build/Products");
522
+ let entries = std::fs::read_dir(&products_path)
523
+ .with_context(|| format!("failed to read {}", products_path.display()))?;
524
+ let mut test_run_paths = entries
525
+ .filter_map(|entry| entry.ok().map(|entry| entry.path()))
526
+ .filter(|path| {
527
+ path.extension()
528
+ .is_some_and(|extension| extension == "xctestrun")
529
+ })
530
+ .collect::<Vec<_>>();
531
+ test_run_paths.sort();
532
+
533
+ test_run_paths.into_iter().next().ok_or_else(|| {
534
+ anyhow!(
535
+ "Xcode did not create an .xctestrun file in {}",
536
+ products_path.display()
537
+ )
538
+ })
539
+ }
540
+
541
+ pub fn start_controlkit_device_runner(
542
+ &self,
543
+ request: &StartControlKitDeviceRunner,
544
+ ) -> Result<RunningControlKitDeviceRunner> {
545
+ ensure_path_exists(&request.xctestrun_path)?;
546
+ set_xctestrun_environment(
547
+ &request.xctestrun_path,
548
+ "CONTROLKIT_LISTEN_HOST",
549
+ &request.listen_host,
550
+ )?;
551
+ set_xctestrun_environment(
552
+ &request.xctestrun_path,
553
+ "CONTROLKIT_LISTEN_PORT",
554
+ &request.listen_port.to_string(),
555
+ )?;
556
+
557
+ let tunnel_host = self.device_tunnel_address(&request.device_udid)?;
558
+ let log_file = std::fs::OpenOptions::new()
559
+ .create(true)
560
+ .truncate(true)
561
+ .write(true)
562
+ .open(&request.log_path)
563
+ .with_context(|| format!("failed to open {}", request.log_path.display()))?;
564
+ let error_log_file = log_file
565
+ .try_clone()
566
+ .with_context(|| format!("failed to clone {}", request.log_path.display()))?;
567
+
568
+ let child = Command::new(&self.xcodebuild_path)
569
+ .arg("test-without-building")
570
+ .arg("-xctestrun")
571
+ .arg(&request.xctestrun_path)
572
+ .arg("-destination")
573
+ .arg(format!("id={}", request.device_udid))
574
+ .stdin(Stdio::null())
575
+ .stdout(Stdio::from(log_file))
576
+ .stderr(Stdio::from(error_log_file))
577
+ .spawn()
578
+ .with_context(|| {
579
+ format!(
580
+ "failed to start ControlKit runner for device {}",
581
+ request.device_udid
582
+ )
583
+ })?;
584
+
585
+ Ok(RunningControlKitDeviceRunner {
586
+ process: child,
587
+ tunnel_host,
588
+ listen_port: request.listen_port,
589
+ log_path: request.log_path.clone(),
590
+ })
591
+ }
592
+}
593
+
594
+#[derive(Debug, Clone)]
595
+pub struct BuildControlKitDeviceRunner {
596
+ pub project_path: PathBuf,
597
+ pub scheme: String,
598
+ pub configuration: String,
599
+ pub device_udid: String,
600
+ pub derived_data_path: PathBuf,
601
+}
602
+
603
+#[derive(Debug, Clone)]
604
+pub struct StartControlKitDeviceRunner {
605
+ pub device_udid: String,
606
+ pub xctestrun_path: PathBuf,
607
+ pub listen_host: String,
608
+ pub listen_port: u16,
609
+ pub log_path: PathBuf,
610
+}
611
+
612
+#[derive(Debug)]
613
+pub struct RunningControlKitDeviceRunner {
614
+ pub process: Child,
615
+ pub tunnel_host: String,
616
+ pub listen_port: u16,
617
+ pub log_path: PathBuf,
618
+}
619
+
620
+fn set_xctestrun_environment(path: &Path, name: &str, value: &str) -> Result<()> {
621
+ let key = format!(":TestConfigurations:0:TestTargets:0:EnvironmentVariables:{name}");
622
+ let set_status = Command::new("/usr/libexec/PlistBuddy")
623
+ .arg("-c")
624
+ .arg(format!("Set {key} {value}"))
625
+ .arg(path)
626
+ .status()
627
+ .with_context(|| format!("failed to update {}", path.display()))?;
628
+
629
+ if set_status.success() {
630
+ return Ok(());
631
+ }
632
+
633
+ let add_status = Command::new("/usr/libexec/PlistBuddy")
634
+ .arg("-c")
635
+ .arg(format!("Add {key} string {value}"))
636
+ .arg(path)
637
+ .status()
638
+ .with_context(|| format!("failed to update {}", path.display()))?;
639
+
640
+ if add_status.success() {
641
+ return Ok(());
642
+ }
643
+
644
+ Err(anyhow!(
645
+ "failed to set {name} in XCTest plan {}",
646
+ path.display()
647
+ ))
648
+}
649
+
650
+fn parse_device_tunnel_address(output: &str) -> Option<String> {
651
+ output
652
+ .lines()
653
+ .find_map(|line| line.split_once("Tunnel IP Address:"))
654
+ .map(|(_, address)| address.trim().to_string())
655
+ .filter(|address| !address.is_empty())
656
}
657
658
pub fn parse_simctl_devices(output: &str) -> Result<Vec<Simulator>> {
@@ -567,4 +765,19 @@ mod tests {
765
assert_eq!(devices[0].state, "Booted");
766
assert!(devices[0].is_booted());
767
}
768
+
769
+ #[test]
770
+ fn formats_ipv6_controlkit_url() {
771
+ let controlkit = ControlKit::with_host("fdb4:e020:7377::1", 12006);
772
+ assert_eq!(controlkit.base_url, "http://[fdb4:e020:7377::1]:12006");
773
+ }
774
+
775
+ #[test]
776
+ fn parses_device_tunnel_address() {
777
+ let output = "• Device Name: iPhone\n• Tunnel IP Address: fd55:33ce:ad87::1\n";
778
+ assert_eq!(
779
+ parse_device_tunnel_address(output).as_deref(),
780
+ Some("fd55:33ce:ad87::1")
781
+ );
782
+ }
783
}