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