Skip to main content

rootcx_client/
daemon.rs

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    // Existing process alive but slow to respond
49    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    // Already installed as service — just start it
57    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    // Sidecar available — install as OS service, then wait
65    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); // CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
147    }
148    cmd.spawn().map(|_| ()).map_err(|e| err(format!("spawn: {e}")))
149}