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