1pub mod doctor;
2
3use anyhow::{bail, Context, Result};
4use fission_command_core::{ios_executable_name, read_project_config, FissionProject, Target};
5use serde::Serialize;
6use std::env;
7use std::fs::{self, File, OpenOptions};
8use std::io::{self, IsTerminal, Read, Seek, Write};
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::time::{Duration, Instant};
12
13#[derive(Clone, Debug, Serialize)]
14pub struct Device {
15 pub id: String,
16 pub name: String,
17 pub target: Target,
18 pub kind: String,
19 pub status: String,
20 pub detail: String,
21 pub available: bool,
22}
23
24#[derive(Clone, Debug)]
25pub struct RunOptions {
26 pub project_dir: PathBuf,
27 pub target: Option<Target>,
28 pub device: Option<String>,
29 pub detach: bool,
30 pub release: bool,
31 pub host: String,
32 pub port: u16,
33 pub no_open: bool,
34 pub headless: bool,
35}
36
37#[derive(Clone, Debug)]
38pub struct BuildOptions {
39 pub project_dir: PathBuf,
40 pub target: Option<Target>,
41 pub release: bool,
42}
43
44#[derive(Clone, Debug)]
45pub struct TestOptions {
46 pub project_dir: PathBuf,
47 pub target: Option<Target>,
48 pub headless: bool,
49}
50
51#[derive(Clone, Debug)]
52pub struct LogOptions {
53 pub project_dir: PathBuf,
54 pub target: Option<Target>,
55 pub device: Option<String>,
56 pub follow: bool,
57}
58
59#[derive(Clone, Debug)]
60pub struct ServeWebOptions {
61 pub project_dir: PathBuf,
62 pub host: String,
63 pub port: u16,
64 pub open: bool,
65}
66
67pub fn list_devices(project_dir: &Path, json: bool) -> Result<()> {
68 let devices = discover_devices(project_dir);
69 if json {
70 println!("{}", serde_json::to_string_pretty(&devices)?);
71 return Ok(());
72 }
73
74 println!("Fission devices");
75 if devices.is_empty() {
76 println!(
77 "No runnable devices detected. Run `fission doctor web ios android --project-dir {}`.",
78 project_dir.display()
79 );
80 return Ok(());
81 }
82
83 let id_width = devices
84 .iter()
85 .map(|device| device.id.len())
86 .max()
87 .unwrap_or(2)
88 .max(2);
89 let target_width = devices
90 .iter()
91 .map(|device| device.target.as_str().len())
92 .max()
93 .unwrap_or(6)
94 .max(6);
95 println!(
96 "{:<id_width$} {:<target_width$} {:<16} {:<12} {}",
97 "ID",
98 "TARGET",
99 "KIND",
100 "STATUS",
101 "NAME",
102 id_width = id_width,
103 target_width = target_width
104 );
105 for device in devices {
106 println!(
107 "{:<id_width$} {:<target_width$} {:<16} {:<12} {}{}",
108 device.id,
109 device.target.as_str(),
110 device.kind,
111 device.status,
112 device.name,
113 if device.detail.is_empty() {
114 String::new()
115 } else {
116 format!(" ({})", device.detail)
117 },
118 id_width = id_width,
119 target_width = target_width
120 );
121 }
122 Ok(())
123}
124
125pub fn run_app(options: RunOptions) -> Result<()> {
126 let project = read_project_config(&options.project_dir)?;
127 let device = select_device(
128 &options.project_dir,
129 options.target,
130 options.device.as_deref(),
131 )?;
132 ensure_target_configured(&project, &options.project_dir, device.target)?;
133
134 match device.target {
135 Target::Linux | Target::Macos | Target::Windows => run_desktop(&options, &device),
136 Target::Web => run_web(&options, &device),
137 Target::Site => site_serve(
138 &options.project_dir,
139 options.release,
140 options.host,
141 options.port,
142 !options.no_open,
143 ),
144 Target::Ios => run_ios(&project, &options, &device),
145 Target::Android => run_android(&project, &options, &device),
146 }
147}
148
149pub fn build_app(options: BuildOptions) -> Result<()> {
150 let project = read_project_config(&options.project_dir)?;
151 let target = options.target.unwrap_or_else(host_desktop_target);
152 ensure_target_configured(&project, &options.project_dir, target)?;
153
154 match target {
155 Target::Linux | Target::Macos | Target::Windows => {
156 require_desktop_host(target)?;
157 build_desktop(&options.project_dir, options.release)
158 }
159 Target::Web => build_web(&options.project_dir, options.release),
160 Target::Site => site_build(&options.project_dir, options.release),
161 Target::Ios => {
162 require_host(Target::Ios)?;
163 let script = options.project_dir.join("platforms/ios/package-sim.sh");
164 let mut command = command_for_script(&script)?;
165 command.current_dir(&options.project_dir);
166 if options.release {
167 command.env("IOS_SIM_PROFILE", "release");
168 }
169 run_status(&mut command, "iOS build")
170 }
171 Target::Android => {
172 let apk = package_android(&options.project_dir, options.release)?;
173 println!("{}", apk.display());
174 Ok(())
175 }
176 }
177}
178
179pub fn test_app(options: TestOptions) -> Result<()> {
180 let project = read_project_config(&options.project_dir)?;
181 let target = options.target.unwrap_or_else(host_desktop_target);
182 ensure_target_configured(&project, &options.project_dir, target)?;
183
184 match target {
185 Target::Linux | Target::Macos | Target::Windows => {
186 require_desktop_host(target)?;
187 let mut command = Command::new("cargo");
188 command.arg("test").current_dir(&options.project_dir);
189 run_status(&mut command, "desktop tests")
190 }
191 Target::Web => run_target_script(
192 &options.project_dir,
193 "platforms/web/test-browser.sh",
194 |_| {},
195 ),
196 Target::Site => site_check(&options.project_dir, false),
197 Target::Ios => {
198 require_host(Target::Ios)?;
199 run_target_script(
200 &options.project_dir,
201 "platforms/ios/test-sim.sh",
202 |command| {
203 if options.headless {
204 command.env("IOS_SIM_HEADLESS", "1");
205 }
206 },
207 )
208 }
209 Target::Android => run_target_script(
210 &options.project_dir,
211 "platforms/android/test-emulator.sh",
212 |command| {
213 if options.headless {
214 command.env("ANDROID_EMULATOR_HEADLESS", "1");
215 }
216 },
217 ),
218 }
219}
220
221pub fn attach_logs(options: LogOptions) -> Result<()> {
222 let project = read_project_config(&options.project_dir)?;
223 let device = select_device(
224 &options.project_dir,
225 options.target,
226 options.device.as_deref(),
227 )?;
228 match device.target {
229 Target::Android => attach_android_logs(&project, &device, options.follow),
230 Target::Ios => attach_ios_logs(&project, &device, options.follow),
231 Target::Web => tail_log_file(
232 &detached_log_path(&options.project_dir, "web"),
233 options.follow,
234 ),
235 Target::Site => tail_log_file(
236 &detached_log_path(&options.project_dir, "site"),
237 options.follow,
238 ),
239 Target::Linux | Target::Macos | Target::Windows => tail_log_file(
240 &detached_log_path(&options.project_dir, "desktop"),
241 options.follow,
242 ),
243 }
244}
245
246pub fn serve_web(options: ServeWebOptions) -> Result<()> {
247 fission_command_site::serve_static(
248 options.project_dir,
249 options.host,
250 options.port,
251 options.open,
252 )
253}
254
255pub fn site_build(project_dir: &Path, release: bool) -> Result<()> {
256 fission_command_site::build(project_dir, release)
257}
258
259pub fn site_check(project_dir: &Path, release: bool) -> Result<()> {
260 fission_command_site::check(project_dir, release)
261}
262
263pub fn site_routes(project_dir: &Path) -> Result<()> {
264 fission_command_site::routes(project_dir)
265}
266
267pub fn site_serve(
268 project_dir: &Path,
269 release: bool,
270 host: String,
271 port: u16,
272 open: bool,
273) -> Result<()> {
274 fission_command_site::serve(project_dir, release, host, port, open)
275}
276
277pub fn discover_devices(_project_dir: &Path) -> Vec<Device> {
278 let mut devices = Vec::new();
279 devices.push(Device {
280 id: "desktop".to_string(),
281 name: desktop_name().to_string(),
282 target: host_desktop_target(),
283 kind: "desktop".to_string(),
284 status: "available".to_string(),
285 detail: current_os_detail(),
286 available: true,
287 });
288
289 devices.push(if let Some(chrome) = detect_chrome() {
290 Device {
291 id: "chrome".to_string(),
292 name: "Chrome/Chromium".to_string(),
293 target: Target::Web,
294 kind: "browser".to_string(),
295 status: "available".to_string(),
296 detail: chrome.display().to_string(),
297 available: true,
298 }
299 } else {
300 Device {
301 id: "web-server".to_string(),
302 name: "Local web server".to_string(),
303 target: Target::Web,
304 kind: "web-server".to_string(),
305 status: "available".to_string(),
306 detail: "Chrome/Chromium was not auto-detected".to_string(),
307 available: true,
308 }
309 });
310
311 devices.extend(discover_ios_simulators());
312 devices.extend(discover_android_devices());
313 devices.push(Device {
314 id: "site".to_string(),
315 name: "Static site".to_string(),
316 target: Target::Site,
317 kind: "site-server".to_string(),
318 status: "available".to_string(),
319 detail: "multi-page static output".to_string(),
320 available: true,
321 });
322 devices
323}
324
325fn select_device(
326 project_dir: &Path,
327 target: Option<Target>,
328 query: Option<&str>,
329) -> Result<Device> {
330 let devices = discover_devices(project_dir)
331 .into_iter()
332 .filter(|device| target.map(|target| target == device.target).unwrap_or(true))
333 .collect::<Vec<_>>();
334
335 if let Some(query) = query {
336 let query_lower = query.to_ascii_lowercase();
337 let mut matches = devices
338 .iter()
339 .filter(|device| {
340 device.id.eq_ignore_ascii_case(query)
341 || device.id.to_ascii_lowercase().starts_with(&query_lower)
342 || device.name.eq_ignore_ascii_case(query)
343 || device.name.to_ascii_lowercase().contains(&query_lower)
344 || device.target.as_str() == query_lower
345 })
346 .cloned()
347 .collect::<Vec<_>>();
348 if matches.iter().any(|device| !device.available) {
349 matches.retain(|device| device.available);
350 if matches.is_empty() {
351 bail!("device selector `{query}` matched a device that is not currently runnable");
352 }
353 }
354 return match matches.len() {
355 0 => bail!(
356 "no device matched `{query}`; run `fission devices --project-dir {}`",
357 project_dir.display()
358 ),
359 1 => Ok(matches[0].clone()),
360 _ => {
361 bail!("device selector `{query}` matched multiple devices; use an exact device id")
362 }
363 };
364 }
365
366 let devices = devices
367 .into_iter()
368 .filter(|device| device.available)
369 .collect::<Vec<_>>();
370
371 if devices.is_empty() {
372 bail!(
373 "no runnable devices detected; run `fission devices --project-dir {}`",
374 project_dir.display()
375 );
376 }
377 if devices.len() == 1 {
378 return Ok(devices[0].clone());
379 }
380
381 if !io::stdin().is_terminal() {
382 print_device_choices(&devices);
383 bail!("multiple devices are available; pass `--device <id>` or `--target <target>`");
384 }
385
386 print_device_choices(&devices);
387 print!("Select a device: ");
388 io::stdout().flush()?;
389 let mut line = String::new();
390 io::stdin().read_line(&mut line)?;
391 let index = line
392 .trim()
393 .parse::<usize>()
394 .context("expected a numbered device selection")?;
395 if index == 0 || index > devices.len() {
396 bail!("device selection {index} is out of range");
397 }
398 Ok(devices[index - 1].clone())
399}
400
401fn print_device_choices(devices: &[Device]) {
402 println!("Available devices:");
403 for (idx, device) in devices.iter().enumerate() {
404 println!(
405 " {}) {} [{}:{}] - {}",
406 idx + 1,
407 device.name,
408 device.target.as_str(),
409 device.id,
410 device.status
411 );
412 }
413}
414
415fn ensure_target_configured(
416 project: &FissionProject,
417 project_dir: &Path,
418 target: Target,
419) -> Result<()> {
420 if !project.targets.contains(&target) {
421 bail!(
422 "target `{}` is not configured for this app; run `fission add-target {} --project-dir {}`",
423 target.as_str(),
424 target.as_str(),
425 project_dir.display()
426 );
427 }
428 let scaffold = project_dir.join(target.scaffold_relative_path());
429 if !scaffold.exists() {
430 bail!(
431 "target `{}` scaffold is missing at {}; run `fission add-target {} --project-dir {}`",
432 target.as_str(),
433 scaffold.display(),
434 target.as_str(),
435 project_dir.display()
436 );
437 }
438 Ok(())
439}
440
441fn run_desktop(options: &RunOptions, _device: &Device) -> Result<()> {
442 let mut command = Command::new("cargo");
443 command.arg("run").current_dir(&options.project_dir);
444 if options.release {
445 command.arg("--release");
446 }
447 run_child(
448 command,
449 options.detach,
450 detached_log_path(&options.project_dir, "desktop"),
451 )
452}
453
454fn run_web(options: &RunOptions, _device: &Device) -> Result<()> {
455 build_web(&options.project_dir, options.release)?;
456 let open = !options.no_open;
457 if options.detach {
458 let log_path = detached_log_path(&options.project_dir, "web");
459 let log = open_log(&log_path)?;
460 let mut command = Command::new(env::current_exe()?);
461 command
462 .arg("serve-web")
463 .arg("--project-dir")
464 .arg(&options.project_dir)
465 .arg("--host")
466 .arg(&options.host)
467 .arg("--port")
468 .arg(options.port.to_string());
469 if open {
470 command.arg("--open");
471 }
472 let err = log.try_clone()?;
473 let child = command
474 .stdout(Stdio::from(log))
475 .stderr(Stdio::from(err))
476 .spawn()?;
477 println!(
478 "Started web server pid {} at {}. Logs: {}",
479 child.id(),
480 format!("http://{}:{}/platforms/web/", options.host, options.port),
481 log_path.display()
482 );
483 return Ok(());
484 }
485
486 fission_command_site::serve_static(
487 options.project_dir.clone(),
488 options.host.clone(),
489 options.port,
490 open,
491 )
492}
493
494fn run_ios(project: &FissionProject, options: &RunOptions, device: &Device) -> Result<()> {
495 require_host(Target::Ios)?;
496 let script = options.project_dir.join("platforms/ios/run-sim.sh");
497 let mut command = command_for_script(&script)?;
498 command.current_dir(&options.project_dir);
499 if device.kind == "ios-simulator" {
500 command.env("IOS_SIM_DEVICE_ID", &device.id);
501 }
502 if options.headless || options.no_open {
503 command.env("IOS_SIM_HEADLESS", "1");
504 }
505 if options.release {
506 command.env("IOS_SIM_PROFILE", "release");
507 }
508 run_status(&mut command, "iOS launch")?;
509 if !options.detach {
510 attach_ios_logs(project, device, true)?;
511 }
512 Ok(())
513}
514
515fn run_android(project: &FissionProject, options: &RunOptions, device: &Device) -> Result<()> {
516 if device.kind == "android-avd" {
517 let script = options
518 .project_dir
519 .join("platforms/android/run-emulator.sh");
520 let mut command = command_for_script(&script)?;
521 command.current_dir(&options.project_dir);
522 command.env(
523 "ANDROID_AVD_NAME",
524 device.id.trim_start_matches("android-avd:"),
525 );
526 if options.headless || options.no_open {
527 command.env("ANDROID_EMULATOR_HEADLESS", "1");
528 }
529 if options.release {
530 command.env("ANDROID_PROFILE", "release");
531 }
532 run_status(&mut command, "Android emulator launch")?;
533 if !options.detach {
534 let serial = first_android_serial().unwrap_or_else(|| "emulator-5554".to_string());
535 let running = Device {
536 id: serial,
537 kind: "android-emulator".to_string(),
538 ..device.clone()
539 };
540 attach_android_logs(project, &running, true)?;
541 }
542 return Ok(());
543 }
544
545 let apk = package_android(&options.project_dir, options.release)?;
546 let adb = adb_path()?;
547 run_status(
548 Command::new(&adb)
549 .arg("-s")
550 .arg(&device.id)
551 .arg("install")
552 .arg("-r")
553 .arg(&apk),
554 "Android install",
555 )?;
556 run_status(
557 Command::new(&adb)
558 .arg("-s")
559 .arg(&device.id)
560 .arg("shell")
561 .arg("am")
562 .arg("start")
563 .arg("-n")
564 .arg(format!("{}/android.app.NativeActivity", project.app.app_id)),
565 "Android launch",
566 )?;
567 if !options.detach {
568 attach_android_logs(project, device, true)?;
569 }
570 Ok(())
571}
572
573fn attach_ios_logs(project: &FissionProject, device: &Device, follow: bool) -> Result<()> {
574 require_host(Target::Ios)?;
575 let executable = ios_executable_name(project);
576 let predicate = format!("process == \"{}\"", executable);
577 let mut command = Command::new("xcrun");
578 command
579 .arg("simctl")
580 .arg("spawn")
581 .arg(&device.id)
582 .arg("log")
583 .arg(if follow { "stream" } else { "show" })
584 .arg("--style")
585 .arg("compact")
586 .arg("--predicate")
587 .arg(predicate);
588 println!(
589 "Attaching iOS logs for {}. Press Ctrl+C to stop.",
590 executable
591 );
592 run_status(&mut command, "iOS logs")
593}
594
595fn attach_android_logs(project: &FissionProject, device: &Device, follow: bool) -> Result<()> {
596 let adb = adb_path()?;
597 let pid = wait_for_android_pid(
598 &adb,
599 &device.id,
600 &project.app.app_id,
601 Duration::from_secs(20),
602 )?;
603 let mut command = Command::new(&adb);
604 command
605 .arg("-s")
606 .arg(&device.id)
607 .arg("logcat")
608 .arg("--pid")
609 .arg(pid);
610 if !follow {
611 command.arg("-d");
612 }
613 println!(
614 "Attaching Android logs for {} on {}. Press Ctrl+C to stop.",
615 project.app.app_id, device.id
616 );
617 run_status(&mut command, "Android logs")
618}
619
620fn tail_log_file(path: &Path, follow: bool) -> Result<()> {
621 if !path.exists() {
622 bail!(
623 "no detached log file found at {}; run with `--detach` first",
624 path.display()
625 );
626 }
627 if !follow {
628 print!("{}", fs::read_to_string(path)?);
629 return Ok(());
630 }
631
632 println!("Following {}. Press Ctrl+C to stop.", path.display());
633 let mut offset = 0u64;
634 loop {
635 let mut file = File::open(path)?;
636 file.seek_relative(offset as i64)?;
637 let mut buf = String::new();
638 file.read_to_string(&mut buf)?;
639 if !buf.is_empty() {
640 print!("{}", buf);
641 io::stdout().flush()?;
642 offset += buf.len() as u64;
643 }
644 std::thread::sleep(Duration::from_millis(500));
645 }
646}
647
648fn build_web(project_dir: &Path, release: bool) -> Result<()> {
649 let project_dir = fs::canonicalize(project_dir).with_context(|| {
650 format!(
651 "failed to resolve project directory {}",
652 project_dir.display()
653 )
654 })?;
655 let out_dir = project_dir.join("platforms/web/pkg");
656 let mut command = Command::new("wasm-pack");
657 command
658 .arg("build")
659 .arg(&project_dir)
660 .arg("--target")
661 .arg("web")
662 .arg("--out-dir")
663 .arg(out_dir);
664 command.arg(if release { "--release" } else { "--dev" });
665 run_status(&mut command, "web build")
666}
667
668fn package_android(project_dir: &Path, release: bool) -> Result<PathBuf> {
669 let script = project_dir.join("platforms/android/package-apk.sh");
670 let mut command = command_for_script(&script)?;
671 command.current_dir(project_dir);
672 if release {
673 command.env("ANDROID_PROFILE", "release");
674 }
675 let output = command
676 .output()
677 .context("failed to run Android package script")?;
678 if !output.status.success() {
679 io::stderr().write_all(&output.stderr).ok();
680 bail!("Android package failed with {}", output.status);
681 }
682 io::stderr().write_all(&output.stderr).ok();
683 let stdout = String::from_utf8_lossy(&output.stdout);
684 let apk = stdout
685 .lines()
686 .rev()
687 .find(|line| line.trim_end().ends_with(".apk"))
688 .map(|line| PathBuf::from(line.trim()))
689 .context("Android package script did not print an APK path")?;
690 Ok(apk)
691}
692
693fn run_child(mut command: Command, detach: bool, log_path: PathBuf) -> Result<()> {
694 if detach {
695 let log = open_log(&log_path)?;
696 let err = log.try_clone()?;
697 let child = command
698 .stdout(Stdio::from(log))
699 .stderr(Stdio::from(err))
700 .spawn()?;
701 println!("Started pid {}. Logs: {}", child.id(), log_path.display());
702 return Ok(());
703 }
704 let status = command.status()?;
705 if !status.success() {
706 bail!("command exited with {status}");
707 }
708 Ok(())
709}
710
711fn build_desktop(project_dir: &Path, release: bool) -> Result<()> {
712 let mut command = Command::new("cargo");
713 command.arg("build").current_dir(project_dir);
714 if release {
715 command.arg("--release");
716 }
717 run_status(&mut command, "desktop build")
718}
719
720fn run_target_script<F>(project_dir: &Path, relative_script: &str, configure: F) -> Result<()>
721where
722 F: FnOnce(&mut Command),
723{
724 let script = project_dir.join(relative_script);
725 let mut command = command_for_script(&script)?;
726 command.current_dir(project_dir);
727 configure(&mut command);
728 run_status(&mut command, relative_script)
729}
730
731fn run_status(command: &mut Command, label: &str) -> Result<()> {
732 let status = command
733 .status()
734 .with_context(|| format!("failed to run {label}"))?;
735 if !status.success() {
736 bail!("{label} failed with {status}");
737 }
738 Ok(())
739}
740
741fn command_for_script(script: &Path) -> Result<Command> {
742 if !script.exists() {
743 bail!("script is missing at {}", script.display());
744 }
745 let script = fs::canonicalize(script)
746 .with_context(|| format!("failed to resolve script {}", script.display()))?;
747 if cfg!(windows) {
748 if find_in_path("bash").is_none() {
749 bail!(
750 "running {} on Windows currently requires bash on PATH; install Git for Windows or run the equivalent command in WSL",
751 script.display()
752 );
753 }
754 let mut command = Command::new("bash");
755 command.arg(script);
756 Ok(command)
757 } else {
758 Ok(Command::new(script))
759 }
760}
761
762fn detached_log_path(project_dir: &Path, name: &str) -> PathBuf {
763 project_dir.join(".fission/run").join(format!("{name}.log"))
764}
765
766fn open_log(path: &Path) -> Result<File> {
767 if let Some(parent) = path.parent() {
768 fs::create_dir_all(parent)?;
769 }
770 OpenOptions::new()
771 .create(true)
772 .append(true)
773 .open(path)
774 .with_context(|| format!("failed to open log file {}", path.display()))
775}
776
777fn discover_ios_simulators() -> Vec<Device> {
778 if !cfg!(target_os = "macos") || find_in_path("xcrun").is_none() {
779 return Vec::new();
780 }
781 let output = match Command::new("xcrun")
782 .args(["simctl", "list", "devices", "available", "-j"])
783 .output()
784 {
785 Ok(output) if output.status.success() => output,
786 _ => return Vec::new(),
787 };
788 let payload: serde_json::Value = match serde_json::from_slice(&output.stdout) {
789 Ok(payload) => payload,
790 Err(_) => return Vec::new(),
791 };
792 let mut devices = Vec::new();
793 if let Some(groups) = payload.get("devices").and_then(|value| value.as_object()) {
794 for (runtime, entries) in groups {
795 if !runtime.contains("SimRuntime.iOS") {
796 continue;
797 }
798 let Some(entries) = entries.as_array() else {
799 continue;
800 };
801 for entry in entries {
802 let name = entry
803 .get("name")
804 .and_then(|value| value.as_str())
805 .unwrap_or("iOS Simulator");
806 if !name.contains("iPhone") {
807 continue;
808 }
809 let Some(udid) = entry.get("udid").and_then(|value| value.as_str()) else {
810 continue;
811 };
812 let state = entry
813 .get("state")
814 .and_then(|value| value.as_str())
815 .unwrap_or("unknown");
816 devices.push(Device {
817 id: udid.to_string(),
818 name: name.to_string(),
819 target: Target::Ios,
820 kind: "ios-simulator".to_string(),
821 status: state.to_ascii_lowercase(),
822 detail: runtime
823 .rsplit('.')
824 .next()
825 .unwrap_or(runtime)
826 .replace('-', " "),
827 available: true,
828 });
829 }
830 }
831 }
832 devices
833}
834
835fn discover_android_devices() -> Vec<Device> {
836 let mut devices = Vec::new();
837 let Ok(adb) = adb_path() else {
838 return devices;
839 };
840 if let Ok(output) = Command::new(&adb).arg("devices").arg("-l").output() {
841 if output.status.success() {
842 let stdout = String::from_utf8_lossy(&output.stdout);
843 for line in stdout.lines().skip(1) {
844 let line = line.trim();
845 if line.is_empty() {
846 continue;
847 }
848 let mut parts = line.split_whitespace();
849 let Some(serial) = parts.next() else { continue };
850 let status = parts.next().unwrap_or("unknown");
851 let detail = parts.collect::<Vec<_>>().join(" ");
852 devices.push(Device {
853 id: serial.to_string(),
854 name: if serial.starts_with("emulator-") {
855 "Android Emulator"
856 } else {
857 "Android Device"
858 }
859 .to_string(),
860 target: Target::Android,
861 kind: if serial.starts_with("emulator-") {
862 "android-emulator"
863 } else {
864 "android-device"
865 }
866 .to_string(),
867 status: status.to_string(),
868 detail,
869 available: status == "device",
870 });
871 }
872 }
873 }
874
875 if let Some(avdmanager) = android_tool("cmdline-tools/latest/bin/avdmanager") {
876 if let Ok(output) = Command::new(avdmanager).args(["list", "avd"]).output() {
877 if output.status.success() {
878 let stdout = String::from_utf8_lossy(&output.stdout);
879 for line in stdout.lines() {
880 let line = line.trim();
881 if let Some(name) = line.strip_prefix("Name:") {
882 let name = name.trim();
883 devices.push(Device {
884 id: format!("android-avd:{name}"),
885 name: name.to_string(),
886 target: Target::Android,
887 kind: "android-avd".to_string(),
888 status: "configured".to_string(),
889 detail: "stopped emulator profile".to_string(),
890 available: true,
891 });
892 }
893 }
894 }
895 }
896 }
897 devices
898}
899
900fn wait_for_android_pid(
901 adb: &Path,
902 serial: &str,
903 app_id: &str,
904 timeout: Duration,
905) -> Result<String> {
906 let start = Instant::now();
907 while start.elapsed() < timeout {
908 let output = Command::new(adb)
909 .arg("-s")
910 .arg(serial)
911 .arg("shell")
912 .arg("pidof")
913 .arg(app_id)
914 .output();
915 if let Ok(output) = output {
916 if output.status.success() {
917 let pid = String::from_utf8_lossy(&output.stdout).trim().to_string();
918 if !pid.is_empty() {
919 return Ok(pid);
920 }
921 }
922 }
923 std::thread::sleep(Duration::from_millis(500));
924 }
925 bail!("timed out waiting for Android process `{app_id}` on {serial}")
926}
927
928fn first_android_serial() -> Option<String> {
929 let adb = adb_path().ok()?;
930 let output = Command::new(adb).arg("devices").output().ok()?;
931 if !output.status.success() {
932 return None;
933 }
934 String::from_utf8_lossy(&output.stdout)
935 .lines()
936 .skip(1)
937 .find_map(|line| {
938 let mut parts = line.split_whitespace();
939 let serial = parts.next()?;
940 let status = parts.next()?;
941 (status == "device").then(|| serial.to_string())
942 })
943}
944
945fn adb_path() -> Result<PathBuf> {
946 android_tool("platform-tools/adb")
947 .context("Android adb was not found; run `fission doctor android`")
948}
949
950fn android_tool(relative: &str) -> Option<PathBuf> {
951 let home = android_home();
952 let path = home.join(relative);
953 if path.exists() {
954 return Some(path);
955 }
956 let exe = home.join(format!("{relative}.exe"));
957 if exe.exists() {
958 return Some(exe);
959 }
960 None
961}
962
963fn android_home() -> PathBuf {
964 env::var_os("ANDROID_HOME")
965 .or_else(|| env::var_os("ANDROID_SDK_ROOT"))
966 .map(PathBuf::from)
967 .unwrap_or_else(default_android_home)
968}
969
970fn default_android_home() -> PathBuf {
971 let home = env::var_os("HOME")
972 .or_else(|| env::var_os("USERPROFILE"))
973 .map(PathBuf::from)
974 .unwrap_or_else(|| PathBuf::from("."));
975 if cfg!(target_os = "macos") {
976 home.join("Library/Android/sdk")
977 } else if cfg!(target_os = "windows") {
978 env::var_os("LOCALAPPDATA")
979 .map(PathBuf::from)
980 .unwrap_or(home)
981 .join("Android/Sdk")
982 } else {
983 home.join("Android/Sdk")
984 }
985}
986
987fn detect_chrome() -> Option<PathBuf> {
988 for var in ["FISSION_CHROME", "CHROME", "CHROME_BIN"] {
989 if let Ok(value) = env::var(var) {
990 let path = PathBuf::from(value);
991 if path.exists() {
992 return Some(path);
993 }
994 }
995 }
996 let names = if cfg!(target_os = "windows") {
997 vec!["chrome.exe", "msedge.exe", "chromium.exe"]
998 } else {
999 vec!["google-chrome", "chromium", "chromium-browser", "chrome"]
1000 };
1001 for name in names {
1002 if let Some(path) = find_in_path(name) {
1003 return Some(path);
1004 }
1005 }
1006 for path in platform_chrome_paths() {
1007 if path.exists() {
1008 return Some(path);
1009 }
1010 }
1011 None
1012}
1013
1014fn platform_chrome_paths() -> Vec<PathBuf> {
1015 if cfg!(target_os = "macos") {
1016 vec![
1017 PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
1018 PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"),
1019 PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
1020 ]
1021 } else if cfg!(target_os = "windows") {
1022 let mut paths = Vec::new();
1023 if let Some(program_files) = env::var_os("PROGRAMFILES") {
1024 paths.push(PathBuf::from(program_files).join("Google/Chrome/Application/chrome.exe"));
1025 }
1026 if let Some(program_files_x86) = env::var_os("PROGRAMFILES(X86)") {
1027 paths.push(
1028 PathBuf::from(program_files_x86).join("Google/Chrome/Application/chrome.exe"),
1029 );
1030 }
1031 paths
1032 } else {
1033 Vec::new()
1034 }
1035}
1036
1037fn find_in_path(name: &str) -> Option<PathBuf> {
1038 let path = env::var_os("PATH")?;
1039 for dir in env::split_paths(&path) {
1040 let candidate = dir.join(name);
1041 if candidate.exists() {
1042 return Some(candidate);
1043 }
1044 }
1045 None
1046}
1047
1048fn require_host(target: Target) -> Result<()> {
1049 match target {
1050 Target::Ios if !cfg!(target_os = "macos") => {
1051 bail!("iOS simulator runs require macOS with Xcode")
1052 }
1053 _ => Ok(()),
1054 }
1055}
1056
1057fn require_desktop_host(target: Target) -> Result<()> {
1058 let host = host_desktop_target();
1059 if target != host {
1060 bail!(
1061 "desktop target `{}` cannot be built or run from this host with the current CLI; use `{}` on this machine",
1062 target.as_str(),
1063 host.as_str()
1064 );
1065 }
1066 Ok(())
1067}
1068
1069fn host_desktop_target() -> Target {
1070 if cfg!(target_os = "windows") {
1071 Target::Windows
1072 } else if cfg!(target_os = "macos") {
1073 Target::Macos
1074 } else {
1075 Target::Linux
1076 }
1077}
1078
1079fn desktop_name() -> &'static str {
1080 if cfg!(target_os = "windows") {
1081 "Windows desktop"
1082 } else if cfg!(target_os = "macos") {
1083 "macOS desktop"
1084 } else {
1085 "Linux desktop"
1086 }
1087}
1088
1089fn current_os_detail() -> String {
1090 env::consts::OS.to_string()
1091}