Skip to main content

zoi_install/
service.rs

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/// Actions that can be performed on a background service.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ServiceAction {
13    /// Start the service.
14    Start,
15    /// Stop the service.
16    Stop,
17    /// Restart the service.
18    Restart,
19    /// Check the status of the service.
20    Status,
21    /// Enable the service to start automatically.
22    Enable,
23    /// Disable the service from starting automatically.
24    Disable
25}
26
27/// Runs a service-manager command and turns a non-zero exit code into an
28/// error instead of reporting a successful service action.
29fn 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
43/// Manages a service for a given package.
44///
45/// # Errors
46///
47/// Returns an error if the package is not installed, if it does not define
48/// a background service, or if the service management command fails.
49pub 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
83/// Lists all installed services and their current status.
84///
85/// # Errors
86///
87/// Returns an error if installed packages cannot be retrieved or if
88/// querying service status fails.
89pub 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
103/// Cleans up service files for a given package.
104///
105/// # Errors
106///
107/// Returns an error if service files cannot be removed or if
108/// the service manager cannot be reloaded.
109pub 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
188/// Gets the current status of a service.
189fn 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
242/// Manages a Linux systemd service.
243fn 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
293/// Ensures a Linux systemd unit file exists.
294fn 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
373/// Manages a macOS launchd service.
374fn 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
416/// Ensures a macOS launchd plist file exists.
417fn 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
506/// Manages a Windows service.
507fn 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
580/// Checks if a Windows service exists.
581fn 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
590/// Creates a Windows service.
591fn 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}