Skip to main content

waterui_cli/workflows/inspector/
launcher.rs

1//! Inspector app launcher and session management.
2
3use std::path::{Path, PathBuf};
4use std::pin::Pin;
5
6use eyre::{Context as _, Result, bail};
7use tracing::info;
8
9use crate::device::{Device, Local, RunOptions, Running};
10use crate::platform::TargetPlatform;
11use crate::project::Project;
12use crate::runtime_compat::runtime_profile_tag;
13use crate::runtime_fingerprint::{compute_runtime_fingerprint, runtime_package_identity};
14use crate::support_app;
15use crate::templates::TemplateContext;
16
17const INSPECTOR_TEMPLATE_COMMIT: &str = env!("WATERUI_CLI_COMMIT");
18const INSPECTOR_METADATA_FILE: &str = ".waterui-inspector-signature";
19
20#[derive(Debug, Clone)]
21struct InspectorRequirements {
22    waterui_path: Option<PathBuf>,
23    runtime_fingerprint: String,
24}
25
26/// Target platform for launching Inspector app.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum InspectorPlatform {
29    /// iOS Simulator.
30    IosSimulator,
31    /// macOS.
32    Macos,
33    /// Android (device or emulator).
34    Android,
35}
36
37/// Launch options for Inspector app.
38#[derive(Debug, Clone)]
39pub struct InspectorLaunchOptions {
40    /// Runtime app endpoint address (`host:port`).
41    pub target_addr: String,
42    /// One-time token used by runtime endpoint and inspector app.
43    pub token: String,
44}
45
46/// Inspector session state.
47#[derive(Debug)]
48pub struct InspectorSession {
49    /// Current platform.
50    pub platform: InspectorPlatform,
51    /// Running handle for apps launched by this session.
52    running: Option<Pin<Box<Running>>>,
53    /// Whether this session owns the app lifecycle.
54    owns_app: bool,
55}
56
57impl InspectorSession {
58    /// Shutdown the inspector app if this session launched it.
59    ///
60    /// # Errors
61    /// This method currently does not return an operational error.
62    pub fn shutdown(&mut self) -> Result<()> {
63        if self.owns_app {
64            self.running.take();
65        }
66        Ok(())
67    }
68
69    /// Detach inspector app so it keeps running after session drop.
70    pub fn detach(&mut self) {
71        if let Some(running) = self.running.take() {
72            std::mem::forget(running);
73            self.owns_app = false;
74        }
75    }
76}
77
78/// Launch (or relaunch) an inspector support app.
79///
80/// # Errors
81/// Returns an error if the support project cannot be prepared or the inspector app fails to launch.
82pub async fn launch_inspector_session(
83    project_path: &Path,
84    platform: InspectorPlatform,
85    options: InspectorLaunchOptions,
86) -> Result<InspectorSession> {
87    let requirements = resolve_inspector_requirements(project_path).await?;
88
89    let inspector_app_path = inspector_support_path()?;
90    ensure_inspector_support_app(&inspector_app_path, &requirements).await?;
91
92    let project = Project::open(&inspector_app_path)
93        .await
94        .wrap_err("Failed to open inspector support app project")?;
95
96    let mut run_options = RunOptions::new();
97    run_options.insert_env_var(
98        "WATERUI_INSPECTOR_TARGET_ADDR".to_string(),
99        options.target_addr.clone(),
100    );
101    run_options.insert_env_var("WATERUI_INSPECTOR_TOKEN".to_string(), options.token.clone());
102
103    let host = crate::toolchain::Host::current();
104    let running = match platform {
105        InspectorPlatform::Macos => {
106            let backend = project
107                .apple_backend()
108                .ok_or_else(|| eyre::eyre!("Apple backend not configured"))?;
109            let device = Local;
110            device.launch(&host).await?;
111            info!("Building and running inspector app on macOS...");
112            project
113                .run_with_options(backend, TargetPlatform::MacOS, device, run_options)
114                .await
115                .map_err(|e| eyre::eyre!("Failed to run inspector app: {e}"))?
116        }
117        InspectorPlatform::IosSimulator => {
118            let backend = project
119                .apple_backend()
120                .ok_or_else(|| eyre::eyre!("Apple backend not configured"))?;
121            let simulator =
122                crate::apple::device::AppleSimulator::select_ios(&host, &project, None).await?;
123
124            simulator.launch(&host).await?;
125            info!("Building and running inspector app on iOS Simulator...");
126            project
127                .run_with_options(
128                    backend,
129                    TargetPlatform::IOSSimulator,
130                    simulator,
131                    run_options,
132                )
133                .await
134                .map_err(|e| eyre::eyre!("Failed to run inspector app: {e}"))?
135        }
136        InspectorPlatform::Android => {
137            let backend = project
138                .android_backend()
139                .ok_or_else(|| eyre::eyre!("Android backend not configured"))?;
140
141            let devices = crate::android::device::AndroidDevice::scan(&host).await?;
142            if let Some(device) = devices.into_iter().next() {
143                device.launch(&host).await?;
144                info!("Building and running inspector app on Android device...");
145                project
146                    .run_android_with_options(backend, device, run_options)
147                    .await
148                    .map_err(|e| eyre::eyre!("Failed to run inspector app: {e}"))?
149            } else {
150                let avds = crate::android::platform::AndroidPlatform::list_avds(&host).await?;
151                let avd_name = avds
152                    .into_iter()
153                    .next()
154                    .ok_or_else(|| eyre::eyre!("No Android devices or emulators available."))?;
155                let emulator =
156                    crate::android::device::AndroidEmulator::open(&host, avd_name).await?;
157                emulator.launch(&host).await?;
158                info!("Building and running inspector app on Android emulator...");
159                project
160                    .run_android_with_options(backend, emulator, run_options)
161                    .await
162                    .map_err(|e| eyre::eyre!("Failed to run inspector app: {e}"))?
163            }
164        }
165    };
166
167    Ok(InspectorSession {
168        platform,
169        running: Some(Box::pin(running)),
170        owns_app: true,
171    })
172}
173
174fn inspector_support_path() -> Result<PathBuf> {
175    support_app::support_app_path("inspector_support")
176}
177
178async fn ensure_inspector_support_app(
179    path: &Path,
180    requirements: &InspectorRequirements,
181) -> Result<()> {
182    let desired_signature = inspector_signature(requirements);
183    let scaffold_path = path.to_path_buf();
184    let scaffold_requirements = requirements.clone();
185    support_app::ensure_support_app(
186        path,
187        INSPECTOR_METADATA_FILE,
188        &desired_signature,
189        "inspector support",
190        move || async move { scaffold_inspector_app(&scaffold_path, &scaffold_requirements).await },
191    )
192    .await
193}
194
195async fn scaffold_inspector_app(path: &Path, requirements: &InspectorRequirements) -> Result<()> {
196    use crate::project::{CreateOptions, Manifest as WaterManifest, PackageType};
197
198    let waterui_path = requirements.waterui_path.clone();
199
200    let options = CreateOptions {
201        name: "WaterUI Inspector".to_string(),
202        bundle_identifier: crate::project_types::BundleIdentifier::try_from(
203            "dev.waterui.inspector",
204        )
205        .expect("inspector support bundle identifier must be valid"),
206        package_type: PackageType::Playground,
207        waterui_path: waterui_path.clone(),
208        channel: None,
209        framework_manifest: None,
210        framework: None,
211        author: String::new(),
212        web: None,
213    };
214
215    let project = Project::create(path, options)
216        .await
217        .map_err(|e| eyre::eyre!("Failed to create inspector app: {e}"))?;
218
219    let mut manifest = WaterManifest::open(project.root().join("Water.toml")).await?;
220    manifest.package.accessory = false;
221    manifest.save(project.root()).await?;
222
223    let ctx = TemplateContext::for_support_playground(
224        "WaterUI Inspector",
225        project.crate_name().clone(),
226        crate::project_types::BundleIdentifier::try_from("dev.waterui.inspector")
227            .expect("inspector support bundle identifier must be valid"),
228        waterui_path,
229        &project.resolved_framework().await?,
230        false,
231        None,
232    );
233
234    crate::templates::inspector::scaffold(project.root(), &ctx)
235        .await
236        .wrap_err("Failed to scaffold embedded inspector app template")?;
237
238    info!("Inspector app scaffolded at {}", path.display());
239    Ok(())
240}
241
242fn inspector_signature(requirements: &InspectorRequirements) -> String {
243    format!(
244        "template_commit={INSPECTOR_TEMPLATE_COMMIT}\nwaterui_dependency={}\nruntime_fingerprint={}\ntemplate_fingerprint={}",
245        requirements.waterui_path.as_ref().map_or_else(
246            || String::from("registry"),
247            |path| path.display().to_string()
248        ),
249        requirements.runtime_fingerprint,
250        crate::templates::inspector::template_fingerprint(),
251    )
252}
253
254async fn resolve_inspector_requirements(project_path: &Path) -> Result<InspectorRequirements> {
255    let current_dir = project_path.to_path_buf();
256    let metadata = smol::unblock(move || {
257        cargo_metadata::MetadataCommand::new()
258            .current_dir(current_dir)
259            .exec()
260    })
261    .await
262    .wrap_err("Failed to resolve user project Cargo metadata for inspector compatibility")?;
263
264    let waterui = select_unique_package(&metadata, "waterui")?;
265    let waterui_core = select_unique_package(&metadata, "waterui-core")?;
266    let runtime_identity = runtime_package_identity(waterui_core);
267
268    let runtime_fingerprint_base = if waterui.source.is_none() {
269        let waterui_root = waterui
270            .manifest_path
271            .as_std_path()
272            .parent()
273            .map(Path::to_path_buf)
274            .ok_or_else(|| eyre::eyre!("Failed to derive waterui package root path"))?;
275        let fingerprint = compute_runtime_fingerprint(&waterui_root, &runtime_identity).await?;
276        return Ok(InspectorRequirements {
277            waterui_path: Some(waterui_root),
278            runtime_fingerprint: format!("{fingerprint}|profile={}", runtime_profile_tag()),
279        });
280    } else {
281        let source = waterui
282            .source
283            .as_ref()
284            .map(ToString::to_string)
285            .expect("registry dependency must have a source");
286        format!("{runtime_identity}:source:{source}")
287    };
288
289    Ok(InspectorRequirements {
290        waterui_path: None,
291        runtime_fingerprint: format!(
292            "{runtime_fingerprint_base}|profile={}",
293            runtime_profile_tag()
294        ),
295    })
296}
297
298fn select_unique_package<'a>(
299    metadata: &'a cargo_metadata::Metadata,
300    name: &str,
301) -> Result<&'a cargo_metadata::Package> {
302    let mut matches = metadata.packages.iter().filter(|p| p.name == name);
303    let first = matches
304        .next()
305        .ok_or_else(|| eyre::eyre!("Could not resolve package `{name}` from metadata"))?;
306    if matches.next().is_some() {
307        bail!(
308            "Multiple `{name}` packages were resolved. Inspector requires a single resolved `{name}` package."
309        );
310    }
311    Ok(first)
312}