Skip to main content

fission_command_run/
lib.rs

1pub mod doctor;
2
3use anyhow::{bail, Context, Result};
4use fission_command_core::{ios_executable_name, read_project_config, FissionProject, Target};
5use serde::Serialize;
6use std::env;
7use std::fs::{self, File, OpenOptions};
8use std::io::{self, IsTerminal, Read, Seek, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::time::{Duration, Instant};
12
13#[derive(Clone, Debug, Serialize)]
14pub struct Device {
15    pub id: String,
16    pub name: String,
17    pub target: Target,
18    pub kind: String,
19    pub status: String,
20    pub detail: String,
21    pub available: bool,
22}
23
24#[derive(Clone, Debug)]
25pub struct RunOptions {
26    pub project_dir: PathBuf,
27    pub target: Option<Target>,
28    pub device: Option<String>,
29    pub detach: bool,
30    pub release: bool,
31    pub host: String,
32    pub port: u16,
33    pub no_open: bool,
34    pub headless: bool,
35}
36
37#[derive(Clone, Debug)]
38pub struct BuildOptions {
39    pub project_dir: PathBuf,
40    pub target: Option<Target>,
41    pub release: bool,
42}
43
44#[derive(Clone, Debug)]
45pub struct TestOptions {
46    pub project_dir: PathBuf,
47    pub target: Option<Target>,
48    pub headless: bool,
49}
50
51#[derive(Clone, Debug)]
52pub struct LogOptions {
53    pub project_dir: PathBuf,
54    pub target: Option<Target>,
55    pub device: Option<String>,
56    pub follow: bool,
57}
58
59#[derive(Clone, Debug)]
60pub struct ServeWebOptions {
61    pub project_dir: PathBuf,
62    pub host: String,
63    pub port: u16,
64    pub open: bool,
65}
66
67pub fn list_devices(project_dir: &Path, json: bool) -> Result<()> {
68    let devices = discover_devices(project_dir);
69    if json {
70        println!("{}", serde_json::to_string_pretty(&devices)?);
71        return Ok(());
72    }
73
74    println!("Fission devices");
75    if devices.is_empty() {
76        println!(
77            "No runnable devices detected. Run `fission doctor web ios android --project-dir {}`.",
78            project_dir.display()
79        );
80        return Ok(());
81    }
82
83    let id_width = devices
84        .iter()
85        .map(|device| device.id.len())
86        .max()
87        .unwrap_or(2)
88        .max(2);
89    let target_width = devices
90        .iter()
91        .map(|device| device.target.as_str().len())
92        .max()
93        .unwrap_or(6)
94        .max(6);
95    println!(
96        "{:<id_width$}  {:<target_width$}  {:<16}  {:<12}  {}",
97        "ID",
98        "TARGET",
99        "KIND",
100        "STATUS",
101        "NAME",
102        id_width = id_width,
103        target_width = target_width
104    );
105    for device in devices {
106        println!(
107            "{:<id_width$}  {:<target_width$}  {:<16}  {:<12}  {}{}",
108            device.id,
109            device.target.as_str(),
110            device.kind,
111            device.status,
112            device.name,
113            if device.detail.is_empty() {
114                String::new()
115            } else {
116                format!(" ({})", device.detail)
117            },
118            id_width = id_width,
119            target_width = target_width
120        );
121    }
122    Ok(())
123}
124
125pub fn run_app(options: RunOptions) -> Result<()> {
126    let project = read_project_config(&options.project_dir)?;
127    let device = select_device(
128        &options.project_dir,
129        options.target,
130        options.device.as_deref(),
131    )?;
132    ensure_target_configured(&project, &options.project_dir, device.target)?;
133
134    match device.target {
135        Target::Linux | Target::Macos | Target::Windows => run_desktop(&options, &device),
136        Target::Web => run_web(&options, &device),
137        Target::Site => site_serve(
138            &options.project_dir,
139            options.release,
140            options.host,
141            options.port,
142            !options.no_open,
143        ),
144        Target::Ios => run_ios(&project, &options, &device),
145        Target::Android => run_android(&project, &options, &device),
146    }
147}
148
149pub fn build_app(options: BuildOptions) -> Result<()> {
150    let project = read_project_config(&options.project_dir)?;
151    let target = options.target.unwrap_or_else(host_desktop_target);
152    ensure_target_configured(&project, &options.project_dir, target)?;
153
154    match target {
155        Target::Linux | Target::Macos | Target::Windows => {
156            require_desktop_host(target)?;
157            build_desktop(&options.project_dir, options.release)
158        }
159        Target::Web => build_web(&options.project_dir, options.release),
160        Target::Site => site_build(&options.project_dir, options.release),
161        Target::Ios => {
162            require_host(Target::Ios)?;
163            let script = options.project_dir.join("platforms/ios/package-sim.sh");
164            let mut command = command_for_script(&script)?;
165            command.current_dir(&options.project_dir);
166            if options.release {
167                command.env("IOS_SIM_PROFILE", "release");
168            }
169            run_status(&mut command, "iOS build")
170        }
171        Target::Android => {
172            let apk = package_android(&options.project_dir, options.release)?;
173            println!("{}", apk.display());
174            Ok(())
175        }
176    }
177}
178
179pub fn test_app(options: TestOptions) -> Result<()> {
180    let project = read_project_config(&options.project_dir)?;
181    let target = options.target.unwrap_or_else(host_desktop_target);
182    ensure_target_configured(&project, &options.project_dir, target)?;
183
184    match target {
185        Target::Linux | Target::Macos | Target::Windows => {
186            require_desktop_host(target)?;
187            let mut command = Command::new("cargo");
188            command.arg("test").current_dir(&options.project_dir);
189            run_status(&mut command, "desktop tests")
190        }
191        Target::Web => run_target_script(
192            &options.project_dir,
193            "platforms/web/test-browser.sh",
194            |_| {},
195        ),
196        Target::Site => site_check(&options.project_dir, false),
197        Target::Ios => {
198            require_host(Target::Ios)?;
199            run_target_script(
200                &options.project_dir,
201                "platforms/ios/test-sim.sh",
202                |command| {
203                    if options.headless {
204                        command.env("IOS_SIM_HEADLESS", "1");
205                    }
206                },
207            )
208        }
209        Target::Android => run_target_script(
210            &options.project_dir,
211            "platforms/android/test-emulator.sh",
212            |command| {
213                if options.headless {
214                    command.env("ANDROID_EMULATOR_HEADLESS", "1");
215                }
216            },
217        ),
218    }
219}
220
221pub fn attach_logs(options: LogOptions) -> Result<()> {
222    let project = read_project_config(&options.project_dir)?;
223    let device = select_device(
224        &options.project_dir,
225        options.target,
226        options.device.as_deref(),
227    )?;
228    match device.target {
229        Target::Android => attach_android_logs(&project, &device, options.follow),
230        Target::Ios => attach_ios_logs(&project, &device, options.follow),
231        Target::Web => tail_log_file(
232            &detached_log_path(&options.project_dir, "web"),
233            options.follow,
234        ),
235        Target::Site => tail_log_file(
236            &detached_log_path(&options.project_dir, "site"),
237            options.follow,
238        ),
239        Target::Linux | Target::Macos | Target::Windows => tail_log_file(
240            &detached_log_path(&options.project_dir, "desktop"),
241            options.follow,
242        ),
243    }
244}
245
246pub fn serve_web(options: ServeWebOptions) -> Result<()> {
247    fission_command_site::serve_static(
248        options.project_dir,
249        options.host,
250        options.port,
251        options.open,
252    )
253}
254
255pub fn site_build(project_dir: &Path, release: bool) -> Result<()> {
256    fission_command_site::build(project_dir, release)
257}
258
259pub fn site_check(project_dir: &Path, release: bool) -> Result<()> {
260    fission_command_site::check(project_dir, release)
261}
262
263pub fn site_routes(project_dir: &Path) -> Result<()> {
264    fission_command_site::routes(project_dir)
265}
266
267pub fn site_serve(
268    project_dir: &Path,
269    release: bool,
270    host: String,
271    port: u16,
272    open: bool,
273) -> Result<()> {
274    fission_command_site::serve(project_dir, release, host, port, open)
275}
276
277pub fn discover_devices(_project_dir: &Path) -> Vec<Device> {
278    let mut devices = Vec::new();
279    devices.push(Device {
280        id: "desktop".to_string(),
281        name: desktop_name().to_string(),
282        target: host_desktop_target(),
283        kind: "desktop".to_string(),
284        status: "available".to_string(),
285        detail: current_os_detail(),
286        available: true,
287    });
288
289    devices.push(if let Some(chrome) = detect_chrome() {
290        Device {
291            id: "chrome".to_string(),
292            name: "Chrome/Chromium".to_string(),
293            target: Target::Web,
294            kind: "browser".to_string(),
295            status: "available".to_string(),
296            detail: chrome.display().to_string(),
297            available: true,
298        }
299    } else {
300        Device {
301            id: "web-server".to_string(),
302            name: "Local web server".to_string(),
303            target: Target::Web,
304            kind: "web-server".to_string(),
305            status: "available".to_string(),
306            detail: "Chrome/Chromium was not auto-detected".to_string(),
307            available: true,
308        }
309    });
310
311    devices.extend(discover_ios_simulators());
312    devices.extend(discover_android_devices());
313    devices.push(Device {
314        id: "site".to_string(),
315        name: "Static site".to_string(),
316        target: Target::Site,
317        kind: "site-server".to_string(),
318        status: "available".to_string(),
319        detail: "multi-page static output".to_string(),
320        available: true,
321    });
322    devices
323}
324
325fn select_device(
326    project_dir: &Path,
327    target: Option<Target>,
328    query: Option<&str>,
329) -> Result<Device> {
330    let devices = discover_devices(project_dir)
331        .into_iter()
332        .filter(|device| target.map(|target| target == device.target).unwrap_or(true))
333        .collect::<Vec<_>>();
334
335    if let Some(query) = query {
336        let query_lower = query.to_ascii_lowercase();
337        let mut matches = devices
338            .iter()
339            .filter(|device| {
340                device.id.eq_ignore_ascii_case(query)
341                    || device.id.to_ascii_lowercase().starts_with(&query_lower)
342                    || device.name.eq_ignore_ascii_case(query)
343                    || device.name.to_ascii_lowercase().contains(&query_lower)
344                    || device.target.as_str() == query_lower
345            })
346            .cloned()
347            .collect::<Vec<_>>();
348        if matches.iter().any(|device| !device.available) {
349            matches.retain(|device| device.available);
350            if matches.is_empty() {
351                bail!("device selector `{query}` matched a device that is not currently runnable");
352            }
353        }
354        return match matches.len() {
355            0 => bail!(
356                "no device matched `{query}`; run `fission devices --project-dir {}`",
357                project_dir.display()
358            ),
359            1 => Ok(matches[0].clone()),
360            _ => {
361                bail!("device selector `{query}` matched multiple devices; use an exact device id")
362            }
363        };
364    }
365
366    let devices = devices
367        .into_iter()
368        .filter(|device| device.available)
369        .collect::<Vec<_>>();
370
371    if devices.is_empty() {
372        bail!(
373            "no runnable devices detected; run `fission devices --project-dir {}`",
374            project_dir.display()
375        );
376    }
377    if devices.len() == 1 {
378        return Ok(devices[0].clone());
379    }
380
381    if !io::stdin().is_terminal() {
382        print_device_choices(&devices);
383        bail!("multiple devices are available; pass `--device <id>` or `--target <target>`");
384    }
385
386    print_device_choices(&devices);
387    print!("Select a device: ");
388    io::stdout().flush()?;
389    let mut line = String::new();
390    io::stdin().read_line(&mut line)?;
391    let index = line
392        .trim()
393        .parse::<usize>()
394        .context("expected a numbered device selection")?;
395    if index == 0 || index > devices.len() {
396        bail!("device selection {index} is out of range");
397    }
398    Ok(devices[index - 1].clone())
399}
400
401fn print_device_choices(devices: &[Device]) {
402    println!("Available devices:");
403    for (idx, device) in devices.iter().enumerate() {
404        println!(
405            "  {}) {} [{}:{}] - {}",
406            idx + 1,
407            device.name,
408            device.target.as_str(),
409            device.id,
410            device.status
411        );
412    }
413}
414
415fn ensure_target_configured(
416    project: &FissionProject,
417    project_dir: &Path,
418    target: Target,
419) -> Result<()> {
420    if !project.targets.contains(&target) {
421        bail!(
422            "target `{}` is not configured for this app; run `fission add-target {} --project-dir {}`",
423            target.as_str(),
424            target.as_str(),
425            project_dir.display()
426        );
427    }
428    let scaffold = project_dir.join(target.scaffold_relative_path());
429    if !scaffold.exists() {
430        bail!(
431            "target `{}` scaffold is missing at {}; run `fission add-target {} --project-dir {}`",
432            target.as_str(),
433            scaffold.display(),
434            target.as_str(),
435            project_dir.display()
436        );
437    }
438    Ok(())
439}
440
441fn run_desktop(options: &RunOptions, _device: &Device) -> Result<()> {
442    let mut command = Command::new("cargo");
443    command.arg("run").current_dir(&options.project_dir);
444    if options.release {
445        command.arg("--release");
446    }
447    run_child(
448        command,
449        options.detach,
450        detached_log_path(&options.project_dir, "desktop"),
451    )
452}
453
454fn run_web(options: &RunOptions, _device: &Device) -> Result<()> {
455    build_web(&options.project_dir, options.release)?;
456    let open = !options.no_open;
457    if options.detach {
458        let log_path = detached_log_path(&options.project_dir, "web");
459        let log = open_log(&log_path)?;
460        let mut command = Command::new(env::current_exe()?);
461        command
462            .arg("serve-web")
463            .arg("--project-dir")
464            .arg(&options.project_dir)
465            .arg("--host")
466            .arg(&options.host)
467            .arg("--port")
468            .arg(options.port.to_string());
469        if open {
470            command.arg("--open");
471        }
472        let err = log.try_clone()?;
473        let child = command
474            .stdout(Stdio::from(log))
475            .stderr(Stdio::from(err))
476            .spawn()?;
477        println!(
478            "Started web server pid {} at {}. Logs: {}",
479            child.id(),
480            format!("http://{}:{}/platforms/web/", options.host, options.port),
481            log_path.display()
482        );
483        return Ok(());
484    }
485
486    fission_command_site::serve_static(
487        options.project_dir.clone(),
488        options.host.clone(),
489        options.port,
490        open,
491    )
492}
493
494fn run_ios(project: &FissionProject, options: &RunOptions, device: &Device) -> Result<()> {
495    require_host(Target::Ios)?;
496    let script = options.project_dir.join("platforms/ios/run-sim.sh");
497    let mut command = command_for_script(&script)?;
498    command.current_dir(&options.project_dir);
499    if device.kind == "ios-simulator" {
500        command.env("IOS_SIM_DEVICE_ID", &device.id);
501    }
502    if options.headless || options.no_open {
503        command.env("IOS_SIM_HEADLESS", "1");
504    }
505    if options.release {
506        command.env("IOS_SIM_PROFILE", "release");
507    }
508    run_status(&mut command, "iOS launch")?;
509    if !options.detach {
510        attach_ios_logs(project, device, true)?;
511    }
512    Ok(())
513}
514
515fn run_android(project: &FissionProject, options: &RunOptions, device: &Device) -> Result<()> {
516    if device.kind == "android-avd" {
517        let script = options
518            .project_dir
519            .join("platforms/android/run-emulator.sh");
520        let mut command = command_for_script(&script)?;
521        command.current_dir(&options.project_dir);
522        command.env(
523            "ANDROID_AVD_NAME",
524            device.id.trim_start_matches("android-avd:"),
525        );
526        if options.headless || options.no_open {
527            command.env("ANDROID_EMULATOR_HEADLESS", "1");
528        }
529        if options.release {
530            command.env("ANDROID_PROFILE", "release");
531        }
532        run_status(&mut command, "Android emulator launch")?;
533        if !options.detach {
534            let serial = first_android_serial().unwrap_or_else(|| "emulator-5554".to_string());
535            let running = Device {
536                id: serial,
537                kind: "android-emulator".to_string(),
538                ..device.clone()
539            };
540            attach_android_logs(project, &running, true)?;
541        }
542        return Ok(());
543    }
544
545    let apk = package_android(&options.project_dir, options.release)?;
546    let adb = adb_path()?;
547    run_status(
548        Command::new(&adb)
549            .arg("-s")
550            .arg(&device.id)
551            .arg("install")
552            .arg("-r")
553            .arg(&apk),
554        "Android install",
555    )?;
556    run_status(
557        Command::new(&adb)
558            .arg("-s")
559            .arg(&device.id)
560            .arg("shell")
561            .arg("am")
562            .arg("start")
563            .arg("-n")
564            .arg(format!("{}/android.app.NativeActivity", project.app.app_id)),
565        "Android launch",
566    )?;
567    if !options.detach {
568        attach_android_logs(project, device, true)?;
569    }
570    Ok(())
571}
572
573fn attach_ios_logs(project: &FissionProject, device: &Device, follow: bool) -> Result<()> {
574    require_host(Target::Ios)?;
575    let executable = ios_executable_name(project);
576    let predicate = format!("process == \"{}\"", executable);
577    let mut command = Command::new("xcrun");
578    command
579        .arg("simctl")
580        .arg("spawn")
581        .arg(&device.id)
582        .arg("log")
583        .arg(if follow { "stream" } else { "show" })
584        .arg("--style")
585        .arg("compact")
586        .arg("--predicate")
587        .arg(predicate);
588    println!(
589        "Attaching iOS logs for {}. Press Ctrl+C to stop.",
590        executable
591    );
592    run_status(&mut command, "iOS logs")
593}
594
595fn attach_android_logs(project: &FissionProject, device: &Device, follow: bool) -> Result<()> {
596    let adb = adb_path()?;
597    let pid = wait_for_android_pid(
598        &adb,
599        &device.id,
600        &project.app.app_id,
601        Duration::from_secs(20),
602    )?;
603    let mut command = Command::new(&adb);
604    command
605        .arg("-s")
606        .arg(&device.id)
607        .arg("logcat")
608        .arg("--pid")
609        .arg(pid);
610    if !follow {
611        command.arg("-d");
612    }
613    println!(
614        "Attaching Android logs for {} on {}. Press Ctrl+C to stop.",
615        project.app.app_id, device.id
616    );
617    run_status(&mut command, "Android logs")
618}
619
620fn tail_log_file(path: &Path, follow: bool) -> Result<()> {
621    if !path.exists() {
622        bail!(
623            "no detached log file found at {}; run with `--detach` first",
624            path.display()
625        );
626    }
627    if !follow {
628        print!("{}", fs::read_to_string(path)?);
629        return Ok(());
630    }
631
632    println!("Following {}. Press Ctrl+C to stop.", path.display());
633    let mut offset = 0u64;
634    loop {
635        let mut file = File::open(path)?;
636        file.seek_relative(offset as i64)?;
637        let mut buf = String::new();
638        file.read_to_string(&mut buf)?;
639        if !buf.is_empty() {
640            print!("{}", buf);
641            io::stdout().flush()?;
642            offset += buf.len() as u64;
643        }
644        std::thread::sleep(Duration::from_millis(500));
645    }
646}
647
648fn build_web(project_dir: &Path, release: bool) -> Result<()> {
649    let project_dir = fs::canonicalize(project_dir).with_context(|| {
650        format!(
651            "failed to resolve project directory {}",
652            project_dir.display()
653        )
654    })?;
655    let out_dir = project_dir.join("platforms/web/pkg");
656    let mut command = Command::new("wasm-pack");
657    command
658        .arg("build")
659        .arg(&project_dir)
660        .arg("--target")
661        .arg("web")
662        .arg("--out-dir")
663        .arg(out_dir);
664    command.arg(if release { "--release" } else { "--dev" });
665    run_status(&mut command, "web build")
666}
667
668fn package_android(project_dir: &Path, release: bool) -> Result<PathBuf> {
669    let script = project_dir.join("platforms/android/package-apk.sh");
670    let mut command = command_for_script(&script)?;
671    command.current_dir(project_dir);
672    if release {
673        command.env("ANDROID_PROFILE", "release");
674    }
675    let output = command
676        .output()
677        .context("failed to run Android package script")?;
678    if !output.status.success() {
679        io::stderr().write_all(&output.stderr).ok();
680        bail!("Android package failed with {}", output.status);
681    }
682    io::stderr().write_all(&output.stderr).ok();
683    let stdout = String::from_utf8_lossy(&output.stdout);
684    let apk = stdout
685        .lines()
686        .rev()
687        .find(|line| line.trim_end().ends_with(".apk"))
688        .map(|line| PathBuf::from(line.trim()))
689        .context("Android package script did not print an APK path")?;
690    Ok(apk)
691}
692
693fn run_child(mut command: Command, detach: bool, log_path: PathBuf) -> Result<()> {
694    if detach {
695        let log = open_log(&log_path)?;
696        let err = log.try_clone()?;
697        let child = command
698            .stdout(Stdio::from(log))
699            .stderr(Stdio::from(err))
700            .spawn()?;
701        println!("Started pid {}. Logs: {}", child.id(), log_path.display());
702        return Ok(());
703    }
704    let status = command.status()?;
705    if !status.success() {
706        bail!("command exited with {status}");
707    }
708    Ok(())
709}
710
711fn build_desktop(project_dir: &Path, release: bool) -> Result<()> {
712    let mut command = Command::new("cargo");
713    command.arg("build").current_dir(project_dir);
714    if release {
715        command.arg("--release");
716    }
717    run_status(&mut command, "desktop build")
718}
719
720fn run_target_script<F>(project_dir: &Path, relative_script: &str, configure: F) -> Result<()>
721where
722    F: FnOnce(&mut Command),
723{
724    let script = project_dir.join(relative_script);
725    let mut command = command_for_script(&script)?;
726    command.current_dir(project_dir);
727    configure(&mut command);
728    run_status(&mut command, relative_script)
729}
730
731fn run_status(command: &mut Command, label: &str) -> Result<()> {
732    let status = command
733        .status()
734        .with_context(|| format!("failed to run {label}"))?;
735    if !status.success() {
736        bail!("{label} failed with {status}");
737    }
738    Ok(())
739}
740
741fn command_for_script(script: &Path) -> Result<Command> {
742    if !script.exists() {
743        bail!("script is missing at {}", script.display());
744    }
745    let script = fs::canonicalize(script)
746        .with_context(|| format!("failed to resolve script {}", script.display()))?;
747    if cfg!(windows) {
748        if find_in_path("bash").is_none() {
749            bail!(
750                "running {} on Windows currently requires bash on PATH; install Git for Windows or run the equivalent command in WSL",
751                script.display()
752            );
753        }
754        let mut command = Command::new("bash");
755        command.arg(script);
756        Ok(command)
757    } else {
758        Ok(Command::new(script))
759    }
760}
761
762fn detached_log_path(project_dir: &Path, name: &str) -> PathBuf {
763    project_dir.join(".fission/run").join(format!("{name}.log"))
764}
765
766fn open_log(path: &Path) -> Result<File> {
767    if let Some(parent) = path.parent() {
768        fs::create_dir_all(parent)?;
769    }
770    OpenOptions::new()
771        .create(true)
772        .append(true)
773        .open(path)
774        .with_context(|| format!("failed to open log file {}", path.display()))
775}
776
777fn discover_ios_simulators() -> Vec<Device> {
778    if !cfg!(target_os = "macos") || find_in_path("xcrun").is_none() {
779        return Vec::new();
780    }
781    let output = match Command::new("xcrun")
782        .args(["simctl", "list", "devices", "available", "-j"])
783        .output()
784    {
785        Ok(output) if output.status.success() => output,
786        _ => return Vec::new(),
787    };
788    let payload: serde_json::Value = match serde_json::from_slice(&output.stdout) {
789        Ok(payload) => payload,
790        Err(_) => return Vec::new(),
791    };
792    let mut devices = Vec::new();
793    if let Some(groups) = payload.get("devices").and_then(|value| value.as_object()) {
794        for (runtime, entries) in groups {
795            if !runtime.contains("SimRuntime.iOS") {
796                continue;
797            }
798            let Some(entries) = entries.as_array() else {
799                continue;
800            };
801            for entry in entries {
802                let name = entry
803                    .get("name")
804                    .and_then(|value| value.as_str())
805                    .unwrap_or("iOS Simulator");
806                if !name.contains("iPhone") {
807                    continue;
808                }
809                let Some(udid) = entry.get("udid").and_then(|value| value.as_str()) else {
810                    continue;
811                };
812                let state = entry
813                    .get("state")
814                    .and_then(|value| value.as_str())
815                    .unwrap_or("unknown");
816                devices.push(Device {
817                    id: udid.to_string(),
818                    name: name.to_string(),
819                    target: Target::Ios,
820                    kind: "ios-simulator".to_string(),
821                    status: state.to_ascii_lowercase(),
822                    detail: runtime
823                        .rsplit('.')
824                        .next()
825                        .unwrap_or(runtime)
826                        .replace('-', " "),
827                    available: true,
828                });
829            }
830        }
831    }
832    devices
833}
834
835fn discover_android_devices() -> Vec<Device> {
836    let mut devices = Vec::new();
837    let Ok(adb) = adb_path() else {
838        return devices;
839    };
840    if let Ok(output) = Command::new(&adb).arg("devices").arg("-l").output() {
841        if output.status.success() {
842            let stdout = String::from_utf8_lossy(&output.stdout);
843            for line in stdout.lines().skip(1) {
844                let line = line.trim();
845                if line.is_empty() {
846                    continue;
847                }
848                let mut parts = line.split_whitespace();
849                let Some(serial) = parts.next() else { continue };
850                let status = parts.next().unwrap_or("unknown");
851                let detail = parts.collect::<Vec<_>>().join(" ");
852                devices.push(Device {
853                    id: serial.to_string(),
854                    name: if serial.starts_with("emulator-") {
855                        "Android Emulator"
856                    } else {
857                        "Android Device"
858                    }
859                    .to_string(),
860                    target: Target::Android,
861                    kind: if serial.starts_with("emulator-") {
862                        "android-emulator"
863                    } else {
864                        "android-device"
865                    }
866                    .to_string(),
867                    status: status.to_string(),
868                    detail,
869                    available: status == "device",
870                });
871            }
872        }
873    }
874
875    if let Some(avdmanager) = android_tool("cmdline-tools/latest/bin/avdmanager") {
876        if let Ok(output) = Command::new(avdmanager).args(["list", "avd"]).output() {
877            if output.status.success() {
878                let stdout = String::from_utf8_lossy(&output.stdout);
879                for line in stdout.lines() {
880                    let line = line.trim();
881                    if let Some(name) = line.strip_prefix("Name:") {
882                        let name = name.trim();
883                        devices.push(Device {
884                            id: format!("android-avd:{name}"),
885                            name: name.to_string(),
886                            target: Target::Android,
887                            kind: "android-avd".to_string(),
888                            status: "configured".to_string(),
889                            detail: "stopped emulator profile".to_string(),
890                            available: true,
891                        });
892                    }
893                }
894            }
895        }
896    }
897    devices
898}
899
900fn wait_for_android_pid(
901    adb: &Path,
902    serial: &str,
903    app_id: &str,
904    timeout: Duration,
905) -> Result<String> {
906    let start = Instant::now();
907    while start.elapsed() < timeout {
908        let output = Command::new(adb)
909            .arg("-s")
910            .arg(serial)
911            .arg("shell")
912            .arg("pidof")
913            .arg(app_id)
914            .output();
915        if let Ok(output) = output {
916            if output.status.success() {
917                let pid = String::from_utf8_lossy(&output.stdout).trim().to_string();
918                if !pid.is_empty() {
919                    return Ok(pid);
920                }
921            }
922        }
923        std::thread::sleep(Duration::from_millis(500));
924    }
925    bail!("timed out waiting for Android process `{app_id}` on {serial}")
926}
927
928fn first_android_serial() -> Option<String> {
929    let adb = adb_path().ok()?;
930    let output = Command::new(adb).arg("devices").output().ok()?;
931    if !output.status.success() {
932        return None;
933    }
934    String::from_utf8_lossy(&output.stdout)
935        .lines()
936        .skip(1)
937        .find_map(|line| {
938            let mut parts = line.split_whitespace();
939            let serial = parts.next()?;
940            let status = parts.next()?;
941            (status == "device").then(|| serial.to_string())
942        })
943}
944
945fn adb_path() -> Result<PathBuf> {
946    android_tool("platform-tools/adb")
947        .context("Android adb was not found; run `fission doctor android`")
948}
949
950fn android_tool(relative: &str) -> Option<PathBuf> {
951    let home = android_home();
952    let path = home.join(relative);
953    if path.exists() {
954        return Some(path);
955    }
956    let exe = home.join(format!("{relative}.exe"));
957    if exe.exists() {
958        return Some(exe);
959    }
960    None
961}
962
963fn android_home() -> PathBuf {
964    env::var_os("ANDROID_HOME")
965        .or_else(|| env::var_os("ANDROID_SDK_ROOT"))
966        .map(PathBuf::from)
967        .unwrap_or_else(default_android_home)
968}
969
970fn default_android_home() -> PathBuf {
971    let home = env::var_os("HOME")
972        .or_else(|| env::var_os("USERPROFILE"))
973        .map(PathBuf::from)
974        .unwrap_or_else(|| PathBuf::from("."));
975    if cfg!(target_os = "macos") {
976        home.join("Library/Android/sdk")
977    } else if cfg!(target_os = "windows") {
978        env::var_os("LOCALAPPDATA")
979            .map(PathBuf::from)
980            .unwrap_or(home)
981            .join("Android/Sdk")
982    } else {
983        home.join("Android/Sdk")
984    }
985}
986
987fn detect_chrome() -> Option<PathBuf> {
988    for var in ["FISSION_CHROME", "CHROME", "CHROME_BIN"] {
989        if let Ok(value) = env::var(var) {
990            let path = PathBuf::from(value);
991            if path.exists() {
992                return Some(path);
993            }
994        }
995    }
996    let names = if cfg!(target_os = "windows") {
997        vec!["chrome.exe", "msedge.exe", "chromium.exe"]
998    } else {
999        vec!["google-chrome", "chromium", "chromium-browser", "chrome"]
1000    };
1001    for name in names {
1002        if let Some(path) = find_in_path(name) {
1003            return Some(path);
1004        }
1005    }
1006    for path in platform_chrome_paths() {
1007        if path.exists() {
1008            return Some(path);
1009        }
1010    }
1011    None
1012}
1013
1014fn platform_chrome_paths() -> Vec<PathBuf> {
1015    if cfg!(target_os = "macos") {
1016        vec![
1017            PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
1018            PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
1019            PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
1020        ]
1021    } else if cfg!(target_os = "windows") {
1022        let mut paths = Vec::new();
1023        if let Some(program_files) = env::var_os("PROGRAMFILES") {
1024            paths.push(PathBuf::from(program_files).join("Google/Chrome/Application/chrome.exe"));
1025        }
1026        if let Some(program_files_x86) = env::var_os("PROGRAMFILES(X86)") {
1027            paths.push(
1028                PathBuf::from(program_files_x86).join("Google/Chrome/Application/chrome.exe"),
1029            );
1030        }
1031        paths
1032    } else {
1033        Vec::new()
1034    }
1035}
1036
1037fn find_in_path(name: &str) -> Option<PathBuf> {
1038    let path = env::var_os("PATH")?;
1039    for dir in env::split_paths(&path) {
1040        let candidate = dir.join(name);
1041        if candidate.exists() {
1042            return Some(candidate);
1043        }
1044    }
1045    None
1046}
1047
1048fn require_host(target: Target) -> Result<()> {
1049    match target {
1050        Target::Ios if !cfg!(target_os = "macos") => {
1051            bail!("iOS simulator runs require macOS with Xcode")
1052        }
1053        _ => Ok(()),
1054    }
1055}
1056
1057fn require_desktop_host(target: Target) -> Result<()> {
1058    let host = host_desktop_target();
1059    if target != host {
1060        bail!(
1061            "desktop target `{}` cannot be built or run from this host with the current CLI; use `{}` on this machine",
1062            target.as_str(),
1063            host.as_str()
1064        );
1065    }
1066    Ok(())
1067}
1068
1069fn host_desktop_target() -> Target {
1070    if cfg!(target_os = "windows") {
1071        Target::Windows
1072    } else if cfg!(target_os = "macos") {
1073        Target::Macos
1074    } else {
1075        Target::Linux
1076    }
1077}
1078
1079fn desktop_name() -> &'static str {
1080    if cfg!(target_os = "windows") {
1081        "Windows desktop"
1082    } else if cfg!(target_os = "macos") {
1083        "macOS desktop"
1084    } else {
1085        "Linux desktop"
1086    }
1087}
1088
1089fn current_os_detail() -> String {
1090    env::consts::OS.to_string()
1091}