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}
133
134impl DeviceEntry {
135 fn is_ios_device(&self) -> bool {
137 matches!(
138 self.hardware_properties.device_type.as_deref(),
139 Some("iPhone" | "iPad")
140 )
141 }
142}
143
144impl ApplePhysicalDevice {
145 pub fn parse_list(json: &str) -> eyre::Result<Vec<Self>> {
156 let list: DeviceList =
157 serde_json::from_str(json).wrap_err("failed to parse `devicectl list devices` JSON")?;
158 Ok(list
159 .result
160 .devices
161 .into_iter()
162 .filter(DeviceEntry::is_ios_device)
163 .filter_map(|entry| {
164 if entry.connection_properties.pairing_state.as_deref() != Some("paired") {
165 return None;
166 }
167 let os_version = entry
168 .device_properties
169 .os_version_number
170 .as_deref()
171 .map(|raw| {
172 parse_semver_version(raw).map_err(|error| {
173 tracing::warn!(
174 "device {} reported an unparseable osVersionNumber `{raw}`: {error}",
175 entry.identifier
176 );
177 })
178 })
179 .transpose()
180 .ok()
181 .flatten();
182 Some(Self {
183 identifier: entry.identifier,
184 udid: entry.hardware_properties.udid.unwrap_or_default(),
185 name: entry
186 .device_properties
187 .name
188 .or_else(|| entry.hardware_properties.marketing_name.clone())
189 .unwrap_or_else(|| String::from("iOS device")),
190 marketing_name: entry.hardware_properties.marketing_name,
191 os_version,
192 transport: match entry.connection_properties.transport_type.as_deref() {
193 Some("wired") => Transport::Wired,
194 Some("localNetwork") => Transport::LocalNetwork,
195 _ => Transport::Other,
196 },
197 tunnel_state: match entry.connection_properties.tunnel_state.as_deref() {
198 Some("connected") => TunnelState::Connected,
199 Some("unavailable") => TunnelState::Unavailable,
200 _ => TunnelState::Disconnected,
201 },
202 developer_mode_enabled: entry
203 .device_properties
204 .developer_mode_status
205 .as_deref()
206 == Some("enabled"),
207 boot_state: entry.device_properties.boot_state.unwrap_or_default(),
208 })
209 })
210 .collect())
211 }
212
213 pub async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
218 let output = host
219 .output(
220 "xcrun",
221 ["devicectl", "list", "devices", "--json-output", "-"],
222 )
223 .await
224 .wrap_err("failed to run `devicectl list devices`")?;
225 if !output.status.success() {
226 bail!(
227 "`devicectl list devices` failed: {}",
228 String::from_utf8_lossy(&output.stderr).trim()
229 );
230 }
231 Self::parse_list(&String::from_utf8_lossy(&output.stdout))
232 }
233
234 #[must_use]
236 pub fn selector(&self) -> &str {
237 &self.identifier
238 }
239
240 pub fn usability(&self) -> Result<(), DeviceUnusable> {
250 if matches!(self.tunnel_state, TunnelState::Unavailable) {
251 return Err(DeviceUnusable::Unreachable);
252 }
253 if self.boot_state != "booted" {
254 return Err(DeviceUnusable::NotBooted);
255 }
256 if !self.developer_mode_enabled {
257 return Err(DeviceUnusable::DeveloperModeDisabled);
258 }
259 Ok(())
260 }
261
262 #[must_use]
268 pub fn supports_deployment_target(&self, deployment_target: &Version) -> bool {
269 self.os_version
270 .as_ref()
271 .is_some_and(|os| os >= deployment_target)
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum DeviceUnusable {
279 Unreachable,
282 NotBooted,
284 DeveloperModeDisabled,
286}
287
288impl DeviceUnusable {
289 #[must_use]
291 pub fn remedy(self, device: &ApplePhysicalDevice) -> String {
292 match self {
293 Self::Unreachable => format!(
294 "{} is paired but unreachable. Unlock it and check the USB cable, \
295 or enable “Connect via network” in Xcode → Devices and Simulators \
296 while the iPhone and this Mac share a LAN.",
297 device.name
298 ),
299 Self::NotBooted => format!("{} is not booted.", device.name),
300 Self::DeveloperModeDisabled => format!(
301 "Developer Mode is disabled on {}. Enable it in \
302 Settings → Privacy & Security → Developer Mode, then restart the device.",
303 device.name
304 ),
305 }
306 }
307}
308
309fn environment_json<'a>(env_vars: impl Iterator<Item = (&'a str, &'a str)>) -> String {
317 let map: serde_json::Map<String, serde_json::Value> = env_vars
318 .map(|(key, value)| {
319 (
320 key.to_string(),
321 serde_json::Value::String(value.to_string()),
322 )
323 })
324 .collect();
325 serde_json::Value::Object(map).to_string()
326}
327
328async fn install_device_app(
329 host: &Host,
330 selector: &str,
331 artifact_path: &Path,
332) -> Result<(), FailToRun> {
333 let output = host
334 .command("xcrun")
335 .args([
336 "devicectl",
337 "device",
338 "install",
339 "app",
340 "--device",
341 selector,
342 ])
343 .arg(artifact_path)
344 .stdout(Stdio::piped())
345 .stderr(Stdio::piped())
346 .output()
347 .await
348 .map_err(|error| FailToRun::Install(eyre!("Failed to install app: {error}")))?;
349 if output.status.success() {
350 return Ok(());
351 }
352 Err(FailToRun::Install(eyre!(
353 "Failed to install app on the device:\n{}\n{}",
354 String::from_utf8_lossy(&output.stdout).trim(),
355 String::from_utf8_lossy(&output.stderr).trim(),
356 )))
357}
358
359#[cfg(unix)]
365fn find_remote_pid(host: &Host, selector: &str, process_name: &str) -> Option<u32> {
366 #[derive(Deserialize)]
367 struct ProcessList {
368 result: ProcessListResult,
369 }
370 #[derive(Deserialize)]
371 #[serde(rename_all = "camelCase")]
372 struct ProcessListResult {
373 #[serde(default)]
374 running_processes: Vec<RemoteProcess>,
375 }
376 #[derive(Deserialize)]
377 #[serde(rename_all = "camelCase")]
378 struct RemoteProcess {
379 executable: String,
380 process_identifier: u32,
381 }
382
383 let output = host
384 .std_command("xcrun")
385 .args([
386 "devicectl",
387 "device",
388 "info",
389 "processes",
390 "--device",
391 selector,
392 "--json-output",
393 "-",
394 ])
395 .output()
396 .ok()?;
397 if !output.status.success() {
398 return None;
399 }
400 let list: ProcessList = serde_json::from_slice(&output.stdout).ok()?;
401 let suffix = format!("/{process_name}");
402 list.result
403 .running_processes
404 .into_iter()
405 .find(|process| process.executable.ends_with(&suffix))
406 .map(|process| process.process_identifier)
407}
408
409#[cfg(unix)]
411fn signal_child(child: &std::process::Child, signal: nix::sys::signal::Signal) {
412 let pid = nix::unistd::Pid::from_raw(
413 i32::try_from(child.id()).expect("process identifiers fit in i32"),
414 );
415 let _ = nix::sys::signal::kill(pid, signal);
416}
417
418#[cfg(unix)]
425fn stop_console_session(
426 mut child: std::process::Child,
427 host: &Host,
428 selector: &str,
429 process_name: &str,
430) {
431 signal_child(&child, nix::sys::signal::Signal::SIGTERM);
432 for _ in 0..40 {
433 if matches!(child.try_wait(), Ok(Some(_))) {
434 return;
435 }
436 std::thread::sleep(Duration::from_millis(50));
437 }
438 if let Some(pid) = find_remote_pid(host, selector, process_name) {
439 let _ = host
440 .std_command("xcrun")
441 .args([
442 "devicectl",
443 "device",
444 "process",
445 "terminate",
446 "--device",
447 selector,
448 "--kill",
449 "--pid",
450 &pid.to_string(),
451 ])
452 .output();
453 }
454 signal_child(&child, nix::sys::signal::Signal::SIGKILL);
455 let _ = child.wait();
456}
457
458#[cfg(not(unix))]
461fn stop_console_session(
462 mut child: std::process::Child,
463 _host: &Host,
464 _selector: &str,
465 _process_name: &str,
466) {
467 let _ = child.kill();
468 let _ = child.wait();
469}
470
471impl Device for ApplePhysicalDevice {
472 fn name(&self) -> &str {
473 &self.name
474 }
475
476 fn launch(&self, _host: &Host) -> impl Future<Output = eyre::Result<()>> + Send {
477 std::future::ready(
480 self.usability()
481 .map_err(|reason| eyre!("{}", reason.remedy(self))),
482 )
483 }
484
485 async fn run(
486 &self,
487 host: &Host,
488 artifact: Artifact,
489 options: crate::device::RunOptions,
490 ) -> Result<Running, FailToRun> {
491 if let Err(reason) = self.usability() {
492 return Err(FailToRun::Run(eyre!("{}", reason.remedy(self))));
493 }
494
495 info!(
496 "Installing {} on {} ({})",
497 artifact.bundle_id(),
498 self.name,
499 self.identifier
500 );
501 install_device_app(host, self.selector(), artifact.path()).await?;
502
503 let env_json = environment_json(options.env_vars());
504 let bundle_id = artifact.bundle_id().to_string();
505 let process_name = artifact
506 .path()
507 .file_stem()
508 .and_then(|stem| stem.to_str())
509 .ok_or_else(|| {
510 FailToRun::Run(eyre!(
511 "Artifact path has no UTF-8 filename: {}",
512 artifact.path().display()
513 ))
514 })?
515 .to_string();
516
517 let mut command = host.std_command("xcrun");
528 command.args([
529 "devicectl",
530 "device",
531 "process",
532 "launch",
533 "--device",
534 self.selector(),
535 "--environment-variables",
536 &env_json,
537 "--terminate-existing",
538 "--console",
539 &bundle_id,
540 ]);
541 if let Some((_, dev_url)) = options
542 .env_vars()
543 .find(|(key, _)| *key == "WATERUI_DEV_URL")
544 {
545 command.arg(format!("--waterui-dev-url={dev_url}"));
546 }
547 command
548 .stdin(Stdio::null())
549 .stdout(Stdio::piped())
550 .stderr(Stdio::piped());
551 let mut child = command
552 .spawn()
553 .map_err(|error| FailToRun::Launch(eyre!("Failed to launch app: {error}")))?;
554
555 let stdout = child
556 .stdout
557 .take()
558 .expect("stdout is piped for the devicectl console");
559 let stderr = child
560 .stderr
561 .take()
562 .expect("stderr is piped for the devicectl console");
563
564 let (running, sender) = Running::new({
565 let host = host.clone();
566 let selector = self.selector().to_string();
567 move || stop_console_session(child, &host, &selector, &process_name)
568 });
569
570 let (panic_tx, panic_rx) = smol::channel::bounded::<String>(1);
574 let (eof_tx, eof_rx) = smol::channel::bounded::<()>(2);
575
576 spawn(stream_console(
577 smol::Unblock::new(stdout),
578 ConsoleTarget {
579 sender: sender.clone(),
580 eof: eof_tx.clone(),
581 panic: None,
582 is_err: false,
583 },
584 ))
585 .detach();
586 spawn(stream_console(
587 smol::Unblock::new(stderr),
588 ConsoleTarget {
589 sender: sender.clone(),
590 eof: eof_tx,
591 panic: Some(panic_tx),
592 is_err: true,
593 },
594 ))
595 .detach();
596 spawn(classify_exit(eof_rx, panic_rx, sender)).detach();
597
598 Ok(running)
599 }
600
601 async fn scan(host: &Host) -> eyre::Result<Vec<Self>> {
602 Self::scan(host).await
603 }
604}
605
606struct ConsoleTarget {
609 sender: Sender<DeviceEvent>,
610 eof: Sender<()>,
611 panic: Option<Sender<String>>,
612 is_err: bool,
613}
614
615async fn stream_console(stream: impl smol::io::AsyncRead + Unpin, target: ConsoleTarget) {
618 let mut lines = BufReader::new(stream).lines();
619 while let Some(Ok(line)) = lines.next().await {
620 if target.is_err
621 && line.contains("panicked at")
622 && let Some(panic) = &target.panic
623 {
624 let _ = panic.try_send(line.clone());
625 }
626 let event = if target.is_err {
627 DeviceEvent::Stderr { message: line }
628 } else {
629 DeviceEvent::Stdout { message: line }
630 };
631 if target.sender.try_send(event).is_err() {
632 break;
633 }
634 }
635 let _ = target.eof.try_send(());
636}
637
638async fn classify_exit(
641 eof_rx: smol::channel::Receiver<()>,
642 panic_rx: smol::channel::Receiver<String>,
643 sender: Sender<DeviceEvent>,
644) {
645 let _ = eof_rx.recv().await;
646 let _ = eof_rx.recv().await;
647 let event = panic_rx.try_recv().map_or_else(
648 |_| DeviceEvent::Exited(ApplicationExit::user_closed()),
649 DeviceEvent::Crashed,
650 );
651 let _ = sender.try_send(event);
652}
653
654#[cfg(test)]
655mod tests {
656 use super::{ApplePhysicalDevice, DeviceUnusable, Transport, TunnelState, environment_json};
657 use crate::device::Device as _;
658
659 const DEVICE_LIST_JSON: &str = include_str!("physical_list_sample.json");
660
661 #[test]
662 fn parses_paired_iphone() {
663 let devices = ApplePhysicalDevice::parse_list(DEVICE_LIST_JSON).expect("parse list");
664 assert_eq!(devices.len(), 1);
665 let device = &devices[0];
666 assert_eq!(device.identifier, "898E9834-79A1-5EAD-AA1A-C54E27F04456");
667 assert_eq!(device.udid, "00008140-00011C210CF3001C");
668 assert_eq!(device.name(), "Lexo’s iPhone 16 Pro");
669 assert_eq!(device.marketing_name.as_deref(), Some("iPhone 16 Pro"));
670 assert_eq!(device.transport, Transport::Wired);
671 assert_eq!(device.tunnel_state, TunnelState::Disconnected);
672 assert!(device.developer_mode_enabled);
673 assert!(device.usability().is_ok());
674 }
675
676 #[test]
677 fn unavailable_tunnel_is_unusable() {
678 let mut devices = ApplePhysicalDevice::parse_list(DEVICE_LIST_JSON).expect("parse list");
679 devices[0].tunnel_state = TunnelState::Unavailable;
680 assert_eq!(devices[0].usability(), Err(DeviceUnusable::Unreachable));
681 }
682
683 #[test]
684 fn developer_mode_off_is_unusable() {
685 let mut devices = ApplePhysicalDevice::parse_list(DEVICE_LIST_JSON).expect("parse list");
686 devices[0].developer_mode_enabled = false;
687 assert_eq!(
688 devices[0].usability(),
689 Err(DeviceUnusable::DeveloperModeDisabled)
690 );
691 }
692
693 #[test]
694 fn environment_json_encodes_all_vars() {
695 let vars = [
696 ("WATERUI_DEV_URL", "http://10.0.0.2:5173/"),
697 ("WATERUI_LOG", "debug"),
698 ];
699 let json = environment_json(vars.iter().copied());
700 let parsed: serde_json::Value = serde_json::from_str(&json).expect("env json parses");
701 assert_eq!(parsed["WATERUI_DEV_URL"], "http://10.0.0.2:5173/");
702 assert_eq!(parsed["WATERUI_LOG"], "debug");
703 }
704}