Skip to main content

waterui_cli/apple/
physical.rs

1//! Physical Apple devices (iPhone, iPad) reachable through CoreDevice.
2//!
3//! Discovery goes through `xcrun devicectl list devices --json-output -`;
4//! install/launch go through `devicectl device install app` and
5//! `devicectl device process launch`. Both USB and Wi-Fi ("Connect via
6//! network") transports are transparent to `devicectl` — the
7//! `connectionProperties.transportType` field reports which one a paired
8//! device is currently reachable over.
9
10use 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/// A physical Apple device paired with this Mac (iPhone or iPad).
33///
34/// `devicectl` accepts any of `identifier` (the `CoreDevice` UUID), `udid`,
35/// `ecid`, or `name` as its `--device` selector; the `CoreDevice` identifier is
36/// the most stable across reboots and reconnects, so it is what
37/// [`Self::selector`] returns and what `water run --device` should be given.
38#[derive(Debug, Clone)]
39pub struct ApplePhysicalDevice {
40    /// `CoreDevice` identifier (e.g. `898E9834-79A1-5EAD-AA1A-C54E27F04456`).
41    pub identifier: String,
42    /// Hardware UDID (e.g. `00008140-00011C210CF3001C`).
43    pub udid: String,
44    /// User-assigned device name.
45    pub name: String,
46    /// Marketing name from `hardwareProperties` (e.g. `iPhone 16 Pro`).
47    pub marketing_name: Option<String>,
48    /// OS version running on the device (e.g. iOS `27.0`).
49    pub os_version: Option<Version>,
50    /// How the device is currently reachable (`wired` or `localNetwork`).
51    pub transport: Transport,
52    /// `CoreDevice` tunnel state (`connected`, `disconnected`, `unavailable`).
53    ///
54    /// `disconnected` is not a problem: device commands establish the tunnel
55    /// on demand. `unavailable` means the device cannot be reached at all.
56    pub tunnel_state: TunnelState,
57    /// `deviceProperties.developerModeStatus` — must be `enabled` to run
58    /// development-signed apps.
59    pub developer_mode_enabled: bool,
60    /// `deviceProperties.bootState` — the device is usable when `booted`.
61    pub boot_state: String,
62}
63
64/// How a paired device is connected to this Mac.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Transport {
67    /// USB or Thunderbolt cable.
68    Wired,
69    /// "Connect via network" — the device is reachable over the LAN.
70    LocalNetwork,
71    /// `devicectl` reported a transport this build does not name.
72    Other,
73}
74
75/// `CoreDevice` tunnel reachability state.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum TunnelState {
78    /// The `CoreDevice` tunnel is up.
79    Connected,
80    /// Paired and reachable; the tunnel is established on demand.
81    Disconnected,
82    /// The device cannot be reached (unpaired, offline, or locked out).
83    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    /// iPhones and iPads are the devices `water run` can target.
136    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    /// Parse the `devicectl list devices` JSON output into physical iOS
146    /// devices.
147    ///
148    /// Every paired iPhone/iPad is returned — including ones whose tunnel is
149    /// currently `disconnected` (they come up on demand) — so callers can
150    /// surface them in pickers and in `water devices`. Unpaired entries are
151    /// dropped: `devicectl` cannot act on them at all.
152    ///
153    /// # Errors
154    /// Returns an error when the JSON cannot be parsed.
155    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    /// Enumerate paired iOS devices through `devicectl`.
214    ///
215    /// # Errors
216    /// Returns an error when `devicectl` fails or its output cannot be parsed.
217    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    /// The value `devicectl --device` selects this device by.
235    #[must_use]
236    pub fn selector(&self) -> &str {
237        &self.identifier
238    }
239
240    /// Whether `water run` can put an app on this device right now.
241    ///
242    /// A disconnected tunnel is not a failure — commands bring it up — but an
243    /// `unavailable` tunnel, an unbooted device, or Developer Mode being off
244    /// each make the device unusable and each has a different remedy, so the
245    /// reasons stay distinct for diagnostics.
246    ///
247    /// # Errors
248    /// Returns the [`DeviceUnusable`] reason whose `remedy` text names the fix.
249    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    /// Whether the device's OS can run an app requiring `deployment_target`.
263    ///
264    /// A device whose OS version `devicectl` did not report is treated as
265    /// incapable: selection must never pick a device it cannot prove satisfies
266    /// the app's deployment target.
267    #[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/// Why a paired device cannot run an app; each variant maps to the remedy the
276/// error message should name.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum DeviceUnusable {
279    /// The `CoreDevice` tunnel is `unavailable` — the device is off, unplugged
280    /// and off-LAN, or locked out.
281    Unreachable,
282    /// `bootState` is not `booted`.
283    NotBooted,
284    /// Developer Mode is off; development-signed apps cannot launch.
285    DeveloperModeDisabled,
286}
287
288impl DeviceUnusable {
289    /// The user-facing explanation and remedy for this state.
290    #[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
309/// `devicectl device process launch` environment-variable payload.
310///
311/// The `-e` flag takes a JSON-encoded dictionary; every entry of
312/// [`crate::device::RunOptions::env_vars`] travels through it, so
313/// `WATERUI_DEV_URL`, `WATERUI_LOG`, `WATERUI_PROJECT_DIR` and
314/// `WATERUI_APP_NAME` reach the process exactly as `SIMCTL_CHILD_*` delivers
315/// them on a simulator.
316fn 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/// Find the app's pid on the device by its executable name.
360///
361/// `devicectl device info processes` lists remote processes; the one running
362/// our bundle has the app binary's name as the last component of its
363/// executable path.
364#[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/// Send a signal to a spawned child.
410#[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/// Terminate a `--console`-attached devicectl session and the app it drives.
419///
420/// Catchable signals sent to `devicectl --console` are forwarded to the app,
421/// so the graceful path is SIGTERM to our own child. If the app ignores it,
422/// the fallback resolves the remote pid and issues `process terminate
423/// --kill`, then SIGKILLs devicectl itself.
424#[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/// `devicectl` is macOS-only; on other platforms killing the child is the
459/// whole of it.
460#[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        // A physical device needs no boot step — but surface a clear error
478        // for the states that would make `run` fail anyway.
479        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        // `--console` attaches the app's standard streams to devicectl's and
518        // waits for the app to exit: one child gives stdout/stderr streaming,
519        // exit detection, and signal forwarding (a signal to devicectl is
520        // delivered to the app) in a single process. `std::process::Command`,
521        // not `smol`'s: the drop handler waits on it synchronously.
522        //
523        // The dev-server URL travels in the `-e` environment dictionary; a
524        // `--waterui-dev-url=` process argument repeats it, so `dev_url()`
525        // still finds it if a device-side launch path ever strips the
526        // environment.
527        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        // devicectl writes the app's stdout to its stdout and the app's
571        // stderr to its stderr. A panic message on the stderr side is
572        // reported as a crash once the console detaches.
573        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
606/// Output channel for one console pipe: the user's event sender plus the
607/// end-of-file signal the exit classifier waits on.
608struct ConsoleTarget {
609    sender: Sender<DeviceEvent>,
610    eof: Sender<()>,
611    panic: Option<Sender<String>>,
612    is_err: bool,
613}
614
615/// Stream one of devicectl's console pipes into device events, then report
616/// end-of-file so the exit classifier can run once both pipes close.
617async 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
638/// Both console pipes close when devicectl exits; classify the run's end from
639/// whatever the stderr reader captured.
640async 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}