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