1use anyhow::{Context, Result};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
11pub enum DaemonStatus {
12 Running {
14 pid: u32,
16 },
17 Stale {
19 pid: u32,
21 },
22 Stopped,
24}
25
26impl std::fmt::Display for DaemonStatus {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 DaemonStatus::Running { pid } => write!(f, "running (PID {})", pid),
30 DaemonStatus::Stale { pid } => write!(f, "stale (PID {} dead)", pid),
31 DaemonStatus::Stopped => write!(f, "stopped"),
32 }
33 }
34}
35
36pub struct DaemonManager {
38 pid_file: PathBuf,
39 log_dir: PathBuf,
40}
41
42impl DaemonManager {
43 pub fn new(pid_file: &str, log_dir: &str) -> Self {
45 Self {
46 pid_file: crate::config::expand_home(pid_file),
47 log_dir: crate::config::expand_home(log_dir),
48 }
49 }
50
51 pub fn status(&self) -> DaemonStatus {
53 match self.read_pid() {
54 Some(pid) => {
55 if self.is_alive(pid) {
56 DaemonStatus::Running { pid }
57 } else {
58 DaemonStatus::Stale { pid }
59 }
60 }
61 None => DaemonStatus::Stopped,
62 }
63 }
64
65 pub fn start(&self, config_path: &Path) -> Result<()> {
67 match self.status() {
68 DaemonStatus::Running { pid } => {
69 anyhow::bail!("oxios is already running (PID {})", pid);
70 }
71 DaemonStatus::Stale { .. } => {
72 self.cleanup()?;
73 }
74 DaemonStatus::Stopped => {}
75 }
76
77 std::fs::create_dir_all(&self.log_dir).context("failed to create log directory")?;
79
80 let log_file = self.log_dir.join("oxios.log");
81 let exe = std::env::current_exe().context("failed to locate oxios binary")?;
82
83 let child = std::process::Command::new(&exe)
84 .arg("--foreground")
85 .arg("--config")
86 .arg(config_path)
87 .stdout(std::fs::File::create(&log_file)?)
88 .stderr(std::fs::File::create(&log_file)?)
89 .spawn()
90 .context("failed to spawn oxios daemon")?;
91
92 let pid = child.id();
93 self.write_pid(pid)?;
94
95 println!("⬡ oxios started (PID {})", pid);
96 println!(" Logs: {}", log_file.display());
97 println!(" Dashboard: http://127.0.0.1:4200");
98 Ok(())
99 }
100
101 pub fn stop(&self) -> Result<()> {
103 match self.status() {
104 DaemonStatus::Running { pid } => {
105 #[cfg(unix)]
106 {
107 let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
108 if ret != 0 {
109 anyhow::bail!("failed to send SIGTERM to PID {}", pid);
110 }
111 }
112 #[cfg(not(unix))]
113 {
114 let _ = std::process::Command::new("taskkill")
116 .args(["/PID", &pid.to_string(), "/F"])
117 .output();
118 }
119
120 for _ in 0..10 {
122 std::thread::sleep(std::time::Duration::from_millis(200));
123 if !self.is_alive(pid) {
124 break;
125 }
126 }
127
128 self.cleanup()?;
129 println!("⬡ oxios stopped");
130 Ok(())
131 }
132 DaemonStatus::Stale { .. } => {
133 self.cleanup()?;
134 println!("⬡ cleaned up stale PID file");
135 Ok(())
136 }
137 DaemonStatus::Stopped => {
138 println!("⬡ oxios is not running");
139 Ok(())
140 }
141 }
142 }
143
144 pub fn restart(&self, config_path: &Path) -> Result<()> {
146 if matches!(self.status(), DaemonStatus::Running { .. }) {
147 self.stop()?;
148 std::thread::sleep(std::time::Duration::from_millis(500));
149 }
150 self.start(config_path)
151 }
152
153 pub fn install_service(&self) -> Result<()> {
155 let exe = std::env::current_exe().context("failed to locate oxios binary")?;
156
157 #[cfg(target_os = "macos")]
158 {
159 let plist_dir = dirs::home_dir()
160 .map(|h| h.join("Library/LaunchAgents"))
161 .context("failed to locate LaunchAgents directory")?;
162 std::fs::create_dir_all(&plist_dir)?;
163 let plist_path = plist_dir.join("com.a7garden.oxios.plist");
164
165 let home = dirs::home_dir().context("failed to get HOME")?;
166 let log_path = self.log_dir.join("oxiosd.log");
167
168 let plist = format!(
169 r#"<?xml version="1.0" encoding="UTF-8"?>
170<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
171<plist version="1.0">
172<dict>
173 <key>Label</key>
174 <string>com.a7garden.oxios</string>
175 <key>ProgramArguments</key>
176 <array>
177 <string>{exe}</string>
178 <string>--foreground</string>
179 </array>
180 <key>RunAtLoad</key>
181 <true/>
182 <key>KeepAlive</key>
183 <true/>
184 <key>StandardOutPath</key>
185 <string>{log}</string>
186 <key>StandardErrorPath</key>
187 <string>{log}</string>
188 <key>WorkingDirectory</key>
189 <string>{home}</string>
190</dict>
191</plist>
192"#,
193 exe = exe.display(),
194 log = log_path.display(),
195 home = home.display(),
196 );
197
198 std::fs::write(&plist_path, &plist)?;
199 println!("✓ Installed launchd service");
200 println!(" {}", plist_path.display());
201 println!();
202 println!(" Start with: launchctl load {}", plist_path.display());
203 println!(" Stop with: launchctl unload {}", plist_path.display());
204 println!(" Or simply: oxios start / oxios stop");
205 }
206
207 #[cfg(target_os = "linux")]
208 {
209 let unit_dir = PathBuf::from("/etc/systemd/system");
210 let unit_path = unit_dir.join("oxiosd.service");
211
212 let unit = format!(
213 r#"[Unit]
214Description=Oxios Agent Operating System
215After=network.target
216
217[Service]
218Type=simple
219ExecStart={exe} --foreground
220Restart=on-failure
221RestartSec=5s
222
223[Install]
224WantedBy=multi-user.target
225"#,
226 exe = exe.display(),
227 );
228
229 if let Err(e) = std::fs::write(&unit_path, &unit) {
231 anyhow::bail!(
232 "Failed to write {} — run with sudo: {}",
233 unit_path.display(),
234 e
235 );
236 }
237
238 println!("✓ Installed systemd service");
239 println!(" {}", unit_path.display());
240 println!();
241 println!(" Reload: sudo systemctl daemon-reload");
242 println!(" Start: sudo systemctl start oxiosd");
243 println!(" Enable: sudo systemctl enable oxiosd");
244 }
245
246 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
247 {
248 anyhow::bail!("daemon install only supported on macOS and Linux");
249 }
250
251 Ok(())
252 }
253
254 pub fn uninstall_service(&self) -> Result<()> {
256 #[cfg(target_os = "macos")]
257 {
258 let plist_path = dirs::home_dir()
259 .map(|h| h.join("Library/LaunchAgents/com.a7garden.oxios.plist"))
260 .context("failed to locate plist")?;
261
262 if plist_path.exists() {
263 std::fs::remove_file(&plist_path)?;
264 println!("✓ Removed launchd service");
265 } else {
266 println!(" Service not installed");
267 }
268 }
269
270 #[cfg(target_os = "linux")]
271 {
272 let unit_path = PathBuf::from("/etc/systemd/system/oxiosd.service");
273 if unit_path.exists() {
274 if let Err(e) = std::fs::remove_file(&unit_path) {
275 anyhow::bail!(
276 "Failed to remove {} — run with sudo: {}",
277 unit_path.display(),
278 e
279 );
280 }
281 println!("✓ Removed systemd service");
282 } else {
283 println!(" Service not installed");
284 }
285 }
286
287 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
288 {
289 anyhow::bail!("daemon uninstall only supported on macOS and Linux");
290 }
291
292 Ok(())
293 }
294
295 fn read_pid(&self) -> Option<u32> {
298 let content = std::fs::read_to_string(&self.pid_file).ok()?;
299 content.trim().parse().ok()
300 }
301
302 fn write_pid(&self, pid: u32) -> Result<()> {
303 if let Some(parent) = self.pid_file.parent() {
304 std::fs::create_dir_all(parent)?;
305 }
306 std::fs::write(&self.pid_file, pid.to_string())?;
307 Ok(())
308 }
309
310 fn cleanup(&self) -> Result<()> {
311 if self.pid_file.exists() {
312 std::fs::remove_file(&self.pid_file)?;
313 }
314 Ok(())
315 }
316
317 fn is_alive(&self, pid: u32) -> bool {
318 #[cfg(unix)]
319 {
320 unsafe { libc::kill(pid as i32, 0) == 0 }
322 }
323 #[cfg(not(unix))]
324 {
325 let _ = pid;
327 false
328 }
329 }
330}