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!(
712                "{}/rs.fission.runtime.FissionActivity",
713                project.app.app_id
714            )),
715        "Android launch",
716    )?;
717    if !options.detach {
718        attach_android_logs(project, device, true)?;
719    }
720    Ok(())
721}
722
723fn attach_ios_logs(project: &FissionProject, device: &Device, follow: bool) -> Result<()> {
724    require_host(Target::Ios)?;
725    let executable = ios_executable_name(project);
726    let predicate = format!("process == \"{}\"", executable);
727    let mut command = Command::new("xcrun");
728    command
729        .arg("simctl")
730        .arg("spawn")
731        .arg(&device.id)
732        .arg("log")
733        .arg(if follow { "stream" } else { "show" })
734        .arg("--style")
735        .arg("compact")
736        .arg("--predicate")
737        .arg(predicate);
738    println!(
739        "Attaching iOS logs for {}. Press Ctrl+C to stop.",
740        executable
741    );
742    run_status(&mut command, "iOS logs")
743}
744
745fn attach_android_logs(project: &FissionProject, device: &Device, follow: bool) -> Result<()> {
746    let adb = adb_path()?;
747    let pid = wait_for_android_pid(
748        &adb,
749        &device.id,
750        &project.app.app_id,
751        Duration::from_secs(20),
752    )?;
753    let mut command = Command::new(&adb);
754    command
755        .arg("-s")
756        .arg(&device.id)
757        .arg("logcat")
758        .arg("--pid")
759        .arg(pid);
760    if !follow {
761        command.arg("-d");
762    }
763    println!(
764        "Attaching Android logs for {} on {}. Press Ctrl+C to stop.",
765        project.app.app_id, device.id
766    );
767    run_status(&mut command, "Android logs")
768}
769
770fn tail_log_file(path: &Path, follow: bool) -> Result<()> {
771    if !path.exists() {
772        bail!(
773            "no detached log file found at {}; run with `--detach` first",
774            path.display()
775        );
776    }
777    if !follow {
778        print!("{}", fs::read_to_string(path)?);
779        return Ok(());
780    }
781
782    println!("Following {}. Press Ctrl+C to stop.", path.display());
783    let mut offset = 0u64;
784    loop {
785        let mut file = File::open(path)?;
786        file.seek_relative(offset as i64)?;
787        let mut buf = String::new();
788        file.read_to_string(&mut buf)?;
789        if !buf.is_empty() {
790            print!("{}", buf);
791            io::stdout().flush()?;
792            offset += buf.len() as u64;
793        }
794        std::thread::sleep(Duration::from_millis(500));
795    }
796}
797
798fn build_web(project_dir: &Path, release: bool) -> Result<()> {
799    let project_dir = fs::canonicalize(project_dir).with_context(|| {
800        format!(
801            "failed to resolve project directory {}",
802            project_dir.display()
803        )
804    })?;
805    let out_dir = project_dir.join("platforms/web/pkg");
806    let mut command = Command::new("wasm-pack");
807    command
808        .arg("build")
809        .arg(&project_dir)
810        .arg("--target")
811        .arg("web")
812        .arg("--out-dir")
813        .arg(out_dir);
814    command.arg(if release { "--release" } else { "--dev" });
815    run_status(&mut command, "web build")
816}
817
818fn package_android(project_dir: &Path, release: bool) -> Result<PathBuf> {
819    let script = project_dir.join("platforms/android/package-apk.sh");
820    let mut command = command_for_script(&script)?;
821    command.current_dir(project_dir);
822    if release {
823        command.env("ANDROID_PROFILE", "release");
824    }
825    let output = command
826        .output()
827        .context("failed to run Android package script")?;
828    if !output.status.success() {
829        io::stderr().write_all(&output.stderr).ok();
830        bail!("Android package failed with {}", output.status);
831    }
832    io::stderr().write_all(&output.stderr).ok();
833    let stdout = String::from_utf8_lossy(&output.stdout);
834    let apk = stdout
835        .lines()
836        .rev()
837        .find(|line| line.trim_end().ends_with(".apk"))
838        .map(|line| PathBuf::from(line.trim()))
839        .context("Android package script did not print an APK path")?;
840    Ok(apk)
841}
842
843fn run_child(mut command: Command, detach: bool, log_path: PathBuf) -> Result<()> {
844    if detach {
845        let log = open_log(&log_path)?;
846        let err = log.try_clone()?;
847        let child = command
848            .stdout(Stdio::from(log))
849            .stderr(Stdio::from(err))
850            .spawn()?;
851        println!("Started pid {}. Logs: {}", child.id(), log_path.display());
852        return Ok(());
853    }
854    let status = command.status()?;
855    if !status.success() {
856        bail!("command exited with {status}");
857    }
858    Ok(())
859}
860
861#[derive(Debug)]
862struct DesktopBinary {
863    version: String,
864    executable_name: String,
865    path: PathBuf,
866}
867
868#[derive(Debug)]
869struct MacosRunApp {
870    bundle: PathBuf,
871}
872
873#[derive(Debug)]
874struct DesktopRunApp {
875    executable: PathBuf,
876}
877
878#[derive(Debug, Deserialize)]
879struct CargoMetadata {
880    packages: Vec<CargoMetadataPackage>,
881    target_directory: PathBuf,
882}
883
884#[derive(Debug, Deserialize)]
885struct CargoMetadataPackage {
886    name: String,
887    version: String,
888    manifest_path: PathBuf,
889    targets: Vec<CargoMetadataTarget>,
890}
891
892#[derive(Debug, Deserialize)]
893struct CargoMetadataTarget {
894    name: String,
895    kind: Vec<String>,
896}
897
898fn build_desktop_binary(project_dir: &Path, release: bool) -> Result<DesktopBinary> {
899    let project_dir = fs::canonicalize(project_dir).with_context(|| {
900        format!(
901            "failed to resolve project directory {}",
902            project_dir.display()
903        )
904    })?;
905    let metadata = cargo_metadata(&project_dir)?;
906    let manifest_path = fs::canonicalize(project_dir.join("Cargo.toml")).with_context(|| {
907        format!(
908            "failed to resolve Cargo manifest at {}",
909            project_dir.join("Cargo.toml").display()
910        )
911    })?;
912    let package = metadata
913        .packages
914        .iter()
915        .find(|package| package.manifest_path == manifest_path)
916        .or_else(|| metadata.packages.first())
917        .context("cargo metadata did not include a package for this project")?;
918    let executable_name = package
919        .targets
920        .iter()
921        .find(|target| target.kind.iter().any(|kind| kind == "bin") && target.name == package.name)
922        .or_else(|| {
923            package
924                .targets
925                .iter()
926                .find(|target| target.kind.iter().any(|kind| kind == "bin"))
927        })
928        .map(|target| target.name.clone())
929        .with_context(|| {
930            format!(
931                "Cargo package `{}` does not define a binary target",
932                package.name
933            )
934        })?;
935
936    let mut command = Command::new("cargo");
937    command
938        .arg("build")
939        .arg("--manifest-path")
940        .arg(project_dir.join("Cargo.toml"))
941        .arg("--package")
942        .arg(&package.name)
943        .current_dir(project_dir);
944    if release {
945        command.arg("--release");
946    }
947    run_status(&mut command, "desktop build")?;
948
949    let profile = if release { "release" } else { "debug" };
950    let path = metadata
951        .target_directory
952        .join(profile)
953        .join(platform_executable_name(&executable_name));
954    if !path.exists() {
955        bail!(
956            "desktop build completed but expected binary is missing at {}",
957            path.display()
958        );
959    }
960
961    Ok(DesktopBinary {
962        version: package.version.clone(),
963        executable_name,
964        path,
965    })
966}
967
968fn cargo_metadata(project_dir: &Path) -> Result<CargoMetadata> {
969    let output = Command::new("cargo")
970        .arg("metadata")
971        .arg("--no-deps")
972        .arg("--format-version")
973        .arg("1")
974        .current_dir(project_dir)
975        .output()
976        .context("failed to run cargo metadata")?;
977    if !output.status.success() {
978        io::stderr().write_all(&output.stderr).ok();
979        bail!("cargo metadata failed with {}", output.status);
980    }
981    serde_json::from_slice(&output.stdout).context("failed to parse cargo metadata")
982}
983
984fn package_macos_run_app(
985    project: &FissionProject,
986    project_dir: &Path,
987    release: bool,
988) -> Result<MacosRunApp> {
989    let project_dir = fs::canonicalize(project_dir).with_context(|| {
990        format!(
991            "failed to resolve project directory {}",
992            project_dir.display()
993        )
994    })?;
995    let binary = build_desktop_binary(&project_dir, release)?;
996    let profile = if release { "release" } else { "debug" };
997    let app_name = macos_display_name(&project.app.name);
998    let app_bundle = project_dir
999        .join(".fission/run/macos")
1000        .join(profile)
1001        .join(format!("{app_name}.app"));
1002    let contents = app_bundle.join("Contents");
1003    let macos_dir = contents.join("MacOS");
1004    let resources_dir = contents.join("Resources");
1005    if app_bundle.exists() {
1006        fs::remove_dir_all(&app_bundle).with_context(|| {
1007            format!(
1008                "failed to clear previous macOS run bundle at {}",
1009                app_bundle.display()
1010            )
1011        })?;
1012    }
1013    fs::create_dir_all(&macos_dir)?;
1014    fs::create_dir_all(&resources_dir)?;
1015
1016    let executable = macos_dir.join(&binary.executable_name);
1017    fs::copy(&binary.path, &executable).with_context(|| {
1018        format!(
1019            "failed to copy {} into macOS app bundle",
1020            binary.path.display()
1021        )
1022    })?;
1023    if let Some(icon) = resolve_app_icon(&project_dir, Target::Macos)? {
1024        let extension = normalized_extension(&icon.path)?;
1025        let destination = resources_dir.join(format!("AppIcon.{extension}"));
1026        fs::copy(&icon.path, &destination).with_context(|| {
1027            format!(
1028                "failed to copy macOS app icon {} to {}",
1029                icon.path.display(),
1030                destination.display()
1031            )
1032        })?;
1033    }
1034
1035    fs::write(
1036        contents.join("Info.plist"),
1037        render_macos_run_info_plist(project, &binary, &app_name),
1038    )?;
1039    fs::write(contents.join("PkgInfo"), "APPL????")?;
1040
1041    Ok(MacosRunApp { bundle: app_bundle })
1042}
1043
1044fn package_linux_run_app(
1045    project: &FissionProject,
1046    project_dir: &Path,
1047    release: bool,
1048) -> Result<DesktopRunApp> {
1049    let project_dir = fs::canonicalize(project_dir).with_context(|| {
1050        format!(
1051            "failed to resolve project directory {}",
1052            project_dir.display()
1053        )
1054    })?;
1055    let binary = build_desktop_binary(&project_dir, release)?;
1056    let profile = if release { "release" } else { "debug" };
1057    let app_root = project_dir
1058        .join(".fission/run/linux")
1059        .join(profile)
1060        .join(sanitize_file_stem(&project.app.name));
1061    if app_root.exists() {
1062        fs::remove_dir_all(&app_root).with_context(|| {
1063            format!(
1064                "failed to clear previous Linux run bundle at {}",
1065                app_root.display()
1066            )
1067        })?;
1068    }
1069    let bin_dir = app_root.join("bin");
1070    let applications_dir = app_root.join("share/applications");
1071    fs::create_dir_all(&bin_dir)?;
1072    fs::create_dir_all(&applications_dir)?;
1073
1074    let executable = bin_dir.join(&binary.executable_name);
1075    fs::copy(&binary.path, &executable).with_context(|| {
1076        format!(
1077            "failed to copy {} into Linux development bundle",
1078            binary.path.display()
1079        )
1080    })?;
1081    copy_unix_mode(&binary.path, &executable).ok();
1082    if let Some(icon) = resolve_app_icon(&project_dir, Target::Linux)? {
1083        let extension = normalized_extension(&icon.path)?;
1084        let icons_dir = if extension == "svg" {
1085            app_root.join("share/icons/hicolor/scalable/apps")
1086        } else {
1087            app_root.join("share/icons/hicolor/512x512/apps")
1088        };
1089        fs::create_dir_all(&icons_dir)?;
1090        let destination = icons_dir.join(format!("{}.{}", project.app.app_id, extension));
1091        fs::copy(&icon.path, &destination).with_context(|| {
1092            format!(
1093                "failed to copy Linux app icon {} to {}",
1094                icon.path.display(),
1095                destination.display()
1096            )
1097        })?;
1098    }
1099    fs::write(
1100        applications_dir.join(format!("{}.desktop", project.app.app_id)),
1101        render_linux_desktop_entry(project, &executable),
1102    )?;
1103    Ok(DesktopRunApp { executable })
1104}
1105
1106fn package_windows_run_app(
1107    project: &FissionProject,
1108    project_dir: &Path,
1109    release: bool,
1110) -> Result<DesktopRunApp> {
1111    let project_dir = fs::canonicalize(project_dir).with_context(|| {
1112        format!(
1113            "failed to resolve project directory {}",
1114            project_dir.display()
1115        )
1116    })?;
1117    let binary = build_desktop_binary(&project_dir, release)?;
1118    let profile = if release { "release" } else { "debug" };
1119    let app_root = project_dir
1120        .join(".fission/run/windows")
1121        .join(profile)
1122        .join(sanitize_file_stem(&project.app.name));
1123    if app_root.exists() {
1124        fs::remove_dir_all(&app_root).with_context(|| {
1125            format!(
1126                "failed to clear previous Windows run bundle at {}",
1127                app_root.display()
1128            )
1129        })?;
1130    }
1131    fs::create_dir_all(&app_root)?;
1132    let executable = app_root.join(platform_executable_name(&binary.executable_name));
1133    fs::copy(&binary.path, &executable).with_context(|| {
1134        format!(
1135            "failed to copy {} into Windows development bundle",
1136            binary.path.display()
1137        )
1138    })?;
1139    if let Some(icon) = resolve_app_icon(&project_dir, Target::Windows)? {
1140        let extension = normalized_extension(&icon.path)?;
1141        let destination = app_root.join(format!("app-icon.{extension}"));
1142        fs::copy(&icon.path, &destination).with_context(|| {
1143            format!(
1144                "failed to copy Windows app icon {} to {}",
1145                icon.path.display(),
1146                destination.display()
1147            )
1148        })?;
1149    }
1150    fs::write(
1151        app_root.join(format!(
1152            "{}.manifest",
1153            platform_executable_name(&binary.executable_name)
1154        )),
1155        render_windows_development_manifest(project),
1156    )?;
1157    Ok(DesktopRunApp { executable })
1158}
1159
1160fn run_macos_app_bundle(app: &MacosRunApp, options: &RunOptions) -> Result<()> {
1161    let log_path = detached_log_path(&options.project_dir, "desktop");
1162    if let Some(parent) = log_path.parent() {
1163        fs::create_dir_all(parent)?;
1164    }
1165    File::create(&log_path)
1166        .with_context(|| format!("failed to create log file {}", log_path.display()))?;
1167
1168    let mut command = Command::new("open");
1169    command.arg("-n");
1170    if !options.detach {
1171        command.arg("-W");
1172    }
1173    command
1174        .arg("--stdout")
1175        .arg(&log_path)
1176        .arg("--stderr")
1177        .arg(&log_path);
1178    for (key, value) in macos_forwarded_env() {
1179        command.arg("--env").arg(format!("{key}={value}"));
1180    }
1181    command.arg(&app.bundle);
1182
1183    if options.detach {
1184        let status = command
1185            .status()
1186            .context("failed to launch macOS app bundle")?;
1187        if !status.success() {
1188            bail!("macOS app launch failed with {status}");
1189        }
1190        println!(
1191            "Started {}. Logs: {}",
1192            app.bundle.display(),
1193            log_path.display()
1194        );
1195        Ok(())
1196    } else {
1197        println!(
1198            "Launching {}. Logs: {}",
1199            app.bundle.display(),
1200            log_path.display()
1201        );
1202        run_status(&mut command, "macOS app")
1203    }
1204}
1205
1206fn macos_forwarded_env() -> Vec<(String, String)> {
1207    env::vars()
1208        .filter(|(key, _)| {
1209            key == "RUST_BACKTRACE" || key.starts_with("FISSION_") || key.starts_with("RUST_LOG")
1210        })
1211        .collect()
1212}
1213
1214fn render_macos_run_info_plist(
1215    project: &FissionProject,
1216    binary: &DesktopBinary,
1217    app_name: &str,
1218) -> String {
1219    format!(
1220        r#"<?xml version="1.0" encoding="UTF-8"?>
1221<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1222<plist version="1.0">
1223<dict>
1224  <key>CFBundleIdentifier</key>
1225  <string>{}</string>
1226  <key>CFBundleName</key>
1227  <string>{}</string>
1228  <key>CFBundleDisplayName</key>
1229  <string>{}</string>
1230  <key>CFBundleExecutable</key>
1231  <string>{}</string>
1232  <key>CFBundlePackageType</key>
1233  <string>APPL</string>
1234  <key>CFBundleShortVersionString</key>
1235  <string>{}</string>
1236  <key>CFBundleVersion</key>
1237  <string>{}</string>
1238  <key>LSMinimumSystemVersion</key>
1239  <string>13.0</string>
1240  <key>CFBundleIconFile</key>
1241  <string>AppIcon</string>
1242  <key>NSHighResolutionCapable</key>
1243  <true/>
1244{}
1245</dict>
1246</plist>
1247"#,
1248        escape_xml(&project.app.app_id),
1249        escape_xml(app_name),
1250        escape_xml(app_name),
1251        escape_xml(&binary.executable_name),
1252        escape_xml(&binary.version),
1253        escape_xml(&binary.version),
1254        render_macos_run_capability_plist_entries(project)
1255    )
1256}
1257
1258fn render_macos_run_capability_plist_entries(project: &FissionProject) -> String {
1259    let mut out = String::new();
1260    if project
1261        .capabilities
1262        .contains(&PlatformCapability::Bluetooth)
1263    {
1264        out.push_str(
1265            "  <key>NSBluetoothAlwaysUsageDescription</key>\n  <string>This app uses Bluetooth when you request nearby-device features.</string>\n",
1266        );
1267    }
1268    if project.capabilities.contains(&PlatformCapability::Camera)
1269        || project
1270            .capabilities
1271            .contains(&PlatformCapability::BarcodeScanner)
1272    {
1273        out.push_str(
1274            "  <key>NSCameraUsageDescription</key>\n  <string>This app uses the camera when you request camera or barcode features.</string>\n",
1275        );
1276    }
1277    if project
1278        .capabilities
1279        .contains(&PlatformCapability::Geolocation)
1280        || project.capabilities.contains(&PlatformCapability::Wifi)
1281    {
1282        out.push_str(
1283            "  <key>NSLocationWhenInUseUsageDescription</key>\n  <string>This app uses location information when you request location-aware or Wi-Fi features.</string>\n",
1284        );
1285    }
1286    if project
1287        .capabilities
1288        .contains(&PlatformCapability::Microphone)
1289    {
1290        out.push_str(
1291            "  <key>NSMicrophoneUsageDescription</key>\n  <string>This app uses the microphone when you request audio capture.</string>\n",
1292        );
1293    }
1294    out
1295}
1296
1297fn macos_display_name(name: &str) -> String {
1298    let mut out = String::new();
1299    let mut uppercase_next = true;
1300    for ch in name.chars() {
1301        match ch {
1302            '-' | '_' | ' ' => {
1303                uppercase_next = true;
1304                if !out.ends_with(' ') && !out.is_empty() {
1305                    out.push(' ');
1306                }
1307            }
1308            _ if uppercase_next => {
1309                out.extend(ch.to_uppercase());
1310                uppercase_next = false;
1311            }
1312            _ => out.push(ch),
1313        }
1314    }
1315    out.trim().to_string()
1316}
1317
1318fn render_linux_desktop_entry(project: &FissionProject, executable: &Path) -> String {
1319    format!(
1320        "[Desktop Entry]\nType=Application\nName={}\nExec={}\nIcon={}\nStartupNotify=true\nStartupWMClass={}\nCategories=Utility;\n",
1321        escape_desktop_entry(&macos_display_name(&project.app.name)),
1322        escape_desktop_entry(&executable.display().to_string()),
1323        escape_desktop_entry(&project.app.app_id),
1324        escape_desktop_entry(&project.app.app_id)
1325    )
1326}
1327
1328fn render_windows_development_manifest(project: &FissionProject) -> String {
1329    format!(
1330        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1331<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
1332  <assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="{}" type="win32"/>
1333  <description>{}</description>
1334  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
1335    <security>
1336      <requestedPrivileges>
1337        <requestedExecutionLevel level="asInvoker" uiAccess="false"/>
1338      </requestedPrivileges>
1339    </security>
1340  </trustInfo>
1341  <application xmlns="urn:schemas-microsoft-com:asm.v3">
1342    <windowsSettings>
1343      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
1344      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
1345    </windowsSettings>
1346  </application>
1347</assembly>
1348"#,
1349        escape_xml(&project.app.app_id),
1350        escape_xml(&macos_display_name(&project.app.name))
1351    )
1352}
1353
1354fn escape_desktop_entry(value: &str) -> String {
1355    value
1356        .replace('\\', "\\\\")
1357        .replace('\n', "\\n")
1358        .replace('\r', "")
1359}
1360
1361fn sanitize_file_stem(value: &str) -> String {
1362    let sanitized = value
1363        .chars()
1364        .map(|ch| {
1365            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1366                ch.to_ascii_lowercase()
1367            } else {
1368                '-'
1369            }
1370        })
1371        .collect::<String>();
1372    let trimmed = sanitized.trim_matches(['-', '.', '_']);
1373    if trimmed.is_empty() {
1374        "app".to_string()
1375    } else {
1376        trimmed.to_string()
1377    }
1378}
1379
1380#[cfg(unix)]
1381fn copy_unix_mode(source: &Path, dest: &Path) -> Result<()> {
1382    use std::os::unix::fs::PermissionsExt;
1383    let mode = fs::metadata(source)?.permissions().mode();
1384    fs::set_permissions(dest, fs::Permissions::from_mode(mode))?;
1385    Ok(())
1386}
1387
1388#[cfg(not(unix))]
1389fn copy_unix_mode(_source: &Path, _dest: &Path) -> Result<()> {
1390    Ok(())
1391}
1392
1393fn platform_executable_name(name: &str) -> String {
1394    if cfg!(target_os = "windows") {
1395        format!("{name}.exe")
1396    } else {
1397        name.to_string()
1398    }
1399}
1400
1401fn escape_xml(value: &str) -> String {
1402    value
1403        .replace('&', "&amp;")
1404        .replace('<', "&lt;")
1405        .replace('>', "&gt;")
1406        .replace('"', "&quot;")
1407}
1408
1409fn build_desktop(project_dir: &Path, release: bool) -> Result<()> {
1410    build_desktop_binary(project_dir, release).map(|_| ())
1411}
1412
1413fn run_target_script<F>(project_dir: &Path, relative_script: &str, configure: F) -> Result<()>
1414where
1415    F: FnOnce(&mut Command),
1416{
1417    let script = project_dir.join(relative_script);
1418    let mut command = command_for_script(&script)?;
1419    command.current_dir(project_dir);
1420    configure(&mut command);
1421    run_status(&mut command, relative_script)
1422}
1423
1424fn run_status(command: &mut Command, label: &str) -> Result<()> {
1425    let status = command
1426        .status()
1427        .with_context(|| format!("failed to run {label}"))?;
1428    if !status.success() {
1429        bail!("{label} failed with {status}");
1430    }
1431    Ok(())
1432}
1433
1434fn command_for_script(script: &Path) -> Result<Command> {
1435    if !script.exists() {
1436        bail!("script is missing at {}", script.display());
1437    }
1438    let script = fs::canonicalize(script)
1439        .with_context(|| format!("failed to resolve script {}", script.display()))?;
1440    if cfg!(windows) {
1441        if find_in_path("bash").is_none() {
1442            bail!(
1443                "running {} on Windows currently requires bash on PATH; install Git for Windows or run the equivalent command in WSL",
1444                script.display()
1445            );
1446        }
1447        let mut command = Command::new("bash");
1448        command.arg(script);
1449        Ok(command)
1450    } else {
1451        Ok(Command::new(script))
1452    }
1453}
1454
1455fn detached_log_path(project_dir: &Path, name: &str) -> PathBuf {
1456    project_dir.join(".fission/run").join(format!("{name}.log"))
1457}
1458
1459fn open_log(path: &Path) -> Result<File> {
1460    if let Some(parent) = path.parent() {
1461        fs::create_dir_all(parent)?;
1462    }
1463    OpenOptions::new()
1464        .create(true)
1465        .append(true)
1466        .open(path)
1467        .with_context(|| format!("failed to open log file {}", path.display()))
1468}
1469
1470fn discover_ios_simulators() -> Vec<Device> {
1471    if !cfg!(target_os = "macos") || find_in_path("xcrun").is_none() {
1472        return Vec::new();
1473    }
1474    let output = match Command::new("xcrun")
1475        .args(["simctl", "list", "devices", "available", "-j"])
1476        .output()
1477    {
1478        Ok(output) if output.status.success() => output,
1479        _ => return Vec::new(),
1480    };
1481    let payload: serde_json::Value = match serde_json::from_slice(&output.stdout) {
1482        Ok(payload) => payload,
1483        Err(_) => return Vec::new(),
1484    };
1485    let mut devices = Vec::new();
1486    if let Some(groups) = payload.get("devices").and_then(|value| value.as_object()) {
1487        for (runtime, entries) in groups {
1488            if !runtime.contains("SimRuntime.iOS") {
1489                continue;
1490            }
1491            let Some(entries) = entries.as_array() else {
1492                continue;
1493            };
1494            for entry in entries {
1495                let name = entry
1496                    .get("name")
1497                    .and_then(|value| value.as_str())
1498                    .unwrap_or("iOS Simulator");
1499                if !name.contains("iPhone") {
1500                    continue;
1501                }
1502                let Some(udid) = entry.get("udid").and_then(|value| value.as_str()) else {
1503                    continue;
1504                };
1505                let state = entry
1506                    .get("state")
1507                    .and_then(|value| value.as_str())
1508                    .unwrap_or("unknown");
1509                devices.push(Device {
1510                    id: udid.to_string(),
1511                    name: name.to_string(),
1512                    target: Target::Ios,
1513                    kind: "ios-simulator".to_string(),
1514                    status: state.to_ascii_lowercase(),
1515                    detail: runtime
1516                        .rsplit('.')
1517                        .next()
1518                        .unwrap_or(runtime)
1519                        .replace('-', " "),
1520                    available: true,
1521                });
1522            }
1523        }
1524    }
1525    devices
1526}
1527
1528fn discover_android_devices() -> Vec<Device> {
1529    let mut devices = Vec::new();
1530    let Ok(adb) = adb_path() else {
1531        return devices;
1532    };
1533    if let Ok(output) = Command::new(&adb).arg("devices").arg("-l").output() {
1534        if output.status.success() {
1535            let stdout = String::from_utf8_lossy(&output.stdout);
1536            for line in stdout.lines().skip(1) {
1537                let line = line.trim();
1538                if line.is_empty() {
1539                    continue;
1540                }
1541                let mut parts = line.split_whitespace();
1542                let Some(serial) = parts.next() else { continue };
1543                let status = parts.next().unwrap_or("unknown");
1544                let detail = parts.collect::<Vec<_>>().join(" ");
1545                devices.push(Device {
1546                    id: serial.to_string(),
1547                    name: if serial.starts_with("emulator-") {
1548                        "Android Emulator"
1549                    } else {
1550                        "Android Device"
1551                    }
1552                    .to_string(),
1553                    target: Target::Android,
1554                    kind: if serial.starts_with("emulator-") {
1555                        "android-emulator"
1556                    } else {
1557                        "android-device"
1558                    }
1559                    .to_string(),
1560                    status: status.to_string(),
1561                    detail,
1562                    available: status == "device",
1563                });
1564            }
1565        }
1566    }
1567
1568    if let Some(avdmanager) = android_tool("cmdline-tools/latest/bin/avdmanager") {
1569        if let Ok(output) = Command::new(avdmanager).args(["list", "avd"]).output() {
1570            if output.status.success() {
1571                let stdout = String::from_utf8_lossy(&output.stdout);
1572                for line in stdout.lines() {
1573                    let line = line.trim();
1574                    if let Some(name) = line.strip_prefix("Name:") {
1575                        let name = name.trim();
1576                        devices.push(Device {
1577                            id: format!("android-avd:{name}"),
1578                            name: name.to_string(),
1579                            target: Target::Android,
1580                            kind: "android-avd".to_string(),
1581                            status: "configured".to_string(),
1582                            detail: "stopped emulator profile".to_string(),
1583                            available: true,
1584                        });
1585                    }
1586                }
1587            }
1588        }
1589    }
1590    devices
1591}
1592
1593fn wait_for_android_pid(
1594    adb: &Path,
1595    serial: &str,
1596    app_id: &str,
1597    timeout: Duration,
1598) -> Result<String> {
1599    let start = Instant::now();
1600    while start.elapsed() < timeout {
1601        let output = Command::new(adb)
1602            .arg("-s")
1603            .arg(serial)
1604            .arg("shell")
1605            .arg("pidof")
1606            .arg(app_id)
1607            .output();
1608        if let Ok(output) = output {
1609            if output.status.success() {
1610                let pid = String::from_utf8_lossy(&output.stdout).trim().to_string();
1611                if !pid.is_empty() {
1612                    return Ok(pid);
1613                }
1614            }
1615        }
1616        std::thread::sleep(Duration::from_millis(500));
1617    }
1618    bail!("timed out waiting for Android process `{app_id}` on {serial}")
1619}
1620
1621fn first_android_serial() -> Option<String> {
1622    let adb = adb_path().ok()?;
1623    let output = Command::new(adb).arg("devices").output().ok()?;
1624    if !output.status.success() {
1625        return None;
1626    }
1627    String::from_utf8_lossy(&output.stdout)
1628        .lines()
1629        .skip(1)
1630        .find_map(|line| {
1631            let mut parts = line.split_whitespace();
1632            let serial = parts.next()?;
1633            let status = parts.next()?;
1634            (status == "device").then(|| serial.to_string())
1635        })
1636}
1637
1638fn adb_path() -> Result<PathBuf> {
1639    android_tool("platform-tools/adb")
1640        .context("Android adb was not found; run `fission doctor android`")
1641}
1642
1643fn android_tool(relative: &str) -> Option<PathBuf> {
1644    let home = android_home();
1645    let path = home.join(relative);
1646    if path.exists() {
1647        return Some(path);
1648    }
1649    let exe = home.join(format!("{relative}.exe"));
1650    if exe.exists() {
1651        return Some(exe);
1652    }
1653    None
1654}
1655
1656fn android_home() -> PathBuf {
1657    env::var_os("ANDROID_HOME")
1658        .or_else(|| env::var_os("ANDROID_SDK_ROOT"))
1659        .map(PathBuf::from)
1660        .unwrap_or_else(default_android_home)
1661}
1662
1663fn default_android_home() -> PathBuf {
1664    let home = env::var_os("HOME")
1665        .or_else(|| env::var_os("USERPROFILE"))
1666        .map(PathBuf::from)
1667        .unwrap_or_else(|| PathBuf::from("."));
1668    if cfg!(target_os = "macos") {
1669        home.join("Library/Android/sdk")
1670    } else if cfg!(target_os = "windows") {
1671        env::var_os("LOCALAPPDATA")
1672            .map(PathBuf::from)
1673            .unwrap_or(home)
1674            .join("Android/Sdk")
1675    } else {
1676        home.join("Android/Sdk")
1677    }
1678}
1679
1680fn detect_chrome() -> Option<PathBuf> {
1681    for var in ["FISSION_CHROME", "CHROME", "CHROME_BIN"] {
1682        if let Ok(value) = env::var(var) {
1683            let path = PathBuf::from(value);
1684            if path.exists() {
1685                return Some(path);
1686            }
1687        }
1688    }
1689    let names = if cfg!(target_os = "windows") {
1690        vec!["chrome.exe", "msedge.exe", "chromium.exe"]
1691    } else {
1692        vec!["google-chrome", "chromium", "chromium-browser", "chrome"]
1693    };
1694    for name in names {
1695        if let Some(path) = find_in_path(name) {
1696            return Some(path);
1697        }
1698    }
1699    for path in platform_chrome_paths() {
1700        if path.exists() {
1701            return Some(path);
1702        }
1703    }
1704    None
1705}
1706
1707fn platform_chrome_paths() -> Vec<PathBuf> {
1708    if cfg!(target_os = "macos") {
1709        vec![
1710            PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
1711            PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
1712            PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
1713        ]
1714    } else if cfg!(target_os = "windows") {
1715        let mut paths = Vec::new();
1716        if let Some(program_files) = env::var_os("PROGRAMFILES") {
1717            paths.push(PathBuf::from(program_files).join("Google/Chrome/Application/chrome.exe"));
1718        }
1719        if let Some(program_files_x86) = env::var_os("PROGRAMFILES(X86)") {
1720            paths.push(
1721                PathBuf::from(program_files_x86).join("Google/Chrome/Application/chrome.exe"),
1722            );
1723        }
1724        paths
1725    } else {
1726        Vec::new()
1727    }
1728}
1729
1730fn find_in_path(name: &str) -> Option<PathBuf> {
1731    let path = env::var_os("PATH")?;
1732    for dir in env::split_paths(&path) {
1733        let candidate = dir.join(name);
1734        if candidate.exists() {
1735            return Some(candidate);
1736        }
1737    }
1738    None
1739}
1740
1741fn require_host(target: Target) -> Result<()> {
1742    match target {
1743        Target::Ios if !cfg!(target_os = "macos") => {
1744            bail!("iOS simulator runs require macOS with Xcode")
1745        }
1746        _ => Ok(()),
1747    }
1748}
1749
1750fn require_desktop_host(target: Target) -> Result<()> {
1751    let host = host_desktop_target();
1752    if target != host {
1753        bail!(
1754            "desktop target `{}` cannot be built or run from this host with the current CLI; use `{}` on this machine",
1755            target.as_str(),
1756            host.as_str()
1757        );
1758    }
1759    Ok(())
1760}
1761
1762fn host_desktop_target() -> Target {
1763    if cfg!(target_os = "windows") {
1764        Target::Windows
1765    } else if cfg!(target_os = "macos") {
1766        Target::Macos
1767    } else {
1768        Target::Linux
1769    }
1770}
1771
1772fn desktop_name() -> &'static str {
1773    if cfg!(target_os = "windows") {
1774        "Windows desktop"
1775    } else if cfg!(target_os = "macos") {
1776        "macOS desktop"
1777    } else {
1778        "Linux desktop"
1779    }
1780}
1781
1782fn current_os_detail() -> String {
1783    env::consts::OS.to_string()
1784}
1785
1786#[cfg(test)]
1787mod tests {
1788    use super::*;
1789    use std::collections::BTreeSet;
1790
1791    fn project() -> FissionProject {
1792        FissionProject {
1793            app: fission_command_core::AppConfig {
1794                name: "field-inspector".into(),
1795                app_id: "com.fission.examples.fieldinspector".into(),
1796                splash: None,
1797            },
1798            targets: BTreeSet::new(),
1799            capabilities: BTreeSet::new(),
1800            native: Default::default(),
1801        }
1802    }
1803
1804    #[test]
1805    fn linux_development_entry_uses_app_identity() {
1806        let entry = render_linux_desktop_entry(&project(), Path::new("/tmp/field-inspector"));
1807        assert!(entry.contains("Name=Field Inspector"));
1808        assert!(entry.contains("Icon=com.fission.examples.fieldinspector"));
1809        assert!(entry.contains("StartupWMClass=com.fission.examples.fieldinspector"));
1810    }
1811
1812    #[test]
1813    fn windows_development_manifest_uses_app_identity() {
1814        let manifest = render_windows_development_manifest(&project());
1815        assert!(manifest.contains("com.fission.examples.fieldinspector"));
1816        assert!(manifest.contains("PerMonitorV2"));
1817        assert!(manifest.contains("asInvoker"));
1818    }
1819}