waterui_cli/
device.rs

1//! Device management and application running utilities for `WaterUI` CLI.
2
3use std::{
4    collections::HashMap,
5    fmt::Debug,
6    path::{Path, PathBuf},
7};
8
9use color_eyre::eyre;
10use smol::{
11    channel::{Receiver, Sender, unbounded},
12    stream::Stream,
13};
14
15use crate::platform::Platform;
16
17/// Minimum log level for streaming device logs.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
19pub enum LogLevel {
20    /// Only errors
21    Error,
22    /// Warnings and errors
23    Warn,
24    /// Info, warnings, and errors
25    #[default]
26    Info,
27    /// Debug and above
28    Debug,
29    /// All logs including verbose
30    Verbose,
31}
32
33impl LogLevel {
34    /// Convert to Android logcat priority character.
35    #[must_use]
36    pub const fn to_android_priority(self) -> char {
37        match self {
38            Self::Error => 'E',
39            Self::Warn => 'W',
40            Self::Info => 'I',
41            Self::Debug => 'D',
42            Self::Verbose => 'V',
43        }
44    }
45
46    /// Convert to iOS/macOS `log stream --level` argument.
47    ///
48    /// Apple's unified logging `log stream --level` accepts: default, info, debug
49    /// - `debug` includes all messages (debug, info, default, error, fault)
50    /// - `info` includes info and above
51    /// - `default` includes default (notice) and above
52    ///
53    /// Since we want to capture errors/warnings, we need at least `default` level.
54    #[must_use]
55    pub const fn to_apple_level(self) -> &'static str {
56        match self {
57            Self::Error | Self::Warn | Self::Info => "default",
58            Self::Debug | Self::Verbose => "debug",
59        }
60    }
61}
62
63/// Options for running an application on a device
64#[derive(Debug, Clone, Default)]
65pub struct RunOptions {
66    /// # Note
67    ///
68    /// Android do not support environment variables yet.
69    /// iOS/macOS support environment variables via `export SIMCTL_CHILD_KEY=Val`
70    ///
71    /// As a workaround, on Android we pass values as Activity intent extras using the
72    /// `waterui.env.<KEY>` namespace, and the app reads them on startup and calls `Os.setenv()`.
73    env_vars: HashMap<String, String>,
74
75    /// If set, stream device logs at or above this level.
76    log_level: Option<LogLevel>,
77}
78
79impl RunOptions {
80    /// Create new run options
81    #[must_use]
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Insert an environment variable to be set when running the application
87    pub fn insert_env_var(&mut self, key: String, value: String) {
88        self.env_vars.insert(key, value);
89    }
90
91    /// Get an iterator over the environment variables
92    pub fn env_vars(&self) -> impl Iterator<Item = (&str, &str)> {
93        self.env_vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
94    }
95
96    /// Set the minimum log level to stream.
97    pub const fn set_log_level(&mut self, level: LogLevel) {
98        self.log_level = Some(level);
99    }
100
101    /// Get the log level if set.
102    #[must_use]
103    pub const fn log_level(&self) -> Option<LogLevel> {
104        self.log_level
105    }
106}
107
108/// Represents a build artifact to be run on a device
109#[derive(Debug)]
110pub struct Artifact {
111    bundle_id: String,
112    path: PathBuf,
113}
114
115impl Artifact {
116    /// Create a new artifact
117    #[must_use]
118    pub fn new(bundle_id: impl Into<String>, path: PathBuf) -> Self {
119        Self {
120            bundle_id: bundle_id.into(),
121            path,
122        }
123    }
124
125    /// Get the bundle identifier of the artifact
126    #[must_use]
127    pub const fn bundle_id(&self) -> &str {
128        self.bundle_id.as_str()
129    }
130
131    /// Get the path to the artifact
132    #[must_use]
133    pub fn path(&self) -> &Path {
134        &self.path
135    }
136}
137
138/// Trait representing a device (e.g., emulator, simulator, physical device)
139pub trait Device: Send {
140    /// Associated platform type for the device.
141    type Platform: Platform;
142    /// Launch the device emulator or simulator.
143    ///
144    /// If the device is a physical device, this should do nothing.
145    fn launch(&self) -> impl Future<Output = eyre::Result<()>> + Send;
146
147    /// Run the given artifact on the device with the specified options.
148    fn run(
149        &self,
150        artifact: Artifact,
151        options: RunOptions,
152    ) -> impl Future<Output = Result<Running, FailToRun>> + Send;
153
154    /// Get the platform this device belongs to.
155    fn platform(&self) -> Self::Platform;
156}
157
158/// Represents a running application on a device.
159///
160/// Drop the `Running` to terminate the application
161pub struct Running {
162    sender: Sender<DeviceEvent>,
163    receiver: Receiver<DeviceEvent>,
164    on_drop: Vec<Box<dyn FnOnce() + Send>>,
165}
166
167impl Debug for Running {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.debug_struct("Running").finish_non_exhaustive()
170    }
171}
172
173impl Running {
174    /// Create a new `Running` instance
175    #[allow(clippy::missing_panics_doc)]
176    pub fn new(on_drop: impl FnOnce() + Send + 'static) -> (Self, Sender<DeviceEvent>) {
177        let (sender, receiver) = unbounded();
178        sender.try_send(DeviceEvent::Started).unwrap(); // `unwrap` is safe here, as we just created the channel
179        (
180            Self {
181                sender: sender.clone(),
182                receiver,
183                on_drop: vec![Box::new(on_drop)],
184            },
185            sender,
186        )
187    }
188
189    /// Retain a value for the lifetime of the `Running` instance.
190    pub fn retain<T: Send + 'static>(&mut self, value: T) {
191        self.on_drop.push(Box::new(move || {
192            drop(value);
193        }));
194    }
195}
196
197impl Stream for Running {
198    type Item = DeviceEvent;
199
200    fn poll_next(
201        self: std::pin::Pin<&mut Self>,
202        cx: &mut std::task::Context<'_>,
203    ) -> std::task::Poll<Option<Self::Item>> {
204        // SAFETY: We only project to the `receiver` field, which is safe to pin
205        // because we never move out of it and the other fields don't affect pinning
206        let receiver = unsafe { &mut self.get_unchecked_mut().receiver };
207        unsafe { std::pin::Pin::new_unchecked(receiver) }.poll_next(cx)
208    }
209}
210
211impl Drop for Running {
212    fn drop(&mut self) {
213        let _ = self.sender.try_send(DeviceEvent::Stopped);
214        for f in self.on_drop.drain(..) {
215            f();
216        }
217    }
218}
219
220/// Errors that can occur when running an application on a device
221#[derive(Debug, thiserror::Error)]
222pub enum FailToRun {
223    /// Invalid artifact provided.
224    #[error("Invalid artifact")]
225    InvalidArtifact,
226
227    /// Failed to install the application on the device.
228    #[error("Failed to install application on device: {0}")]
229    Install(eyre::Report),
230
231    /// Failed to launch the device.
232    #[error("Failed to launch device: {0}")]
233    Launch(eyre::Report),
234    /// Failed to run the application on the device.
235    #[error("Failed to run application on device: {0}")]
236    Run(eyre::Report),
237
238    /// Failed to package the artifacts.
239    #[error("Failed to package the artifacts: {0}")]
240    Package(eyre::Report),
241
242    /// Failed to build the project.
243    #[error("Failed to build the project: {0}")]
244    Build(eyre::Report),
245
246    /// Failed to start hot reload server.
247    #[error("Failed to start hot reload server: {0}")]
248    HotReload(crate::debug::hot_reload::FailToLaunch),
249
250    /// Application crashed.
251    #[error("Application crashed: {0}")]
252    Crashed(String),
253}
254
255/// Events emitted by a running application on a device
256#[derive(Debug)]
257pub enum DeviceEvent {
258    /// Application has started
259    Started,
260    /// Application has stopped by CLI
261    Stopped,
262    /// Standard output from the application
263    Stdout {
264        /// The output message
265        message: String,
266    },
267
268    /// Standard error from the application
269    Stderr {
270        /// The error message
271        message: String,
272    },
273    /// Standard log from the application
274    Log {
275        /// The log level
276        level: tracing::Level,
277        /// The log message
278        message: String,
279    },
280
281    /// Unexpected exit of the application, may triggered by user quitting
282    Exited,
283
284    /// Application crashed with error message
285    Crashed(String),
286}
287
288/// Represents the kind of device
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum DeviceKind {
291    /// Simulator device
292    Simulator,
293    /// Physical device
294    Physical,
295}
296
297/// Represents the state of a device
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum DeviceState {
300    /// Device is booted and ready
301    Booted,
302    /// Device is shutdown
303    Shutdown,
304    /// Device is disconnected (e.g., physical device unplugged)
305    Disconnected,
306}