1use std::{path::PathBuf, time::{Duration, Instant}};
2use crate::ClientError;
3
4const PORT: u16 = rootcx_platform::DEFAULT_API_PORT;
5const POLL: Duration = Duration::from_millis(500);
6const TIMEOUT_SPAWN: Duration = Duration::from_secs(30);
7const TIMEOUT_EXIST: Duration = Duration::from_secs(15);
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum RuntimeStatus { Ready, NotInstalled }
11
12fn healthy() -> bool {
13 reqwest::blocking::get(format!("http://localhost:{PORT}/health")).is_ok_and(|r| r.status().is_success())
14}
15
16fn wait_healthy(timeout: Duration) -> bool {
17 let t = Instant::now();
18 while t.elapsed() < timeout { if healthy() { return true; } std::thread::sleep(POLL); }
19 false
20}
21
22fn read_pid() -> Option<u32> {
23 let p = rootcx_platform::dirs::rootcx_home().ok()?.join("runtime.pid");
24 std::fs::read_to_string(p).ok()?.trim().parse().ok()
25}
26
27fn err(s: impl Into<String>) -> ClientError { ClientError::RuntimeStart(s.into()) }
28
29fn installed_binary() -> Option<PathBuf> {
30 let p = rootcx_platform::dirs::rootcx_home().ok()?.join("bin").join(rootcx_platform::bin::binary_name("rootcx-core"));
31 p.is_file().then_some(p)
32}
33
34fn sidecar_binary() -> Option<PathBuf> {
35 let dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
36 for candidate in [
37 dir.join(rootcx_platform::bin::binary_name(&format!("rootcx-core-{}", rootcx_platform::bin::TARGET_TRIPLE))),
38 dir.join(rootcx_platform::bin::binary_name("rootcx-core")),
39 ] {
40 if candidate.is_file() { return Some(candidate); }
41 }
42 None
43}
44
45pub fn ensure_runtime() -> Result<RuntimeStatus, ClientError> {
46 if healthy() { return Ok(RuntimeStatus::Ready); }
47
48 if let Some(pid) = read_pid() {
50 if rootcx_platform::process::process_alive(pid) {
51 return if wait_healthy(TIMEOUT_EXIST) { Ok(RuntimeStatus::Ready) }
52 else { Err(err(format!("pid {pid} alive but unresponsive"))) };
53 }
54 }
55
56 if let Some(bin) = installed_binary() {
58 let log = open_log()?;
59 spawn(&bin, log)?;
60 return if wait_healthy(TIMEOUT_SPAWN) { Ok(RuntimeStatus::Ready) }
61 else { Err(err("daemon unresponsive after spawn")) };
62 }
63
64 if let Some(sidecar) = sidecar_binary() {
66 let status = std::process::Command::new(&sidecar)
67 .args(["install", "--service"])
68 .status()
69 .map_err(|e| err(format!("install: {e}")))?;
70 if !status.success() {
71 return Err(err("rootcx-core install --service failed"));
72 }
73 return if wait_healthy(TIMEOUT_SPAWN) { Ok(RuntimeStatus::Ready) }
74 else { Err(err("daemon unresponsive after install")) };
75 }
76
77 Ok(RuntimeStatus::NotInstalled)
78}
79
80pub fn prompt_runtime_install() -> Result<(), ClientError> {
81 #[cfg(target_os = "macos")]
82 {
83 let script = r#"display dialog "RootCX Runtime is required but not installed.\nDownload and install it now?" buttons {"Cancel", "Install"} default button "Install" with title "RootCX""#;
84 if !std::process::Command::new("osascript").args(["-e", script]).output()
85 .is_ok_and(|o| o.status.success()) {
86 return Err(err("RootCX Runtime installation cancelled"));
87 }
88 let url = runtime_download_url();
89 if open::that(&url).is_err() {
90 eprintln!("Download the runtime manually: {url}");
91 }
92 let deadline = Instant::now() + Duration::from_secs(300);
93 while Instant::now() < deadline {
94 std::thread::sleep(Duration::from_secs(2));
95 if rootcx_platform::bin::runtime_installed() { return ensure_runtime().map(|_| ()); }
96 }
97 return Err(err("RootCX Runtime installation timed out"));
98 }
99 #[cfg(not(target_os = "macos"))]
100 Err(err("RootCX Runtime is not installed. Please install it manually."))
101}
102
103pub fn deploy_bundled_backend(app_id: &str) {
104 let Some(archive) = find_bundled_resource("backend.tar.gz") else { return };
105 let Ok(data) = std::fs::read(&archive) else { return };
106 let Ok(part) = reqwest::blocking::multipart::Part::bytes(data)
107 .file_name("backend.tar.gz").mime_str("application/gzip") else { return };
108 let _ = reqwest::blocking::Client::new()
109 .post(format!("http://localhost:{PORT}/api/v1/apps/{app_id}/deploy"))
110 .multipart(reqwest::blocking::multipart::Form::new().part("archive", part))
111 .send();
112}
113
114fn runtime_download_url() -> String {
115 let base = std::env::var("ROOTCX_RELEASE_URL")
116 .unwrap_or_else(|_| "https://github.com/rootcx/rootcx/releases/latest/download".into());
117 let triple = rootcx_platform::bin::TARGET_TRIPLE;
118 #[cfg(target_os = "macos")]
119 { return format!("{base}/RootCX-Runtime-{triple}.pkg"); }
120 #[cfg(target_os = "windows")]
121 { return format!("{base}/RootCX-Runtime-{triple}.exe"); }
122 #[cfg(target_os = "linux")]
123 { format!("{base}/RootCX-Runtime-{triple}.tar.gz") }
124}
125
126fn find_bundled_resource(name: &str) -> Option<PathBuf> {
127 let dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
128 #[cfg(target_os = "macos")]
129 { let p = dir.parent()?.join("Resources/resources").join(name); if p.is_file() { return Some(p); } }
130 let p = dir.join("resources").join(name);
131 p.is_file().then_some(p)
132}
133
134fn open_log() -> Result<std::fs::File, ClientError> {
135 let log_dir = rootcx_platform::dirs::rootcx_home().map(|h| h.join("logs")).map_err(|e| err(e.to_string()))?;
136 std::fs::create_dir_all(&log_dir).map_err(|e| err(format!("log dir: {e}")))?;
137 std::fs::OpenOptions::new().create(true).append(true)
138 .open(log_dir.join("runtime.log")).map_err(|e| err(format!("log file: {e}")))
139}
140
141fn spawn(bin: &std::path::Path, log: std::fs::File) -> Result<(), ClientError> {
142 let mut cmd = std::process::Command::new(bin);
143 cmd.arg("--daemon").stdout(log.try_clone().map_err(|e| err(format!("fd: {e}")))?).stderr(log);
144 #[cfg(windows)] {
145 use std::os::windows::process::CommandExt;
146 cmd.creation_flags(0x0000_0200 | 0x0800_0000); }
148 cmd.spawn().map(|_| ()).map_err(|e| err(format!("spawn: {e}")))
149}