Skip to main content

fission_command_run/
lib.rs

1pub mod doctor;
2
3use anyhow::{bail, Context, Result};
4use fission_command_core::{
5    ios_executable_name, normalized_extension, read_project_config, resolve_app_icon,
6    sync_platform_config, FissionProject, PlatformCapability, Target,
7};
8use serde::{Deserialize, Serialize};
9use std::env;
10use std::fs::{self, File, OpenOptions};
11use std::io::{self, IsTerminal, Read, Seek, Write};
12use std::net::TcpListener;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::time::{Duration, Instant};
16
17#[derive(Clone, Debug, Serialize)]
18pub struct Device {
19    pub id: String,
20    pub name: String,
21    pub target: Target,
22    pub kind: String,
23    pub status: String,
24    pub detail: String,
25    pub available: bool,
26}
27
28#[derive(Clone, Debug)]
29pub struct RunOptions {
30    pub project_dir: PathBuf,
31    pub target: Option<Target>,
32    pub device: Option<String>,
33    pub detach: bool,
34    pub release: bool,
35    pub host: String,
36    pub port: u16,
37    pub no_open: bool,
38    pub headless: bool,
39}
40
41#[derive(Clone, Debug)]
42pub struct BuildOptions {
43    pub project_dir: PathBuf,
44    pub target: Option<Target>,
45    pub release: bool,
46}
47
48#[derive(Clone, Debug)]
49pub struct TestOptions {
50    pub project_dir: PathBuf,
51    pub target: Option<Target>,
52    pub headless: bool,
53}
54
55#[derive(Clone, Debug)]
56pub struct LogOptions {
57    pub project_dir: PathBuf,
58    pub target: Option<Target>,
59    pub device: Option<String>,
60    pub follow: bool,
61}
62
63#[derive(Clone, Debug)]
64pub struct ServeWebOptions {
65    pub project_dir: PathBuf,
66    pub host: String,
67    pub port: u16,
68    pub open: bool,
69}
70
71pub fn list_devices(project_dir: &Path, json: bool) -> Result<()> {
72    let devices = discover_devices(project_dir);
73    if json {
74        println!("{}", serde_json::to_string_pretty(&devices)?);
75        return Ok(());
76    }
77
78    println!("Fission devices");
79    if devices.is_empty() {
80        println!(
81            "No runnable devices detected. Run `fission doctor web ios android --project-dir {}`.",
82            project_dir.display()
83        );
84        return Ok(());
85    }
86
87    let id_width = devices
88        .iter()
89        .map(|device| device.id.len())
90        .max()
91        .unwrap_or(2)
92        .max(2);
93    let target_width = devices
94        .iter()
95        .map(|device| device.target.as_str().len())
96        .max()
97        .unwrap_or(6)
98        .max(6);
99    println!(
100        "{:<id_width$}  {:<target_width$}  {:<16}  {:<12}  {}",
101        "ID",
102        "TARGET",
103        "KIND",
104        "STATUS",
105        "NAME",
106        id_width = id_width,
107        target_width = target_width
108    );
109    for device in devices {
110        println!(
111            "{:<id_width$}  {:<target_width$}  {:<16}  {:<12}  {}{}",
112            device.id,
113            device.target.as_str(),
114            device.kind,
115            device.status,
116            device.name,
117            if device.detail.is_empty() {
118                String::new()
119            } else {
120                format!(" ({})", device.detail)
121            },
122            id_width = id_width,
123            target_width = target_width
124        );
125    }
126    Ok(())
127}
128
129pub fn run_app(options: RunOptions) -> Result<()> {
130    let project = read_project_config(&options.project_dir)?;
131    let device = select_device(
132        &options.project_dir,
133        options.target,
134        options.device.as_deref(),
135    )?;
136    ensure_target_configured(&project, &options.project_dir, device.target)?;
137    sync_target_platform_config(&options.project_dir, &project, device.target)?;
138
139    match device.target {
140        Target::Linux | Target::Macos | Target::Windows => run_desktop(&project, &options, &device),
141        Target::Web => run_web(&options, &device),
142        Target::Site => site_serve(
143            &options.project_dir,
144            options.release,
145            options.host,
146            options.port,
147            !options.no_open,
148        ),
149        Target::Server => fission_command_server::serve(
150            &options.project_dir,
151            options.release,
152            options.host,
153            options.port,
154        ),
155        Target::Ios => run_ios(&project, &options, &device),
156        Target::Android => run_android(&project, &options, &device),
157    }
158}
159
160pub fn build_app(options: BuildOptions) -> Result<()> {
161    let project = read_project_config(&options.project_dir)?;
162    let target = options.target.unwrap_or_else(host_desktop_target);
163    ensure_target_configured(&project, &options.project_dir, target)?;
164    sync_target_platform_config(&options.project_dir, &project, target)?;
165
166    match target {
167        Target::Linux | Target::Macos | Target::Windows => {
168            require_desktop_host(target)?;
169            build_desktop(&options.project_dir, options.release)
170        }
171        Target::Web => build_web(&options.project_dir, options.release),
172        Target::Site => site_build(&options.project_dir, options.release),
173        Target::Server => fission_command_server::build(&options.project_dir, options.release),
174        Target::Ios => {
175            require_host(Target::Ios)?;
176            let script = options.project_dir.join("platforms/ios/package-sim.sh");
177            let mut command = command_for_script(&script)?;
178            command.current_dir(&options.project_dir);
179            if options.release {
180                command.env("IOS_SIM_PROFILE", "release");
181            }
182            run_status(&mut command, "iOS build")
183        }
184        Target::Android => {
185            let apk = package_android(&options.project_dir, options.release)?;
186            println!("{}", apk.display());
187            Ok(())
188        }
189    }
190}
191
192pub fn test_app(options: TestOptions) -> Result<()> {
193    let project = read_project_config(&options.project_dir)?;
194    let target = options.target.unwrap_or_else(host_desktop_target);
195    ensure_target_configured(&project, &options.project_dir, target)?;
196    sync_target_platform_config(&options.project_dir, &project, target)?;
197
198    match target {
199        Target::Linux | Target::Macos | Target::Windows => {
200            require_desktop_host(target)?;
201            let mut command = Command::new("cargo");
202            command.arg("test").current_dir(&options.project_dir);
203            run_status(&mut command, "desktop tests")
204        }
205        Target::Web => run_target_script(
206            &options.project_dir,
207            "platforms/web/test-browser.sh",
208            |_| {},
209        ),
210        Target::Site => site_check(&options.project_dir, false),
211        Target::Server => fission_command_server::check(&options.project_dir, false),
212        Target::Ios => {
213            require_host(Target::Ios)?;
214            run_target_script(
215                &options.project_dir,
216                "platforms/ios/test-sim.sh",
217                |command| {
218                    if options.headless {
219                        command.env("IOS_SIM_HEADLESS", "1");
220                    }
221                },
222            )
223        }
224        Target::Android => run_target_script(
225            &options.project_dir,
226            "platforms/android/test-emulator.sh",
227            |command| {
228                if options.headless {
229                    command.env("ANDROID_EMULATOR_HEADLESS", "1");
230                }
231            },
232        ),
233    }
234}
235
236fn sync_target_platform_config(
237    project_dir: &Path,
238    project: &FissionProject,
239    target: Target,
240) -> Result<()> {
241    if matches!(target, Target::Android | Target::Ios) {
242        sync_platform_config(project_dir, project)?;
243    }
244    Ok(())
245}
246
247pub fn attach_logs(options: LogOptions) -> Result<()> {
248    let project = read_project_config(&options.project_dir)?;
249    let device = select_device(
250        &options.project_dir,
251        options.target,
252        options.device.as_deref(),
253    )?;
254    match device.target {
255        Target::Android => attach_android_logs(&project, &device, options.follow),
256        Target::Ios => attach_ios_logs(&project, &device, options.follow),
257        Target::Web => tail_log_file(
258            &detached_log_path(&options.project_dir, "web"),
259            options.follow,
260        ),
261        Target::Site => tail_log_file(
262            &detached_log_path(&options.project_dir, "site"),
263            options.follow,
264        ),
265        Target::Server => tail_log_file(
266            &detached_log_path(&options.project_dir, "server"),
267            options.follow,
268        ),
269        Target::Linux | Target::Macos | Target::Windows => tail_log_file(
270            &detached_log_path(&options.project_dir, "desktop"),
271            options.follow,
272        ),
273    }
274}
275
276pub fn serve_web(options: ServeWebOptions) -> Result<()> {
277    fission_command_site::serve_static(
278        options.project_dir,
279        options.host,
280        options.port,
281        options.open,
282    )
283}
284
285pub fn site_build(project_dir: &Path, release: bool) -> Result<()> {
286    fission_command_site::build(project_dir, release)
287}
288
289pub fn site_check(project_dir: &Path, release: bool) -> Result<()> {
290    fission_command_site::check(project_dir, release)
291}
292
293pub fn site_routes(project_dir: &Path) -> Result<()> {
294    fission_command_site::routes(project_dir)
295}
296
297pub fn site_serve(
298    project_dir: &Path,
299    release: bool,
300    host: String,
301    port: u16,
302    open: bool,
303) -> Result<()> {
304    fission_command_site::serve(project_dir, release, host, port, open)
305}
306
307pub fn discover_devices(_project_dir: &Path) -> Vec<Device> {
308    let mut devices = Vec::new();
309    devices.push(Device {
310        id: "desktop".to_string(),
311        name: desktop_name().to_string(),
312        target: host_desktop_target(),
313        kind: "desktop".to_string(),
314        status: "available".to_string(),
315        detail: current_os_detail(),
316        available: true,
317    });
318
319    devices.push(if let Some(chrome) = detect_chrome() {
320        Device {
321            id: "chrome".to_string(),
322            name: "Chrome/Chromium".to_string(),
323            target: Target::Web,
324            kind: "browser".to_string(),
325            status: "available".to_string(),
326            detail: chrome.display().to_string(),
327            available: true,
328        }
329    } else {
330        Device {
331            id: "web-server".to_string(),
332            name: "Local web server".to_string(),
333            target: Target::Web,
334            kind: "web-server".to_string(),
335            status: "available".to_string(),
336            detail: "Chrome/Chromium was not auto-detected".to_string(),
337            available: true,
338        }
339    });
340
341    devices.extend(discover_ios_simulators());
342    devices.extend(discover_android_devices());
343    devices.push(Device {
344        id: "site".to_string(),
345        name: "Static site".to_string(),
346        target: Target::Site,
347        kind: "site-server".to_string(),
348        status: "available".to_string(),
349        detail: "multi-page static output".to_string(),
350        available: true,
351    });
352    devices.push(Device {
353        id: "server".to_string(),
354        name: "Server-rendered web app".to_string(),
355        target: Target::Server,
356        kind: "server".to_string(),
357        status: "available".to_string(),
358        detail: "dynamic HTML server".to_string(),
359        available: true,
360    });
361    devices
362}
363
364fn select_device(
365    project_dir: &Path,
366    target: Option<Target>,
367    query: Option<&str>,
368) -> Result<Device> {
369    let devices = discover_devices(project_dir)
370        .into_iter()
371        .filter(|device| target.map(|target| target == device.target).unwrap_or(true))
372        .collect::<Vec<_>>();
373
374    if let Some(query) = query {
375        let query_lower = query.to_ascii_lowercase();
376        let mut matches = devices
377            .iter()
378            .filter(|device| {
379                device.id.eq_ignore_ascii_case(query)
380                    || device.id.to_ascii_lowercase().starts_with(&query_lower)
381                    || device.name.eq_ignore_ascii_case(query)
382                    || device.name.to_ascii_lowercase().contains(&query_lower)
383                    || device.target.as_str() == query_lower
384            })
385            .cloned()
386            .collect::<Vec<_>>();
387        if matches.iter().any(|device| !device.available) {
388            matches.retain(|device| device.available);
389            if matches.is_empty() {
390                bail!("device selector `{query}` matched a device that is not currently runnable");
391            }
392        }
393        return match matches.len() {
394            0 => bail!(
395                "no device matched `{query}`; run `fission devices --project-dir {}`",
396                project_dir.display()
397            ),
398            1 => Ok(matches[0].clone()),
399            _ => {
400                bail!("device selector `{query}` matched multiple devices; use an exact device id")
401            }
402        };
403    }
404
405    let devices = devices
406        .into_iter()
407        .filter(|device| device.available)
408        .collect::<Vec<_>>();
409
410    if devices.is_empty() {
411        bail!(
412            "no runnable devices detected; run `fission devices --project-dir {}`",
413            project_dir.display()
414        );
415    }
416    if devices.len() == 1 {
417        return Ok(devices[0].clone());
418    }
419
420    if let Some(device) = preferred_device_for_target(target, &devices) {
421        return Ok(device);
422    }
423
424    if !io::stdin().is_terminal() {
425        print_device_choices(&devices);
426        if let Some(target) = target {
427            bail!(
428                "multiple {} devices are available; pass `--device <id>`",
429                target.as_str()
430            );
431        }
432        bail!("multiple devices are available; pass `--device <id>` or `--target <target>`");
433    }
434
435    print_device_choices(&devices);
436    print!("Select a device: ");
437    io::stdout().flush()?;
438    let mut line = String::new();
439    io::stdin().read_line(&mut line)?;
440    let index = line
441        .trim()
442        .parse::<usize>()
443        .context("expected a numbered device selection")?;
444    if index == 0 || index > devices.len() {
445        bail!("device selection {index} is out of range");
446    }
447    Ok(devices[index - 1].clone())
448}
449
450fn preferred_device_for_target(target: Option<Target>, devices: &[Device]) -> Option<Device> {
451    match target? {
452        Target::Android => {
453            let running = devices
454                .iter()
455                .filter(|device| {
456                    matches!(device.kind.as_str(), "android-device" | "android-emulator")
457                })
458                .cloned()
459                .collect::<Vec<_>>();
460            if running.len() == 1 {
461                return Some(running[0].clone());
462            }
463            let avds = devices
464                .iter()
465                .filter(|device| device.kind == "android-avd")
466                .cloned()
467                .collect::<Vec<_>>();
468            if running.is_empty() && avds.len() == 1 {
469                return Some(avds[0].clone());
470            }
471            None
472        }
473        Target::Ios => {
474            let booted = devices
475                .iter()
476                .filter(|device| device.kind == "ios-simulator" && device.status == "booted")
477                .cloned()
478                .collect::<Vec<_>>();
479            (booted.len() == 1).then(|| booted[0].clone())
480        }
481        Target::Web
482        | Target::Site
483        | Target::Server
484        | Target::Linux
485        | Target::Macos
486        | Target::Windows => None,
487    }
488}
489
490fn print_device_choices(devices: &[Device]) {
491    println!("Available devices:");
492    for (idx, device) in devices.iter().enumerate() {
493        println!(
494            "  {}) {} [{}:{}] - {}",
495            idx + 1,
496            device.name,
497            device.target.as_str(),
498            device.id,
499            device.status
500        );
501    }
502}
503
504fn ensure_target_configured(
505    project: &FissionProject,
506    project_dir: &Path,
507    target: Target,
508) -> Result<()> {
509    if !project.targets.contains(&target) {
510        bail!(
511            "target `{}` is not configured for this app; run `fission add-target {} --project-dir {}`",
512            target.as_str(),
513            target.as_str(),
514            project_dir.display()
515        );
516    }
517    let scaffold = project_dir.join(target.scaffold_relative_path());
518    if !scaffold.exists() {
519        bail!(
520            "target `{}` scaffold is missing at {}; run `fission add-target {} --project-dir {}`",
521            target.as_str(),
522            scaffold.display(),
523            target.as_str(),
524            project_dir.display()
525        );
526    }
527    Ok(())
528}
529
530fn run_desktop(project: &FissionProject, options: &RunOptions, device: &Device) -> Result<()> {
531    if matches!(device.target, Target::Macos) && cfg!(target_os = "macos") {
532        let app = package_macos_run_app(project, &options.project_dir, options.release)?;
533        return run_macos_app_bundle(&app, options);
534    }
535    if matches!(device.target, Target::Linux) && cfg!(target_os = "linux") {
536        let app = package_linux_run_app(project, &options.project_dir, options.release)?;
537        let mut command = Command::new(&app.executable);
538        command.current_dir(&options.project_dir);
539        return run_child(
540            command,
541            options.detach,
542            detached_log_path(&options.project_dir, "desktop"),
543        );
544    }
545    if matches!(device.target, Target::Windows) && cfg!(target_os = "windows") {
546        let app = package_windows_run_app(project, &options.project_dir, options.release)?;
547        let mut command = Command::new(&app.executable);
548        command.current_dir(&options.project_dir);
549        return run_child(
550            command,
551            options.detach,
552            detached_log_path(&options.project_dir, "desktop"),
553        );
554    }
555
556    let mut command = Command::new("cargo");
557    command.arg("run").current_dir(&options.project_dir);
558    if options.release {
559        command.arg("--release");
560    }
561    run_child(
562        command,
563        options.detach,
564        detached_log_path(&options.project_dir, "desktop"),
565    )
566}
567
568fn run_web(options: &RunOptions, _device: &Device) -> Result<()> {
569    build_web(&options.project_dir, options.release)?;
570    let open = !options.no_open;
571    let port = available_web_port(&options.host, options.port)?;
572    if options.detach {
573        let log_path = detached_log_path(&options.project_dir, "web");
574        let log = open_log(&log_path)?;
575        let mut command = Command::new(env::current_exe()?);
576        command
577            .arg("serve-web")
578            .arg("--project-dir")
579            .arg(&options.project_dir)
580            .arg("--host")
581            .arg(&options.host)
582            .arg("--port")
583            .arg(port.to_string());
584        if open {
585            command.arg("--open");
586        }
587        let err = log.try_clone()?;
588        let child = command
589            .stdout(Stdio::from(log))
590            .stderr(Stdio::from(err))
591            .spawn()?;
592        println!(
593            "Started web server pid {} at {}. Logs: {}",
594            child.id(),
595            format!("http://{}:{port}/platforms/web/", options.host),
596            log_path.display()
597        );
598        return Ok(());
599    }
600
601    fission_command_site::serve_static(
602        options.project_dir.clone(),
603        options.host.clone(),
604        port,
605        open,
606    )
607}
608
609fn available_web_port(host: &str, requested: u16) -> Result<u16> {
610    const SEARCH_LIMIT: u16 = 50;
611    let mut first_error = None;
612    for offset in 0..=SEARCH_LIMIT {
613        let Some(port) = requested.checked_add(offset) else {
614            break;
615        };
616        match TcpListener::bind((host, port)) {
617            Ok(listener) => {
618                drop(listener);
619                if offset > 0 {
620                    eprintln!(
621                        "Port {host}:{requested} is already in use; using {host}:{port}. Pass `--port {port}` to make this explicit."
622                    );
623                }
624                return Ok(port);
625            }
626            Err(error) if offset == 0 => {
627                first_error = Some(error);
628            }
629            Err(_) => {}
630        }
631    }
632    if let Some(error) = first_error {
633        bail!(
634            "failed to find an available web port from {host}:{requested}: first bind failed with {error}"
635        );
636    }
637    bail!("failed to find an available web port from {host}:{requested}");
638}
639
640fn run_ios(project: &FissionProject, options: &RunOptions, device: &Device) -> Result<()> {
641    require_host(Target::Ios)?;
642    let script = options.project_dir.join("platforms/ios/run-sim.sh");
643    let mut command = command_for_script(&script)?;
644    command.current_dir(&options.project_dir);
645    if device.kind == "ios-simulator" {
646        command.env("IOS_SIM_DEVICE_ID", &device.id);
647    }
648    if options.headless || options.no_open {
649        command.env("IOS_SIM_HEADLESS", "1");
650    }
651    if options.release {
652        command.env("IOS_SIM_PROFILE", "release");
653    }
654    run_status(&mut command, "iOS launch")?;
655    if !options.detach {
656        attach_ios_logs(project, device, true)?;
657    }
658    Ok(())
659}
660
661fn run_android(project: &FissionProject, options: &RunOptions, device: &Device) -> Result<()> {
662    if device.kind == "android-avd" {
663        let script = options
664            .project_dir
665            .join("platforms/android/run-emulator.sh");
666        let mut command = command_for_script(&script)?;
667        command.current_dir(&options.project_dir);
668        command.env(
669            "ANDROID_AVD_NAME",
670            device.id.trim_start_matches("android-avd:"),
671        );
672        if options.headless || options.no_open {
673            command.env("ANDROID_EMULATOR_HEADLESS", "1");
674        }
675        if options.release {
676            command.env("ANDROID_PROFILE", "release");
677        }
678        run_status(&mut command, "Android emulator launch")?;
679        if !options.detach {
680            let serial = first_android_serial().unwrap_or_else(|| "emulator-5554".to_string());
681            let running = Device {
682                id: serial,
683                kind: "android-emulator".to_string(),
684                ..device.clone()
685            };
686            attach_android_logs(project, &running, true)?;
687        }
688        return Ok(());
689    }
690
691    let apk = package_android(&options.project_dir, options.release)?;
692    let adb = adb_path()?;
693    run_status(
694        Command::new(&adb)
695            .arg("-s")
696            .arg(&device.id)
697            .arg("install")
698            .arg("--no-streaming")
699            .arg("-r")
700            .arg(&apk),
701        "Android install",
702    )?;
703    run_status(
704        Command::new(&adb)
705            .arg("-s")
706            .arg(&device.id)
707            .arg("shell")
708            .arg("am")
709            .arg("start")
710            .arg("-n")
711            .arg(format!("{}/android.app.NativeActivity", project.app.app_id)),
712        "Android launch",
713    )?;
714    if !options.detach {
715        attach_android_logs(project, device, true)?;
716    }
717    Ok(())
718}
719
720fn attach_ios_logs(project: &FissionProject, device: &Device, follow: bool) -> Result<()> {
721    require_host(Target::Ios)?;
722    let executable = ios_executable_name(project);
723    let predicate = format!("process == \"{}\"", executable);
724    let mut command = Command::new("xcrun");
725    command
726        .arg("simctl")
727        .arg("spawn")
728        .arg(&device.id)
729        .arg("log")
730        .arg(if follow { "stream" } else { "show" })
731        .arg("--style")
732        .arg("compact")
733        .arg("--predicate")
734        .arg(predicate);
735    println!(
736        "Attaching iOS logs for {}. Press Ctrl+C to stop.",
737        executable
738    );
739    run_status(&mut command, "iOS logs")
740}
741
742fn attach_android_logs(project: &FissionProject, device: &Device, follow: bool) -> Result<()> {
743    let adb = adb_path()?;
744    let pid = wait_for_android_pid(
745        &adb,
746        &device.id,
747        &project.app.app_id,
748        Duration::from_secs(20),
749    )?;
750    let mut command = Command::new(&adb);
751    command
752        .arg("-s")
753        .arg(&device.id)
754        .arg("logcat")
755        .arg("--pid")
756        .arg(pid);
757    if !follow {
758        command.arg("-d");
759    }
760    println!(
761        "Attaching Android logs for {} on {}. Press Ctrl+C to stop.",
762        project.app.app_id, device.id
763    );
764    run_status(&mut command, "Android logs")
765}
766
767fn tail_log_file(path: &Path, follow: bool) -> Result<()> {
768    if !path.exists() {
769        bail!(
770            "no detached log file found at {}; run with `--detach` first",
771            path.display()
772        );
773    }
774    if !follow {
775        print!("{}", fs::read_to_string(path)?);
776        return Ok(());
777    }
778
779    println!("Following {}. Press Ctrl+C to stop.", path.display());
780    let mut offset = 0u64;
781    loop {
782        let mut file = File::open(path)?;
783        file.seek_relative(offset as i64)?;
784        let mut buf = String::new();
785        file.read_to_string(&mut buf)?;
786        if !buf.is_empty() {
787            print!("{}", buf);
788            io::stdout().flush()?;
789            offset += buf.len() as u64;
790        }
791        std::thread::sleep(Duration::from_millis(500));
792    }
793}
794
795fn build_web(project_dir: &Path, release: bool) -> Result<()> {
796    let project_dir = fs::canonicalize(project_dir).with_context(|| {
797        format!(
798            "failed to resolve project directory {}",
799            project_dir.display()
800        )
801    })?;
802    let out_dir = project_dir.join("platforms/web/pkg");
803    let mut command = Command::new("wasm-pack");
804    command
805        .arg("build")
806        .arg(&project_dir)
807        .arg("--target")
808        .arg("web")
809        .arg("--out-dir")
810        .arg(out_dir);
811    command.arg(if release { "--release" } else { "--dev" });
812    run_status(&mut command, "web build")
813}
814
815fn package_android(project_dir: &Path, release: bool) -> Result<PathBuf> {
816    let script = project_dir.join("platforms/android/package-apk.sh");
817    let mut command = command_for_script(&script)?;
818    command.current_dir(project_dir);
819    if release {
820        command.env("ANDROID_PROFILE", "release");
821    }
822    let output = command
823        .output()
824        .context("failed to run Android package script")?;
825    if !output.status.success() {
826        io::stderr().write_all(&output.stderr).ok();
827        bail!("Android package failed with {}", output.status);
828    }
829    io::stderr().write_all(&output.stderr).ok();
830    let stdout = String::from_utf8_lossy(&output.stdout);
831    let apk = stdout
832        .lines()
833        .rev()
834        .find(|line| line.trim_end().ends_with(".apk"))
835        .map(|line| PathBuf::from(line.trim()))
836        .context("Android package script did not print an APK path")?;
837    Ok(apk)
838}
839
840fn run_child(mut command: Command, detach: bool, log_path: PathBuf) -> Result<()> {
841    if detach {
842        let log = open_log(&log_path)?;
843        let err = log.try_clone()?;
844        let child = command
845            .stdout(Stdio::from(log))
846            .stderr(Stdio::from(err))
847            .spawn()?;
848        println!("Started pid {}. Logs: {}", child.id(), log_path.display());
849        return Ok(());
850    }
851    let status = command.status()?;
852    if !status.success() {
853        bail!("command exited with {status}");
854    }
855    Ok(())
856}
857
858#[derive(Debug)]
859struct DesktopBinary {
860    version: String,
861    executable_name: String,
862    path: PathBuf,
863}
864
865#[derive(Debug)]
866struct MacosRunApp {
867    bundle: PathBuf,
868}
869
870#[derive(Debug)]
871struct DesktopRunApp {
872    executable: PathBuf,
873}
874
875#[derive(Debug, Deserialize)]
876struct CargoMetadata {
877    packages: Vec<CargoMetadataPackage>,
878    target_directory: PathBuf,
879}
880
881#[derive(Debug, Deserialize)]
882struct CargoMetadataPackage {
883    name: String,
884    version: String,
885    manifest_path: PathBuf,
886    targets: Vec<CargoMetadataTarget>,
887}
888
889#[derive(Debug, Deserialize)]
890struct CargoMetadataTarget {
891    name: String,
892    kind: Vec<String>,
893}
894
895fn build_desktop_binary(project_dir: &Path, release: bool) -> Result<DesktopBinary> {
896    let project_dir = fs::canonicalize(project_dir).with_context(|| {
897        format!(
898            "failed to resolve project directory {}",
899            project_dir.display()
900        )
901    })?;
902    let metadata = cargo_metadata(&project_dir)?;
903    let manifest_path = fs::canonicalize(project_dir.join("Cargo.toml")).with_context(|| {
904        format!(
905            "failed to resolve Cargo manifest at {}",
906            project_dir.join("Cargo.toml").display()
907        )
908    })?;
909    let package = metadata
910        .packages
911        .iter()
912        .find(|package| package.manifest_path == manifest_path)
913        .or_else(|| metadata.packages.first())
914        .context("cargo metadata did not include a package for this project")?;
915    let executable_name = package
916        .targets
917        .iter()
918        .find(|target| target.kind.iter().any(|kind| kind == "bin") && target.name == package.name)
919        .or_else(|| {
920            package
921                .targets
922                .iter()
923                .find(|target| target.kind.iter().any(|kind| kind == "bin"))
924        })
925        .map(|target| target.name.clone())
926        .with_context(|| {
927            format!(
928                "Cargo package `{}` does not define a binary target",
929                package.name
930            )
931        })?;
932
933    let mut command = Command::new("cargo");
934    command
935        .arg("build")
936        .arg("--manifest-path")
937        .arg(project_dir.join("Cargo.toml"))
938        .arg("--package")
939        .arg(&package.name)
940        .current_dir(project_dir);
941    if release {
942        command.arg("--release");
943    }
944    run_status(&mut command, "desktop build")?;
945
946    let profile = if release { "release" } else { "debug" };
947    let path = metadata
948        .target_directory
949        .join(profile)
950        .join(platform_executable_name(&executable_name));
951    if !path.exists() {
952        bail!(
953            "desktop build completed but expected binary is missing at {}",
954            path.display()
955        );
956    }
957
958    Ok(DesktopBinary {
959        version: package.version.clone(),
960        executable_name,
961        path,
962    })
963}
964
965fn cargo_metadata(project_dir: &Path) -> Result<CargoMetadata> {
966    let output = Command::new("cargo")
967        .arg("metadata")
968        .arg("--no-deps")
969        .arg("--format-version")
970        .arg("1")
971        .current_dir(project_dir)
972        .output()
973        .context("failed to run cargo metadata")?;
974    if !output.status.success() {
975        io::stderr().write_all(&output.stderr).ok();
976        bail!("cargo metadata failed with {}", output.status);
977    }
978    serde_json::from_slice(&output.stdout).context("failed to parse cargo metadata")
979}
980
981fn package_macos_run_app(
982    project: &FissionProject,
983    project_dir: &Path,
984    release: bool,
985) -> Result<MacosRunApp> {
986    let project_dir = fs::canonicalize(project_dir).with_context(|| {
987        format!(
988            "failed to resolve project directory {}",
989            project_dir.display()
990        )
991    })?;
992    let binary = build_desktop_binary(&project_dir, release)?;
993    let profile = if release { "release" } else { "debug" };
994    let app_name = macos_display_name(&project.app.name);
995    let app_bundle = project_dir
996        .join(".fission/run/macos")
997        .join(profile)
998        .join(format!("{app_name}.app"));
999    let contents = app_bundle.join("Contents");
1000    let macos_dir = contents.join("MacOS");
1001    let resources_dir = contents.join("Resources");
1002    if app_bundle.exists() {
1003        fs::remove_dir_all(&app_bundle).with_context(|| {
1004            format!(
1005                "failed to clear previous macOS run bundle at {}",
1006                app_bundle.display()
1007            )
1008        })?;
1009    }
1010    fs::create_dir_all(&macos_dir)?;
1011    fs::create_dir_all(&resources_dir)?;
1012
1013    let executable = macos_dir.join(&binary.executable_name);
1014    fs::copy(&binary.path, &executable).with_context(|| {
1015        format!(
1016            "failed to copy {} into macOS app bundle",
1017            binary.path.display()
1018        )
1019    })?;
1020    if let Some(icon) = resolve_app_icon(&project_dir, Target::Macos)? {
1021        let extension = normalized_extension(&icon.path)?;
1022        let destination = resources_dir.join(format!("AppIcon.{extension}"));
1023        fs::copy(&icon.path, &destination).with_context(|| {
1024            format!(
1025                "failed to copy macOS app icon {} to {}",
1026                icon.path.display(),
1027                destination.display()
1028            )
1029        })?;
1030    }
1031
1032    fs::write(
1033        contents.join("Info.plist"),
1034        render_macos_run_info_plist(project, &binary, &app_name),
1035    )?;
1036    fs::write(contents.join("PkgInfo"), "APPL????")?;
1037
1038    Ok(MacosRunApp { bundle: app_bundle })
1039}
1040
1041fn package_linux_run_app(
1042    project: &FissionProject,
1043    project_dir: &Path,
1044    release: bool,
1045) -> Result<DesktopRunApp> {
1046    let project_dir = fs::canonicalize(project_dir).with_context(|| {
1047        format!(
1048            "failed to resolve project directory {}",
1049            project_dir.display()
1050        )
1051    })?;
1052    let binary = build_desktop_binary(&project_dir, release)?;
1053    let profile = if release { "release" } else { "debug" };
1054    let app_root = project_dir
1055        .join(".fission/run/linux")
1056        .join(profile)
1057        .join(sanitize_file_stem(&project.app.name));
1058    if app_root.exists() {
1059        fs::remove_dir_all(&app_root).with_context(|| {
1060            format!(
1061                "failed to clear previous Linux run bundle at {}",
1062                app_root.display()
1063            )
1064        })?;
1065    }
1066    let bin_dir = app_root.join("bin");
1067    let applications_dir = app_root.join("share/applications");
1068    fs::create_dir_all(&bin_dir)?;
1069    fs::create_dir_all(&applications_dir)?;
1070
1071    let executable = bin_dir.join(&binary.executable_name);
1072    fs::copy(&binary.path, &executable).with_context(|| {
1073        format!(
1074            "failed to copy {} into Linux development bundle",
1075            binary.path.display()
1076        )
1077    })?;
1078    copy_unix_mode(&binary.path, &executable).ok();
1079    if let Some(icon) = resolve_app_icon(&project_dir, Target::Linux)? {
1080        let extension = normalized_extension(&icon.path)?;
1081        let icons_dir = if extension == "svg" {
1082            app_root.join("share/icons/hicolor/scalable/apps")
1083        } else {
1084            app_root.join("share/icons/hicolor/512x512/apps")
1085        };
1086        fs::create_dir_all(&icons_dir)?;
1087        let destination = icons_dir.join(format!("{}.{}", project.app.app_id, extension));
1088        fs::copy(&icon.path, &destination).with_context(|| {
1089            format!(
1090                "failed to copy Linux app icon {} to {}",
1091                icon.path.display(),
1092                destination.display()
1093            )
1094        })?;
1095    }
1096    fs::write(
1097        applications_dir.join(format!("{}.desktop", project.app.app_id)),
1098        render_linux_desktop_entry(project, &executable),
1099    )?;
1100    Ok(DesktopRunApp { executable })
1101}
1102
1103fn package_windows_run_app(
1104    project: &FissionProject,
1105    project_dir: &Path,
1106    release: bool,
1107) -> Result<DesktopRunApp> {
1108    let project_dir = fs::canonicalize(project_dir).with_context(|| {
1109        format!(
1110            "failed to resolve project directory {}",
1111            project_dir.display()
1112        )
1113    })?;
1114    let binary = build_desktop_binary(&project_dir, release)?;
1115    let profile = if release { "release" } else { "debug" };
1116    let app_root = project_dir
1117        .join(".fission/run/windows")
1118        .join(profile)
1119        .join(sanitize_file_stem(&project.app.name));
1120    if app_root.exists() {
1121        fs::remove_dir_all(&app_root).with_context(|| {
1122            format!(
1123                "failed to clear previous Windows run bundle at {}",
1124                app_root.display()
1125            )
1126        })?;
1127    }
1128    fs::create_dir_all(&app_root)?;
1129    let executable = app_root.join(platform_executable_name(&binary.executable_name));
1130    fs::copy(&binary.path, &executable).with_context(|| {
1131        format!(
1132            "failed to copy {} into Windows development bundle",
1133            binary.path.display()
1134        )
1135    })?;
1136    if let Some(icon) = resolve_app_icon(&project_dir, Target::Windows)? {
1137        let extension = normalized_extension(&icon.path)?;
1138        let destination = app_root.join(format!("app-icon.{extension}"));
1139        fs::copy(&icon.path, &destination).with_context(|| {
1140            format!(
1141                "failed to copy Windows app icon {} to {}",
1142                icon.path.display(),
1143                destination.display()
1144            )
1145        })?;
1146    }
1147    fs::write(
1148        app_root.join(format!(
1149            "{}.manifest",
1150            platform_executable_name(&binary.executable_name)
1151        )),
1152        render_windows_development_manifest(project),
1153    )?;
1154    Ok(DesktopRunApp { executable })
1155}
1156
1157fn run_macos_app_bundle(app: &MacosRunApp, options: &RunOptions) -> Result<()> {
1158    let log_path = detached_log_path(&options.project_dir, "desktop");
1159    if let Some(parent) = log_path.parent() {
1160        fs::create_dir_all(parent)?;
1161    }
1162    File::create(&log_path)
1163        .with_context(|| format!("failed to create log file {}", log_path.display()))?;
1164
1165    let mut command = Command::new("open");
1166    command.arg("-n");
1167    if !options.detach {
1168        command.arg("-W");
1169    }
1170    command
1171        .arg("--stdout")
1172        .arg(&log_path)
1173        .arg("--stderr")
1174        .arg(&log_path);
1175    for (key, value) in macos_forwarded_env() {
1176        command.arg("--env").arg(format!("{key}={value}"));
1177    }
1178    command.arg(&app.bundle);
1179
1180    if options.detach {
1181        let status = command
1182            .status()
1183            .context("failed to launch macOS app bundle")?;
1184        if !status.success() {
1185            bail!("macOS app launch failed with {status}");
1186        }
1187        println!(
1188            "Started {}. Logs: {}",
1189            app.bundle.display(),
1190            log_path.display()
1191        );
1192        Ok(())
1193    } else {
1194        println!(
1195            "Launching {}. Logs: {}",
1196            app.bundle.display(),
1197            log_path.display()
1198        );
1199        run_status(&mut command, "macOS app")
1200    }
1201}
1202
1203fn macos_forwarded_env() -> Vec<(String, String)> {
1204    env::vars()
1205        .filter(|(key, _)| {
1206            key == "RUST_BACKTRACE" || key.starts_with("FISSION_") || key.starts_with("RUST_LOG")
1207        })
1208        .collect()
1209}
1210
1211fn render_macos_run_info_plist(
1212    project: &FissionProject,
1213    binary: &DesktopBinary,
1214    app_name: &str,
1215) -> String {
1216    format!(
1217        r#"<?xml version="1.0" encoding="UTF-8"?>
1218<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1219<plist version="1.0">
1220<dict>
1221  <key>CFBundleIdentifier</key>
1222  <string>{}</string>
1223  <key>CFBundleName</key>
1224  <string>{}</string>
1225  <key>CFBundleDisplayName</key>
1226  <string>{}</string>
1227  <key>CFBundleExecutable</key>
1228  <string>{}</string>
1229  <key>CFBundlePackageType</key>
1230  <string>APPL</string>
1231  <key>CFBundleShortVersionString</key>
1232  <string>{}</string>
1233  <key>CFBundleVersion</key>
1234  <string>{}</string>
1235  <key>LSMinimumSystemVersion</key>
1236  <string>13.0</string>
1237  <key>CFBundleIconFile</key>
1238  <string>AppIcon</string>
1239  <key>NSHighResolutionCapable</key>
1240  <true/>
1241{}
1242</dict>
1243</plist>
1244"#,
1245        escape_xml(&project.app.app_id),
1246        escape_xml(app_name),
1247        escape_xml(app_name),
1248        escape_xml(&binary.executable_name),
1249        escape_xml(&binary.version),
1250        escape_xml(&binary.version),
1251        render_macos_run_capability_plist_entries(project)
1252    )
1253}
1254
1255fn render_macos_run_capability_plist_entries(project: &FissionProject) -> String {
1256    let mut out = String::new();
1257    if project
1258        .capabilities
1259        .contains(&PlatformCapability::Bluetooth)
1260    {
1261        out.push_str(
1262            "  <key>NSBluetoothAlwaysUsageDescription</key>\n  <string>This app uses Bluetooth when you request nearby-device features.</string>\n",
1263        );
1264    }
1265    if project.capabilities.contains(&PlatformCapability::Camera)
1266        || project
1267            .capabilities
1268            .contains(&PlatformCapability::BarcodeScanner)
1269    {
1270        out.push_str(
1271            "  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera when you request camera or barcode features.</string>\n",
1272        );
1273    }
1274    if project
1275        .capabilities
1276        .contains(&PlatformCapability::Geolocation)
1277        || project.capabilities.contains(&PlatformCapability::Wifi)
1278    {
1279        out.push_str(
1280            "  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses location information when you request location-aware or Wi-Fi features.</string>\n",
1281        );
1282    }
1283    if project
1284        .capabilities
1285        .contains(&PlatformCapability::Microphone)
1286    {
1287        out.push_str(
1288            "  <key>NSMicrophoneUsageDescription</key>\n  <string>This app uses the microphone when you request audio capture.</string>\n",
1289        );
1290    }
1291    out
1292}
1293
1294fn macos_display_name(name: &str) -> String {
1295    let mut out = String::new();
1296    let mut uppercase_next = true;
1297    for ch in name.chars() {
1298        match ch {
1299            '-' | '_' | ' ' => {
1300                uppercase_next = true;
1301                if !out.ends_with(' ') && !out.is_empty() {
1302                    out.push(' ');
1303                }
1304            }
1305            _ if uppercase_next => {
1306                out.extend(ch.to_uppercase());
1307                uppercase_next = false;
1308            }
1309            _ => out.push(ch),
1310        }
1311    }
1312    out.trim().to_string()
1313}
1314
1315fn render_linux_desktop_entry(project: &FissionProject, executable: &Path) -> String {
1316    format!(
1317        "[Desktop Entry]\nType=Application\nName={}\nExec={}\nIcon={}\nStartupNotify=true\nStartupWMClass={}\nCategories=Utility;\n",
1318        escape_desktop_entry(&macos_display_name(&project.app.name)),
1319        escape_desktop_entry(&executable.display().to_string()),
1320        escape_desktop_entry(&project.app.app_id),
1321        escape_desktop_entry(&project.app.app_id)
1322    )
1323}
1324
1325fn render_windows_development_manifest(project: &FissionProject) -> String {
1326    format!(
1327        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1328<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
1329  <assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="{}" type="win32"/>
1330  <description>{}</description>
1331  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
1332    <security>
1333      <requestedPrivileges>
1334        <requestedExecutionLevel level="asInvoker" uiAccess="false"/>
1335      </requestedPrivileges>
1336    </security>
1337  </trustInfo>
1338  <application xmlns="urn:schemas-microsoft-com:asm.v3">
1339    <windowsSettings>
1340      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
1341      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
1342    </windowsSettings>
1343  </application>
1344</assembly>
1345"#,
1346        escape_xml(&project.app.app_id),
1347        escape_xml(&macos_display_name(&project.app.name))
1348    )
1349}
1350
1351fn escape_desktop_entry(value: &str) -> String {
1352    value
1353        .replace('\\', "\\\\")
1354        .replace('\n', "\\n")
1355        .replace('\r', "")
1356}
1357
1358fn sanitize_file_stem(value: &str) -> String {
1359    let sanitized = value
1360        .chars()
1361        .map(|ch| {
1362            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1363                ch.to_ascii_lowercase()
1364            } else {
1365                '-'
1366            }
1367        })
1368        .collect::<String>();
1369    let trimmed = sanitized.trim_matches(['-', '.', '_']);
1370    if trimmed.is_empty() {
1371        "app".to_string()
1372    } else {
1373        trimmed.to_string()
1374    }
1375}
1376
1377#[cfg(unix)]
1378fn copy_unix_mode(source: &Path, dest: &Path) -> Result<()> {
1379    use std::os::unix::fs::PermissionsExt;
1380    let mode = fs::metadata(source)?.permissions().mode();
1381    fs::set_permissions(dest, fs::Permissions::from_mode(mode))?;
1382    Ok(())
1383}
1384
1385#[cfg(not(unix))]
1386fn copy_unix_mode(_source: &Path, _dest: &Path) -> Result<()> {
1387    Ok(())
1388}
1389
1390fn platform_executable_name(name: &str) -> String {
1391    if cfg!(target_os = "windows") {
1392        format!("{name}.exe")
1393    } else {
1394        name.to_string()
1395    }
1396}
1397
1398fn escape_xml(value: &str) -> String {
1399    value
1400        .replace('&', "&amp;")
1401        .replace('<', "&lt;")
1402        .replace('>', "&gt;")
1403        .replace('"', "&quot;")
1404}
1405
1406fn build_desktop(project_dir: &Path, release: bool) -> Result<()> {
1407    build_desktop_binary(project_dir, release).map(|_| ())
1408}
1409
1410fn run_target_script<F>(project_dir: &Path, relative_script: &str, configure: F) -> Result<()>
1411where
1412    F: FnOnce(&mut Command),
1413{
1414    let script = project_dir.join(relative_script);
1415    let mut command = command_for_script(&script)?;
1416    command.current_dir(project_dir);
1417    configure(&mut command);
1418    run_status(&mut command, relative_script)
1419}
1420
1421fn run_status(command: &mut Command, label: &str) -> Result<()> {
1422    let status = command
1423        .status()
1424        .with_context(|| format!("failed to run {label}"))?;
1425    if !status.success() {
1426        bail!("{label} failed with {status}");
1427    }
1428    Ok(())
1429}
1430
1431fn command_for_script(script: &Path) -> Result<Command> {
1432    if !script.exists() {
1433        bail!("script is missing at {}", script.display());
1434    }
1435    let script = fs::canonicalize(script)
1436        .with_context(|| format!("failed to resolve script {}", script.display()))?;
1437    if cfg!(windows) {
1438        if find_in_path("bash").is_none() {
1439            bail!(
1440                "running {} on Windows currently requires bash on PATH; install Git for Windows or run the equivalent command in WSL",
1441                script.display()
1442            );
1443        }
1444        let mut command = Command::new("bash");
1445        command.arg(script);
1446        Ok(command)
1447    } else {
1448        Ok(Command::new(script))
1449    }
1450}
1451
1452fn detached_log_path(project_dir: &Path, name: &str) -> PathBuf {
1453    project_dir.join(".fission/run").join(format!("{name}.log"))
1454}
1455
1456fn open_log(path: &Path) -> Result<File> {
1457    if let Some(parent) = path.parent() {
1458        fs::create_dir_all(parent)?;
1459    }
1460    OpenOptions::new()
1461        .create(true)
1462        .append(true)
1463        .open(path)
1464        .with_context(|| format!("failed to open log file {}", path.display()))
1465}
1466
1467fn discover_ios_simulators() -> Vec<Device> {
1468    if !cfg!(target_os = "macos") || find_in_path("xcrun").is_none() {
1469        return Vec::new();
1470    }
1471    let output = match Command::new("xcrun")
1472        .args(["simctl", "list", "devices", "available", "-j"])
1473        .output()
1474    {
1475        Ok(output) if output.status.success() => output,
1476        _ => return Vec::new(),
1477    };
1478    let payload: serde_json::Value = match serde_json::from_slice(&output.stdout) {
1479        Ok(payload) => payload,
1480        Err(_) => return Vec::new(),
1481    };
1482    let mut devices = Vec::new();
1483    if let Some(groups) = payload.get("devices").and_then(|value| value.as_object()) {
1484        for (runtime, entries) in groups {
1485            if !runtime.contains("SimRuntime.iOS") {
1486                continue;
1487            }
1488            let Some(entries) = entries.as_array() else {
1489                continue;
1490            };
1491            for entry in entries {
1492                let name = entry
1493                    .get("name")
1494                    .and_then(|value| value.as_str())
1495                    .unwrap_or("iOS Simulator");
1496                if !name.contains("iPhone") {
1497                    continue;
1498                }
1499                let Some(udid) = entry.get("udid").and_then(|value| value.as_str()) else {
1500                    continue;
1501                };
1502                let state = entry
1503                    .get("state")
1504                    .and_then(|value| value.as_str())
1505                    .unwrap_or("unknown");
1506                devices.push(Device {
1507                    id: udid.to_string(),
1508                    name: name.to_string(),
1509                    target: Target::Ios,
1510                    kind: "ios-simulator".to_string(),
1511                    status: state.to_ascii_lowercase(),
1512                    detail: runtime
1513                        .rsplit('.')
1514                        .next()
1515                        .unwrap_or(runtime)
1516                        .replace('-', " "),
1517                    available: true,
1518                });
1519            }
1520        }
1521    }
1522    devices
1523}
1524
1525fn discover_android_devices() -> Vec<Device> {
1526    let mut devices = Vec::new();
1527    let Ok(adb) = adb_path() else {
1528        return devices;
1529    };
1530    if let Ok(output) = Command::new(&adb).arg("devices").arg("-l").output() {
1531        if output.status.success() {
1532            let stdout = String::from_utf8_lossy(&output.stdout);
1533            for line in stdout.lines().skip(1) {
1534                let line = line.trim();
1535                if line.is_empty() {
1536                    continue;
1537                }
1538                let mut parts = line.split_whitespace();
1539                let Some(serial) = parts.next() else { continue };
1540                let status = parts.next().unwrap_or("unknown");
1541                let detail = parts.collect::<Vec<_>>().join(" ");
1542                devices.push(Device {
1543                    id: serial.to_string(),
1544                    name: if serial.starts_with("emulator-") {
1545                        "Android Emulator"
1546                    } else {
1547                        "Android Device"
1548                    }
1549                    .to_string(),
1550                    target: Target::Android,
1551                    kind: if serial.starts_with("emulator-") {
1552                        "android-emulator"
1553                    } else {
1554                        "android-device"
1555                    }
1556                    .to_string(),
1557                    status: status.to_string(),
1558                    detail,
1559                    available: status == "device",
1560                });
1561            }
1562        }
1563    }
1564
1565    if let Some(avdmanager) = android_tool("cmdline-tools/latest/bin/avdmanager") {
1566        if let Ok(output) = Command::new(avdmanager).args(["list", "avd"]).output() {
1567            if output.status.success() {
1568                let stdout = String::from_utf8_lossy(&output.stdout);
1569                for line in stdout.lines() {
1570                    let line = line.trim();
1571                    if let Some(name) = line.strip_prefix("Name:") {
1572                        let name = name.trim();
1573                        devices.push(Device {
1574                            id: format!("android-avd:{name}"),
1575                            name: name.to_string(),
1576                            target: Target::Android,
1577                            kind: "android-avd".to_string(),
1578                            status: "configured".to_string(),
1579                            detail: "stopped emulator profile".to_string(),
1580                            available: true,
1581                        });
1582                    }
1583                }
1584            }
1585        }
1586    }
1587    devices
1588}
1589
1590fn wait_for_android_pid(
1591    adb: &Path,
1592    serial: &str,
1593    app_id: &str,
1594    timeout: Duration,
1595) -> Result<String> {
1596    let start = Instant::now();
1597    while start.elapsed() < timeout {
1598        let output = Command::new(adb)
1599            .arg("-s")
1600            .arg(serial)
1601            .arg("shell")
1602            .arg("pidof")
1603            .arg(app_id)
1604            .output();
1605        if let Ok(output) = output {
1606            if output.status.success() {
1607                let pid = String::from_utf8_lossy(&output.stdout).trim().to_string();
1608                if !pid.is_empty() {
1609                    return Ok(pid);
1610                }
1611            }
1612        }
1613        std::thread::sleep(Duration::from_millis(500));
1614    }
1615    bail!("timed out waiting for Android process `{app_id}` on {serial}")
1616}
1617
1618fn first_android_serial() -> Option<String> {
1619    let adb = adb_path().ok()?;
1620    let output = Command::new(adb).arg("devices").output().ok()?;
1621    if !output.status.success() {
1622        return None;
1623    }
1624    String::from_utf8_lossy(&output.stdout)
1625        .lines()
1626        .skip(1)
1627        .find_map(|line| {
1628            let mut parts = line.split_whitespace();
1629            let serial = parts.next()?;
1630            let status = parts.next()?;
1631            (status == "device").then(|| serial.to_string())
1632        })
1633}
1634
1635fn adb_path() -> Result<PathBuf> {
1636    android_tool("platform-tools/adb")
1637        .context("Android adb was not found; run `fission doctor android`")
1638}
1639
1640fn android_tool(relative: &str) -> Option<PathBuf> {
1641    let home = android_home();
1642    let path = home.join(relative);
1643    if path.exists() {
1644        return Some(path);
1645    }
1646    let exe = home.join(format!("{relative}.exe"));
1647    if exe.exists() {
1648        return Some(exe);
1649    }
1650    None
1651}
1652
1653fn android_home() -> PathBuf {
1654    env::var_os("ANDROID_HOME")
1655        .or_else(|| env::var_os("ANDROID_SDK_ROOT"))
1656        .map(PathBuf::from)
1657        .unwrap_or_else(default_android_home)
1658}
1659
1660fn default_android_home() -> PathBuf {
1661    let home = env::var_os("HOME")
1662        .or_else(|| env::var_os("USERPROFILE"))
1663        .map(PathBuf::from)
1664        .unwrap_or_else(|| PathBuf::from("."));
1665    if cfg!(target_os = "macos") {
1666        home.join("Library/Android/sdk")
1667    } else if cfg!(target_os = "windows") {
1668        env::var_os("LOCALAPPDATA")
1669            .map(PathBuf::from)
1670            .unwrap_or(home)
1671            .join("Android/Sdk")
1672    } else {
1673        home.join("Android/Sdk")
1674    }
1675}
1676
1677fn detect_chrome() -> Option<PathBuf> {
1678    for var in ["FISSION_CHROME", "CHROME", "CHROME_BIN"] {
1679        if let Ok(value) = env::var(var) {
1680            let path = PathBuf::from(value);
1681            if path.exists() {
1682                return Some(path);
1683            }
1684        }
1685    }
1686    let names = if cfg!(target_os = "windows") {
1687        vec!["chrome.exe", "msedge.exe", "chromium.exe"]
1688    } else {
1689        vec!["google-chrome", "chromium", "chromium-browser", "chrome"]
1690    };
1691    for name in names {
1692        if let Some(path) = find_in_path(name) {
1693            return Some(path);
1694        }
1695    }
1696    for path in platform_chrome_paths() {
1697        if path.exists() {
1698            return Some(path);
1699        }
1700    }
1701    None
1702}
1703
1704fn platform_chrome_paths() -> Vec<PathBuf> {
1705    if cfg!(target_os = "macos") {
1706        vec![
1707            PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
1708            PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
1709            PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
1710        ]
1711    } else if cfg!(target_os = "windows") {
1712        let mut paths = Vec::new();
1713        if let Some(program_files) = env::var_os("PROGRAMFILES") {
1714            paths.push(PathBuf::from(program_files).join("Google/Chrome/Application/chrome.exe"));
1715        }
1716        if let Some(program_files_x86) = env::var_os("PROGRAMFILES(X86)") {
1717            paths.push(
1718                PathBuf::from(program_files_x86).join("Google/Chrome/Application/chrome.exe"),
1719            );
1720        }
1721        paths
1722    } else {
1723        Vec::new()
1724    }
1725}
1726
1727fn find_in_path(name: &str) -> Option<PathBuf> {
1728    let path = env::var_os("PATH")?;
1729    for dir in env::split_paths(&path) {
1730        let candidate = dir.join(name);
1731        if candidate.exists() {
1732            return Some(candidate);
1733        }
1734    }
1735    None
1736}
1737
1738fn require_host(target: Target) -> Result<()> {
1739    match target {
1740        Target::Ios if !cfg!(target_os = "macos") => {
1741            bail!("iOS simulator runs require macOS with Xcode")
1742        }
1743        _ => Ok(()),
1744    }
1745}
1746
1747fn require_desktop_host(target: Target) -> Result<()> {
1748    let host = host_desktop_target();
1749    if target != host {
1750        bail!(
1751            "desktop target `{}` cannot be built or run from this host with the current CLI; use `{}` on this machine",
1752            target.as_str(),
1753            host.as_str()
1754        );
1755    }
1756    Ok(())
1757}
1758
1759fn host_desktop_target() -> Target {
1760    if cfg!(target_os = "windows") {
1761        Target::Windows
1762    } else if cfg!(target_os = "macos") {
1763        Target::Macos
1764    } else {
1765        Target::Linux
1766    }
1767}
1768
1769fn desktop_name() -> &'static str {
1770    if cfg!(target_os = "windows") {
1771        "Windows desktop"
1772    } else if cfg!(target_os = "macos") {
1773        "macOS desktop"
1774    } else {
1775        "Linux desktop"
1776    }
1777}
1778
1779fn current_os_detail() -> String {
1780    env::consts::OS.to_string()
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785    use super::*;
1786    use std::collections::BTreeSet;
1787
1788    fn project() -> FissionProject {
1789        FissionProject {
1790            app: fission_command_core::AppConfig {
1791                name: "field-inspector".into(),
1792                app_id: "com.fission.examples.fieldinspector".into(),
1793                splash: None,
1794            },
1795            targets: BTreeSet::new(),
1796            capabilities: BTreeSet::new(),
1797        }
1798    }
1799
1800    #[test]
1801    fn linux_development_entry_uses_app_identity() {
1802        let entry = render_linux_desktop_entry(&project(), Path::new("/tmp/field-inspector"));
1803        assert!(entry.contains("Name=Field Inspector"));
1804        assert!(entry.contains("Icon=com.fission.examples.fieldinspector"));
1805        assert!(entry.contains("StartupWMClass=com.fission.examples.fieldinspector"));
1806    }
1807
1808    #[test]
1809    fn windows_development_manifest_uses_app_identity() {
1810        let manifest = render_windows_development_manifest(&project());
1811        assert!(manifest.contains("com.fission.examples.fieldinspector"));
1812        assert!(manifest.contains("PerMonitorV2"));
1813        assert!(manifest.contains("asInvoker"));
1814    }
1815}