1use std::path::Path;
11#[cfg(unix)]
12use std::time::Duration;
13
14use eyre::{Context as _, bail, eyre};
15use semver::Version;
16use serde::Deserialize;
17use smol::{
18 channel::Sender,
19 io::{AsyncBufReadExt, BufReader},
20 process::Stdio,
21 spawn,
22 stream::StreamExt,
23};
24use tracing::info;
25
26use crate::{
27 device::{ApplicationExit, Artifact, Device, DeviceEvent, FailToRun, Running},
28 toolchain::Host,
29 utils::parse_semver_version,
30};
31
32#[derive(Debug, Clone)]
39pub struct ApplePhysicalDevice {
40 pub identifier: String,
42 pub udid: String,
44 pub name: String,
46 pub marketing_name: Option<String>,
48 pub os_version: Option<Version>,
50 pub transport: Transport,
52 pub tunnel_state: TunnelState,
57 pub developer_mode_enabled: bool,
60 pub boot_state: String,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Transport {
67 Wired,
69 LocalNetwork,
71 Other,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum TunnelState {
78 Connected,
80 Disconnected,
82 Unavailable,
84}
85
86#[derive(Deserialize)]
87struct DeviceList {
88 result: DeviceListResult,
89}
90
91#[derive(Deserialize)]
92struct DeviceListResult {
93 #[serde(default)]
94 devices: Vec<DeviceEntry>,
95}
96
97#[derive(Deserialize)]
98#[serde(rename_all = "camelCase")]
99struct DeviceEntry {
100 identifier: String,
101 #[serde(default)]
102 connection_properties: ConnectionProperties,
103 #[serde(default)]
104 device_properties: DeviceProperties,
105 #[serde(default)]
106 hardware_properties: HardwareProperties,
107}
108
109#[derive(Default, Deserialize)]
110#[serde(rename_all = "camelCase")]
111struct ConnectionProperties {
112 pairing_state: Option<String>,
113 transport_type: Option<String>,
114 tunnel_state: Option<String>,
115}
116
117#[derive(Default, Deserialize)]
118#[serde(rename_all = "camelCase")]
119struct DeviceProperties {
120 name: Option<String>,
121 os_version_number: Option<String>,
122 developer_mode_status: Option<String>,
123 boot_state: Option<String>,
124}
125
126#[derive(Default, Deserialize)]
127#[serde(rename_all = "camelCase")]
128struct HardwareProperties {
129 device_type: Option<String>,
130 marketing_name: Option<String>,
131 udid: Option<String>,
132 reality: Option<String>,
135}
136
137impl DeviceEntry {
138 fn is_ios_device(&self) -> bool {
143 matches!(
144 self.hardware_properties.device_type.as_deref(),
145 Some("iPhone" | "iPad")
146 ) && self.hardware_properties.reality.as_deref() != Some("simulated")
147 }
148}
149
150impl ApplePhysicalDevice {
151 pub fn parse_list(json: &str) -> eyre::Result<Vec<Self>> {
162 let list: DeviceList =
163 serde_json::from_str(json).wrap_err("failed to parse `devicectl list devices` JSON")?;
164 Ok(list
165 .result
166 .devices
167 .into_iter()
168 .filter(DeviceEntry::is_ios_device)
169 .filter_map(|entry| {
170 if entry.connection_properties.pairing_state.as_deref() != Some("paired") {
171 return None;
172 }
173 let os_version = entry
174 .device_properties
175 .os_version_number
176 .as_deref()
177 .map(|raw| {
178 parse_semver_version(raw).map_err(|error| {
179 tracing::warn!(
180 "device {} reported an unparseable osVersionNumber `{raw}`: {error}",
181 entry.identifier
182 );
183 })
184 })
185 .transpose()
186 .ok()
187 .flatten();
188 Some(Self {
189 identifier: entry.identifier,
190 udid: entry.hardware_properties.udid.unwrap_or_default(),
191 name: entry
192 .device_properties
193 .name
194 .or_else(|| entry.hardware_properties.marketing_name.clone())
195 .unwrap_or_else(|| String::from("iOS device")),
196 marketing_name: entry.hardware_properties.marketing_name,
197 os_version,
198 transport: match entry.connection_properties.transport_type.as_deref() {
199 Some("wired") => Transport::Wired,
200 Some("localNetwork") => Transport::LocalNetwork,
201 _ => Transport::Other,
202 },
203 tunnel_state: match entry.connection_properties.tunnel_state.as_deref() {
204 Some("connected") => TunnelState::Connected,
205 Some("unavailable") => TunnelState::Unavailable,
206 _ => TunnelState::Disconnected,
207 },
208 developer_mode_enabled: entry
209 .device_properties
210 .developer_mode_status
211 .as_deref()
212 == Some("enabled"),
213 boot_state: entry.device_properties.boot_state.unwrap_or_default(),
214 })
215 })
216 .collect())
217 }
218
219 pub async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
224 let output = host
225 .output(
226 "xcrun",
227 ["devicectl", "list", "devices", "--json-output", "-"],
228 )
229 .await
230 .wrap_err("failed to run `devicectl list devices`")?;
231 if !output.status.success() {
232 bail!(
233 "`devicectl list devices` failed: {}",
234 String::from_utf8_lossy(&output.stderr).trim()
235 );
236 }
237 Self::parse_list(&String::from_utf8_lossy(&output.stdout))
238 }
239
240 #[must_use]
242 pub fn selector(&self) -> &str {
243 &self.identifier
244 }
245
246 pub fn usability(&self) -> Result<(), DeviceUnusable> {
256 if matches!(self.tunnel_state, TunnelState::Unavailable) {
257 return Err(DeviceUnusable::Unreachable);
258 }
259 if self.boot_state != "booted" {
260 return Err(DeviceUnusable::NotBooted);
261 }
262 if !self.developer_mode_enabled {
263 return Err(DeviceUnusable::DeveloperModeDisabled);
264 }
265 Ok(())
266 }
267
268 #[must_use]
274 pub fn supports_deployment_target(&self, deployment_target: &Version) -> bool {
275 self.os_version
276 .as_ref()
277 .is_some_and(|os| os >= deployment_target)
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum DeviceUnusable {
285 Unreachable,
288 NotBooted,
290 DeveloperModeDisabled,
292}
293
294impl DeviceUnusable {
295 #[must_use]
297 pub fn remedy(self, device: &ApplePhysicalDevice) -> String {
298 match self {
299 Self::Unreachable => format!(
300 "{} is paired but unreachable. Unlock it and check the USB cable, \
301 or enable “Connect via network” in Xcode → Devices and Simulators \
302 while the iPhone and this Mac share a LAN.",
303 device.name
304 ),
305 Self::NotBooted => format!("{} is not booted.", device.name),
306 Self::DeveloperModeDisabled => format!(
307 "Developer Mode is disabled on {}. Enable it in \
308 Settings → Privacy & Security → Developer Mode, then restart the device.",
309 device.name
310 ),
311 }
312 }
313}
314
315fn environment_json<'a>(env_vars: impl Iterator<Item = (&'a str, &'a str)>) -> String {
323 let map: serde_json::Map<String, serde_json::Value> = env_vars
324 .map(|(key, value)| {
325 (
326 key.to_string(),
327 serde_json::Value::String(value.to_string()),
328 )
329 })
330 .collect();
331 serde_json::Value::Object(map).to_string()
332}
333
334async fn install_device_app(
335 host: &Host,
336 selector: &str,
337 artifact_path: &Path,
338) -> Result<(), FailToRun> {
339 let output = host
340 .command("xcrun")
341 .args([
342 "devicectl",
343 "device",
344 "install",
345 "app",
346 "--device",
347 selector,
348 ])
349 .arg(artifact_path)
350 .stdout(Stdio::piped())
351 .stderr(Stdio::piped())
352 .output()
353 .await
354 .map_err(|error| FailToRun::Install(eyre!("Failed to install app: {error}")))?;
355 if output.status.success() {
356 return Ok(());
357 }
358 Err(FailToRun::Install(eyre!(
359 "Failed to install app on the device:\n{}\n{}",
360 String::from_utf8_lossy(&output.stdout).trim(),
361 String::from_utf8_lossy(&output.stderr).trim(),
362 )))
363}
364
365#[cfg(unix)]
371fn find_remote_pid(host: &Host, selector: &str, process_name: &str) -> Option<u32> {
372 #[derive(Deserialize)]
373 struct ProcessList {
374 result: ProcessListResult,
375 }
376 #[derive(Deserialize)]
377 #[serde(rename_all = "camelCase")]
378 struct ProcessListResult {
379 #[serde(default)]
380 running_processes: Vec<RemoteProcess>,
381 }
382 #[derive(Deserialize)]
383 #[serde(rename_all = "camelCase")]
384 struct RemoteProcess {
385 executable: String,
386 process_identifier: u32,
387 }
388
389 let output = host
390 .std_command("xcrun")
391 .args([
392 "devicectl",
393 "device",
394 "info",
395 "processes",
396 "--device",
397 selector,
398 "--json-output",
399 "-",
400 ])
401 .output()
402 .ok()?;
403 if !output.status.success() {
404 return None;
405 }
406 let list: ProcessList = serde_json::from_slice(&output.stdout).ok()?;
407 let suffix = format!("/{process_name}");
408 list.result
409 .running_processes
410 .into_iter()
411 .find(|process| process.executable.ends_with(&suffix))
412 .map(|process| process.process_identifier)
413}
414
415#[cfg(unix)]
417fn signal_child(child: &std::process::Child, signal: nix::sys::signal::Signal) {
418 let pid = nix::unistd::Pid::from_raw(
419 i32::try_from(child.id()).expect("process identifiers fit in i32"),
420 );
421 let _ = nix::sys::signal::kill(pid, signal);
422}
423
424#[cfg(unix)]
431fn stop_console_session(
432 mut child: std::process::Child,
433 host: &Host,
434 selector: &str,
435 process_name: &str,
436) {
437 signal_child(&child, nix::sys::signal::Signal::SIGTERM);
438 for _ in 0..40 {
439 if matches!(child.try_wait(), Ok(Some(_))) {
440 return;
441 }
442 std::thread::sleep(Duration::from_millis(50));
443 }
444 if let Some(pid) = find_remote_pid(host, selector, process_name) {
445 let _ = host
446 .std_command("xcrun")
447 .args([
448 "devicectl",
449 "device",
450 "process",
451 "terminate",
452 "--device",
453 selector,
454 "--kill",
455 "--pid",
456 &pid.to_string(),
457 ])
458 .output();
459 }
460 signal_child(&child, nix::sys::signal::Signal::SIGKILL);
461 let _ = child.wait();
462}
463
464#[cfg(not(unix))]
467fn stop_console_session(
468 mut child: std::process::Child,
469 _host: &Host,
470 _selector: &str,
471 _process_name: &str,
472) {
473 let _ = child.kill();
474 let _ = child.wait();
475}
476
477impl Device for ApplePhysicalDevice {
478 fn name(&self) -> &str {
479 &self.name
480 }
481
482 fn launch(&self, _host: &Host) -> impl Future<Output = eyre::Result<()>> + Send {
483 std::future::ready(
486 self.usability()
487 .map_err(|reason| eyre!("{}", reason.remedy(self))),
488 )
489 }
490
491 async fn run(
492 &self,
493 host: &Host,
494 artifact: Artifact,
495 options: crate::device::RunOptions,
496 ) -> Result<Running, FailToRun> {
497 if let Err(reason) = self.usability() {
498 return Err(FailToRun::Run(eyre!("{}", reason.remedy(self))));
499 }
500
501 info!(
502 "Installing {} on {} ({})",
503 artifact.bundle_id(),
504 self.name,
505 self.identifier
506 );
507 install_device_app(host, self.selector(), artifact.path()).await?;
508
509 let env_json = environment_json(options.env_vars());
510 let bundle_id = artifact.bundle_id().to_string();
511 let process_name = artifact
512 .path()
513 .file_stem()
514 .and_then(|stem| stem.to_str())
515 .ok_or_else(|| {
516 FailToRun::Run(eyre!(
517 "Artifact path has no UTF-8 filename: {}",
518 artifact.path().display()
519 ))
520 })?
521 .to_string();
522
523 let mut command = host.std_command("xcrun");
534 command.args([
535 "devicectl",
536 "device",
537 "process",
538 "launch",
539 "--device",
540 self.selector(),
541 "--environment-variables",
542 &env_json,
543 "--terminate-existing",
544 "--console",
545 &bundle_id,
546 ]);
547 if let Some((_, dev_url)) = options
548 .env_vars()
549 .find(|(key, _)| *key == "WATERUI_DEV_URL")
550 {
551 command.arg(format!("--waterui-dev-url={dev_url}"));
552 }
553 command
554 .stdin(Stdio::null())
555 .stdout(Stdio::piped())
556 .stderr(Stdio::piped());
557 let mut child = command
558 .spawn()
559 .map_err(|error| FailToRun::Launch(eyre!("Failed to launch app: {error}")))?;
560
561 let stdout = child
562 .stdout
563 .take()
564 .expect("stdout is piped for the devicectl console");
565 let stderr = child
566 .stderr
567 .take()
568 .expect("stderr is piped for the devicectl console");
569
570 let (running, sender) = Running::new({
571 let host = host.clone();
572 let selector = self.selector().to_string();
573 move || stop_console_session(child, &host, &selector, &process_name)
574 });
575
576 let (panic_tx, panic_rx) = smol::channel::bounded::<String>(1);
580 let (eof_tx, eof_rx) = smol::channel::bounded::<()>(2);
581
582 spawn(stream_console(
583 smol::Unblock::new(stdout),
584 ConsoleTarget {
585 sender: sender.clone(),
586 eof: eof_tx.clone(),
587 panic: None,
588 is_err: false,
589 },
590 ))
591 .detach();
592 spawn(stream_console(
593 smol::Unblock::new(stderr),
594 ConsoleTarget {
595 sender: sender.clone(),
596 eof: eof_tx,
597 panic: Some(panic_tx),
598 is_err: true,
599 },
600 ))
601 .detach();
602 spawn(classify_exit(eof_rx, panic_rx, sender)).detach();
603
604 Ok(running)
605 }
606
607 async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
608 Self::scan(host).await
609 }
610}
611
612struct ConsoleTarget {
615 sender: Sender<DeviceEvent>,
616 eof: Sender<()>,
617 panic: Option<Sender<String>>,
618 is_err: bool,
619}
620
621async fn stream_console(stream: impl smol::io::AsyncRead + Unpin, target: ConsoleTarget) {
624 let mut lines = BufReader::new(stream).lines();
625 while let Some(Ok(line)) = lines.next().await {
626 if target.is_err
627 && line.contains("panicked at")
628 && let Some(panic) = &target.panic
629 {
630 let _ = panic.try_send(line.clone());
631 }
632 let event = if target.is_err {
633 DeviceEvent::Stderr { message: line }
634 } else {
635 DeviceEvent::Stdout { message: line }
636 };
637 if target.sender.try_send(event).is_err() {
638 break;
639 }
640 }
641 let _ = target.eof.try_send(());
642}
643
644async fn classify_exit(
647 eof_rx: smol::channel::Receiver<()>,
648 panic_rx: smol::channel::Receiver<String>,
649 sender: Sender<DeviceEvent>,
650) {
651 let _ = eof_rx.recv().await;
652 let _ = eof_rx.recv().await;
653 let event = panic_rx.try_recv().map_or_else(
654 |_| DeviceEvent::Exited(ApplicationExit::user_closed()),
655 DeviceEvent::Crashed,
656 );
657 let _ = sender.try_send(event);
658}
659
660#[cfg(test)]
661mod tests {
662 use super::{ApplePhysicalDevice, DeviceUnusable, Transport, TunnelState, environment_json};
663 use crate::device::Device as _;
664
665 const DEVICE_LIST_JSON: &str = include_str!("physical_list_sample.json");
666
667 #[test]
668 fn parses_paired_iphone() {
669 let devices = ApplePhysicalDevice::parse_list(DEVICE_LIST_JSON).expect("parse list");
670 assert_eq!(devices.len(), 1);
671 let device = &devices[0];
672 assert_eq!(device.identifier, "898E9834-79A1-5EAD-AA1A-C54E27F04456");
673 assert_eq!(device.udid, "00008140-00011C210CF3001C");
674 assert_eq!(device.name(), "Lexo’s iPhone 16 Pro");
675 assert_eq!(device.marketing_name.as_deref(), Some("iPhone 16 Pro"));
676 assert_eq!(device.transport, Transport::Wired);
677 assert_eq!(device.tunnel_state, TunnelState::Disconnected);
678 assert!(device.developer_mode_enabled);
679 assert!(device.usability().is_ok());
680 }
681
682 #[test]
683 fn booted_simulator_is_not_a_physical_device() {
684 let devices = ApplePhysicalDevice::parse_list(DEVICE_LIST_JSON).expect("parse list");
685 assert!(
686 devices
687 .iter()
688 .all(|device| device.udid != "3C6AEFDA-0324-4C6E-9352-4A2DAF059AF0"),
689 "the simulated iPhone 17 entry must stay out of the physical device list"
690 );
691 }
692
693 #[test]
694 fn unavailable_tunnel_is_unusable() {
695 let mut devices = ApplePhysicalDevice::parse_list(DEVICE_LIST_JSON).expect("parse list");
696 devices[0].tunnel_state = TunnelState::Unavailable;
697 assert_eq!(devices[0].usability(), Err(DeviceUnusable::Unreachable));
698 }
699
700 #[test]
701 fn developer_mode_off_is_unusable() {
702 let mut devices = ApplePhysicalDevice::parse_list(DEVICE_LIST_JSON).expect("parse list");
703 devices[0].developer_mode_enabled = false;
704 assert_eq!(
705 devices[0].usability(),
706 Err(DeviceUnusable::DeveloperModeDisabled)
707 );
708 }
709
710 #[test]
711 fn environment_json_encodes_all_vars() {
712 let vars = [
713 ("WATERUI_DEV_URL", "http://10.0.0.2:5173/"),
714 ("WATERUI_LOG", "debug"),
715 ];
716 let json = environment_json(vars.iter().copied());
717 let parsed: serde_json::Value = serde_json::from_str(&json).expect("env json parses");
718 assert_eq!(parsed["WATERUI_DEV_URL"], "http://10.0.0.2:5173/");
719 assert_eq!(parsed["WATERUI_LOG"], "debug");
720 }
721}