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    /// `simulated` on a booted simulator, which Xcode 26's `devicectl` lists
133    /// beside the paired hardware; absent on a physical device.
134    reality: Option<String>,
135}
136
137impl DeviceEntry {
138    /// iPhones and iPads are the devices `water run` can target. A booted
139    /// simulator shows up here too, as a `simulated` device that
140    /// `devicectl` cannot install to; it is the simulator list's, under the
141    /// same UDID.
142    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    /// Parse the `devicectl list devices` JSON output into physical iOS
152    /// devices.
153    ///
154    /// Every paired iPhone/iPad is returned — including ones whose tunnel is
155    /// currently `disconnected` (they come up on demand) — so callers can
156    /// surface them in pickers and in `water devices`. Unpaired entries are
157    /// dropped: `devicectl` cannot act on them at all.
158    ///
159    /// # Errors
160    /// Returns an error when the JSON cannot be parsed.
161    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    /// Enumerate paired iOS devices through `devicectl`.
220    ///
221    /// # Errors
222    /// Returns an error when `devicectl` fails or its output cannot be parsed.
223    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    /// The value `devicectl --device` selects this device by.
241    #[must_use]
242    pub fn selector(&self) -> &str {
243        &self.identifier
244    }
245
246    /// Whether `water run` can put an app on this device right now.
247    ///
248    /// A disconnected tunnel is not a failure — commands bring it up — but an
249    /// `unavailable` tunnel, an unbooted device, or Developer Mode being off
250    /// each make the device unusable and each has a different remedy, so the
251    /// reasons stay distinct for diagnostics.
252    ///
253    /// # Errors
254    /// Returns the [`DeviceUnusable`] reason whose `remedy` text names the fix.
255    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    /// Whether the device's OS can run an app requiring `deployment_target`.
269    ///
270    /// A device whose OS version `devicectl` did not report is treated as
271    /// incapable: selection must never pick a device it cannot prove satisfies
272    /// the app's deployment target.
273    #[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/// Why a paired device cannot run an app; each variant maps to the remedy the
282/// error message should name.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum DeviceUnusable {
285    /// The `CoreDevice` tunnel is `unavailable` — the device is off, unplugged
286    /// and off-LAN, or locked out.
287    Unreachable,
288    /// `bootState` is not `booted`.
289    NotBooted,
290    /// Developer Mode is off; development-signed apps cannot launch.
291    DeveloperModeDisabled,
292}
293
294impl DeviceUnusable {
295    /// The user-facing explanation and remedy for this state.
296    #[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
315/// `devicectl device process launch` environment-variable payload.
316///
317/// The `-e` flag takes a JSON-encoded dictionary; every entry of
318/// [`crate::device::RunOptions::env_vars`] travels through it, so
319/// `WATERUI_DEV_URL`, `WATERUI_LOG`, `WATERUI_PROJECT_DIR` and
320/// `WATERUI_APP_NAME` reach the process exactly as `SIMCTL_CHILD_*` delivers
321/// them on a simulator.
322fn 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/// Find the app's pid on the device by its executable name.
366///
367/// `devicectl device info processes` lists remote processes; the one running
368/// our bundle has the app binary's name as the last component of its
369/// executable path.
370#[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/// Send a signal to a spawned child.
416#[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/// Terminate a `--console`-attached devicectl session and the app it drives.
425///
426/// Catchable signals sent to `devicectl --console` are forwarded to the app,
427/// so the graceful path is SIGTERM to our own child. If the app ignores it,
428/// the fallback resolves the remote pid and issues `process terminate
429/// --kill`, then SIGKILLs devicectl itself.
430#[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/// `devicectl` is macOS-only; on other platforms killing the child is the
465/// whole of it.
466#[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        // A physical device needs no boot step — but surface a clear error
484        // for the states that would make `run` fail anyway.
485        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        // `--console` attaches the app's standard streams to devicectl's and
524        // waits for the app to exit: one child gives stdout/stderr streaming,
525        // exit detection, and signal forwarding (a signal to devicectl is
526        // delivered to the app) in a single process. `std::process::Command`,
527        // not `smol`'s: the drop handler waits on it synchronously.
528        //
529        // The dev-server URL travels in the `-e` environment dictionary; a
530        // `--waterui-dev-url=` process argument repeats it, so `dev_url()`
531        // still finds it if a device-side launch path ever strips the
532        // environment.
533        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        // devicectl writes the app's stdout to its stdout and the app's
577        // stderr to its stderr. A panic message on the stderr side is
578        // reported as a crash once the console detaches.
579        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
612/// Output channel for one console pipe: the user's event sender plus the
613/// end-of-file signal the exit classifier waits on.
614struct ConsoleTarget {
615    sender: Sender<DeviceEvent>,
616    eof: Sender<()>,
617    panic: Option<Sender<String>>,
618    is_err: bool,
619}
620
621/// Stream one of devicectl's console pipes into device events, then report
622/// end-of-file so the exit classifier can run once both pipes close.
623async 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
644/// Both console pipes close when devicectl exits; classify the run's end from
645/// whatever the stderr reader captured.
646async 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}