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