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