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