1use std::fmt::Write as _;
2use std::fs;
3use std::path::PathBuf;
4use std::process::Command;
5
6use anyhow::{Context, Result, anyhow};
7use zoi_core::{sysroot, types, utils};
8use zoi_resolver::local;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ServiceAction {
13 Start,
15 Stop,
17 Restart,
19 Status,
21 Enable,
23 Disable
25}
26
27fn run_service_command(cmd: &mut Command, description: &str) -> Result<()> {
30 let status = cmd
31 .status()
32 .with_context(|| format!("Failed to run {description}"))?;
33 if status.success() {
34 Ok(())
35 } else {
36 Err(anyhow!(
37 "{description} failed with exit code {:?}",
38 status.code()
39 ))
40 }
41}
42
43pub fn manage_service(package_name: &str, action: ServiceAction) -> Result<()> {
50 let installed_packages = local::get_installed_packages()?;
51 let manifest = installed_packages
52 .iter()
53 .find(|p| p.name == package_name)
54 .ok_or_else(|| {
55 anyhow!("Package '{package_name}' is not installed.")
56 })?;
57
58 let service = manifest.service.as_ref().ok_or_else(|| {
59 anyhow!(
60 "Package '{package_name}' does not define a background service."
61 )
62 })?;
63
64 let service_name = format!("zoi-{}", manifest.name);
65
66 match std::env::consts::OS {
67 "linux" => {
68 manage_linux_service(&service_name, service, action, manifest.scope)
69 }
70 "macos" => {
71 manage_macos_service(&service_name, service, action, manifest.scope)
72 }
73 "windows" => manage_windows_service(
74 &service_name,
75 service,
76 action,
77 manifest.scope
78 ),
79 _ => Err(anyhow!("Service management not supported on this OS."))
80 }
81}
82
83pub fn list_services() -> Result<Vec<(String, String)>> {
90 let installed_packages = local::get_installed_packages()?;
91 let mut services = Vec::new();
92
93 for pkg in installed_packages {
94 if pkg.service.is_some() {
95 let status = get_service_status(&pkg)?;
96 services.push((pkg.name.clone(), status));
97 }
98 }
99
100 Ok(services)
101}
102
103pub fn cleanup_service(package_name: &str, scope: types::Scope) -> Result<()> {
110 let service_name = format!("zoi-{package_name}");
111 let is_user = scope != types::Scope::System;
112
113 match std::env::consts::OS {
114 "linux" => {
115 let unit_path = if is_user {
116 let home = utils::get_user_home()
117 .ok_or_else(|| anyhow!("Could not find home directory"))?;
118 sysroot::apply_sysroot(
119 home.join(".config/systemd/user")
120 .join(format!("{service_name}.service"))
121 )
122 } else {
123 sysroot::apply_sysroot(PathBuf::from(format!(
124 "/etc/systemd/system/{service_name}.service"
125 )))
126 };
127 if unit_path.exists() {
128 println!("Removing service unit file: {}", unit_path.display());
129 fs::remove_file(&unit_path).with_context(|| {
130 format!(
131 "Failed to remove unit file: {}",
132 unit_path.display()
133 )
134 })?;
135 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err() {
136 let mut cmd = Command::new("systemctl");
137 if is_user {
138 cmd.arg("--user");
139 }
140 cmd.arg("daemon-reload");
141 run_service_command(&mut cmd, "systemctl daemon-reload")?;
142 }
143 }
144 }
145 "macos" => {
146 let plist_path = if is_user {
147 let home = utils::get_user_home()
148 .ok_or_else(|| anyhow!("Could not find home directory"))?;
149 sysroot::apply_sysroot(
150 home.join("Library/LaunchAgents")
151 .join(format!("{service_name}.plist"))
152 )
153 } else {
154 sysroot::apply_sysroot(PathBuf::from(format!(
155 "/Library/LaunchDaemons/{service_name}.plist"
156 )))
157 };
158 if plist_path.exists() {
159 println!(
160 "Removing service plist file: {}",
161 plist_path.display()
162 );
163 fs::remove_file(&plist_path).with_context(|| {
164 format!(
165 "Failed to remove plist file: {}",
166 plist_path.display()
167 )
168 })?;
169 }
170 }
171 "windows"
172 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err()
173 && service_exists_windows(&service_name)? =>
174 {
175 println!("Removing Windows service: {service_name}");
176 Command::new("sc")
177 .arg("delete")
178 .arg(&service_name)
179 .status()
180 .context("Failed to run sc delete")?;
181 }
182 _ => {}
183 }
184
185 Ok(())
186}
187
188fn get_service_status(manifest: &types::InstallManifest) -> Result<String> {
190 let service_name = format!("zoi-{}", manifest.name);
191 match std::env::consts::OS {
192 "linux" => {
193 let mut cmd = Command::new("systemctl");
194 if manifest.scope != types::Scope::System {
195 cmd.arg("--user");
196 }
197 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_ok() {
198 return Ok("inactive".to_string());
199 }
200 let output = cmd
201 .arg("is-active")
202 .arg(&service_name)
203 .output()
204 .context("Failed to run systemctl is-active")?;
205 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
206 }
207 "macos" => {
208 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_ok() {
209 return Ok("inactive".to_string());
210 }
211 let output = Command::new("launchctl")
212 .arg("list")
213 .output()
214 .context("Failed to run launchctl list")?;
215 let list = String::from_utf8_lossy(&output.stdout);
216 if list.contains(&service_name) {
217 Ok("active".to_string())
218 } else {
219 Ok("inactive".to_string())
220 }
221 }
222 "windows" => {
223 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_ok() {
224 return Ok("inactive".to_string());
225 }
226 let output = Command::new("sc")
227 .arg("query")
228 .arg(&service_name)
229 .output()
230 .context("Failed to run sc query")?;
231 let out = String::from_utf8_lossy(&output.stdout);
232 if out.contains("RUNNING") {
233 Ok("active".to_string())
234 } else {
235 Ok("inactive".to_string())
236 }
237 }
238 _ => Ok("unknown".to_string())
239 }
240}
241
242fn manage_linux_service(
244 name: &str,
245 service: &types::Service,
246 action: ServiceAction,
247 scope: types::Scope
248) -> Result<()> {
249 let is_user = scope != types::Scope::System;
250
251 ensure_linux_unit_file(name, service, is_user)?;
252
253 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_ok() {
254 return Ok(());
255 }
256
257 let mut cmd = Command::new("systemctl");
258 if is_user {
259 cmd.arg("--user");
260 }
261
262 match action {
263 ServiceAction::Start => {
264 cmd.arg("start").arg(name);
265 }
266 ServiceAction::Stop => {
267 cmd.arg("stop").arg(name);
268 }
269 ServiceAction::Restart => {
270 cmd.arg("restart").arg(name);
271 }
272 ServiceAction::Status => {
273 cmd.arg("status").arg(name);
274 }
275 ServiceAction::Enable => {
276 cmd.arg("enable").arg("--now").arg(name);
277 }
278 ServiceAction::Disable => {
279 cmd.arg("disable").arg("--now").arg(name);
280 }
281 }
282
283 let status = cmd.status().with_context(|| {
284 format!("Failed to run systemctl for action {name:?}")
285 })?;
286 if !status.success() {
287 return Err(anyhow!("Failed to perform service action on '{name}'."));
288 }
289
290 Ok(())
291}
292
293fn ensure_linux_unit_file(
295 name: &str,
296 service: &types::Service,
297 is_user: bool
298) -> Result<()> {
299 let unit_path = if is_user {
300 let home = utils::get_user_home()
301 .ok_or_else(|| anyhow!("Could not find home directory"))?;
302 let path = sysroot::apply_sysroot(home.join(".config/systemd/user"));
303 fs::create_dir_all(&path).with_context(|| {
304 format!("Failed to create directory: {}", path.display())
305 })?;
306 path.join(format!("{name}.service"))
307 } else {
308 sysroot::apply_sysroot(PathBuf::from(format!(
309 "/etc/systemd/system/{name}.service"
310 )))
311 };
312
313 let mut content = String::from(
314 "[Unit]
315Description=Zoi managed service: "
316 );
317 content.push_str(name);
318 content.push_str(
319 "
320
321[Service]
322ExecStart="
323 );
324 content.push_str(&service.run);
325
326 if let Some(dir) = &service.working_dir {
327 content.push_str(
328 "
329WorkingDirectory="
330 );
331 content.push_str(dir);
332 }
333
334 if let Some(envs) = &service.env {
335 for (k, v) in envs {
336 let _ = write!(content, "\nEnvironment=\"{k}={v}\"");
337 }
338 }
339
340 if let Some(log) = &service.log_path {
341 content.push_str("\nStandardOutput=append:");
342 content.push_str(log);
343 }
344 if let Some(err_log) = &service.error_log_path {
345 content.push_str("\nStandardError=append:");
346 content.push_str(err_log);
347 }
348
349 content.push_str("\n\n[Install]\nWantedBy=");
350 content.push_str(if is_user {
351 "default.target"
352 } else {
353 "multi-user.target"
354 });
355 content.push('\n');
356
357 fs::write(&unit_path, content).with_context(|| {
358 format!("Failed to write unit file: {}", unit_path.display())
359 })?;
360
361 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err() {
362 let mut cmd = Command::new("systemctl");
363 if is_user {
364 cmd.arg("--user");
365 }
366 cmd.arg("daemon-reload");
367 run_service_command(&mut cmd, "systemctl daemon-reload")?;
368 }
369
370 Ok(())
371}
372
373fn manage_macos_service(
375 name: &str,
376 service: &types::Service,
377 action: ServiceAction,
378 scope: types::Scope
379) -> Result<()> {
380 let is_user = scope != types::Scope::System;
381 let plist_path = ensure_macos_plist(name, service, is_user)?;
382
383 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_ok() {
384 return Ok(());
385 }
386
387 match action {
388 ServiceAction::Start | ServiceAction::Enable => {
389 let mut cmd = Command::new("launchctl");
390 cmd.arg("bootstrap")
391 .arg(if is_user { "gui" } else { "system" })
392 .arg(plist_path);
393 run_service_command(&mut cmd, "launchctl bootstrap")?;
394 }
395 ServiceAction::Stop | ServiceAction::Disable => {
396 let mut cmd = Command::new("launchctl");
397 cmd.arg("bootout")
398 .arg(if is_user { "gui" } else { "system" })
399 .arg(plist_path);
400 run_service_command(&mut cmd, "launchctl bootout")?;
401 }
402 ServiceAction::Restart => {
403 manage_macos_service(name, service, ServiceAction::Stop, scope)?;
404 manage_macos_service(name, service, ServiceAction::Start, scope)?;
405 }
406 ServiceAction::Status => {
407 let mut cmd = Command::new("launchctl");
408 cmd.arg("list").arg(name);
409 run_service_command(&mut cmd, "launchctl list")?;
410 }
411 }
412
413 Ok(())
414}
415
416fn ensure_macos_plist(
418 name: &str,
419 service: &types::Service,
420 is_user: bool
421) -> Result<PathBuf> {
422 let plist_path = if is_user {
423 let home = utils::get_user_home()
424 .ok_or_else(|| anyhow!("Could not find home directory"))?;
425 let path = sysroot::apply_sysroot(home.join("Library/LaunchAgents"));
426 fs::create_dir_all(&path).with_context(|| {
427 format!("Failed to create directory: {}", path.display())
428 })?;
429 path.join(format!("{name}.plist"))
430 } else {
431 sysroot::apply_sysroot(PathBuf::from(format!(
432 "/Library/LaunchDaemons/{name}.plist"
433 )))
434 };
435
436 let mut content = format!(
437 r#"<?xml version="1.0" encoding="UTF-8"?>
438<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
439<plist version="1.0">
440<dict>
441 <key>Label</key>
442 <string>{name}</string>
443 <key>ProgramArguments</key>
444 <array>
445"#
446 );
447
448 for part in service.run.split_whitespace() {
449 let _ = writeln!(content, " <string>{part}</string>");
450 }
451
452 content.push_str(" </array>\n");
453
454 if let Some(dir) = &service.working_dir {
455 let _ = write!(
456 content,
457 " <key>WorkingDirectory</key>\n <string>{dir}</string>\n"
458 );
459 }
460
461 if let Some(envs) = &service.env {
462 content.push_str(" <key>EnvironmentVariables</key>\n <dict>\n");
463 for (k, v) in envs {
464 let _ = write!(
465 content,
466 " <key>{k}</key>\n <string>{v}</string>\n"
467 );
468 }
469 content.push_str(" </dict>\n");
470 }
471
472 if let Some(log) = &service.log_path {
473 let _ = write!(
474 content,
475 " <key>StandardOutPath</key>\n <string>{log}</string>\n"
476 );
477 }
478 if let Some(err_log) = &service.error_log_path {
479 let _ = write!(
480 content,
481 " <key>StandardErrorPath</key>\n \
482 <string>{err_log}</string>\n"
483 );
484 }
485
486 if service.run_at_load {
487 content.push_str(
488 " <key>RunAtLoad</key>
489 <true/>
490"
491 );
492 }
493
494 content.push_str(
495 "</dict>
496</plist>
497"
498 );
499
500 fs::write(&plist_path, content).with_context(|| {
501 format!("Failed to write plist file: {}", plist_path.display())
502 })?;
503 Ok(plist_path)
504}
505
506fn manage_windows_service(
508 name: &str,
509 service: &types::Service,
510 action: ServiceAction,
511 _scope: types::Scope
512) -> Result<()> {
513 if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_ok() {
514 return Ok(());
515 }
516
517 match action {
518 ServiceAction::Start => {
519 if !service_exists_windows(name)? {
520 create_windows_service(name, service)?;
521 }
522 let mut cmd = Command::new("sc");
523 cmd.arg("start").arg(name);
524 run_service_command(&mut cmd, "sc start")?;
525 }
526 ServiceAction::Stop => {
527 let mut cmd = Command::new("sc");
528 cmd.arg("stop").arg(name);
529 run_service_command(&mut cmd, "sc stop")?;
530 }
531 ServiceAction::Restart => {
532 let mut stop = Command::new("sc");
533 stop.arg("stop").arg(name);
534 run_service_command(&mut stop, "sc stop (restart)")?;
535 let mut start = Command::new("sc");
536 start.arg("start").arg(name);
537 run_service_command(&mut start, "sc start (restart)")?;
538 }
539 ServiceAction::Status => {
540 let mut cmd = Command::new("sc");
541 cmd.arg("query").arg(name);
542 run_service_command(&mut cmd, "sc query")?;
543 }
544 ServiceAction::Enable => {
545 if !service_exists_windows(name)? {
546 create_windows_service(name, service)?;
547 }
548 let mut configure = Command::new("sc");
549 configure.arg("config").arg(name).arg("start=auto");
550 run_service_command(&mut configure, "sc config")?;
551 let mut start = Command::new("sc");
552 start.arg("start").arg(name);
553 run_service_command(&mut start, "sc start")?;
554 }
555 ServiceAction::Disable => {
556 let mut stop = Command::new("sc");
557 stop.arg("stop").arg(name);
558 run_service_command(&mut stop, "sc stop")?;
559 let mut configure = Command::new("sc");
560 configure.arg("config").arg(name).arg("start=disabled");
561 run_service_command(&mut configure, "sc config")?;
562 }
563 }
564 Ok(())
565}
566
567#[cfg(all(test, unix))]
568mod tests {
569 use std::process::Command;
570
571 use super::run_service_command;
572
573 #[test]
574 fn service_command_reports_non_zero_exit_status() {
575 let mut command = Command::new("false");
576 assert!(run_service_command(&mut command, "test command").is_err());
577 }
578}
579
580fn service_exists_windows(name: &str) -> Result<bool> {
582 let output = Command::new("sc")
583 .arg("query")
584 .arg(name)
585 .output()
586 .context("Failed to run sc query (exists check)")?;
587 Ok(output.status.success())
588}
589
590fn create_windows_service(name: &str, service: &types::Service) -> Result<()> {
592 let mut cmd = Command::new("sc");
593 cmd.arg("create")
594 .arg(name)
595 .arg(format!("binPath={}", service.run));
596
597 if service.run_at_load {
598 cmd.arg("start=auto");
599 }
600
601 let status = cmd.status().context("Failed to run sc create")?;
602 if !status.success() {
603 return Err(anyhow!("Failed to create Windows service '{name}'."));
604 }
605 Ok(())
606}