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