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/// Manages a service for a given package.
28///
29/// # Errors
30///
31/// Returns an error if the package is not installed, if it does not define
32/// a background service, or if the service management command fails.
33pub 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
67/// Lists all installed services and their current status.
68///
69/// # Errors
70///
71/// Returns an error if installed packages cannot be retrieved or if
72/// querying service status fails.
73pub 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
87/// Cleans up service files for a given package.
88///
89/// # Errors
90///
91/// Returns an error if service files cannot be removed or if
92/// the service manager cannot be reloaded.
93pub 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
173/// Gets the current status of a service.
174fn 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
227/// Manages a Linux systemd service.
228fn 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
278/// Ensures a Linux systemd unit file exists.
279fn 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
363/// Manages a macOS launchd service.
364fn 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
410/// Ensures a macOS launchd plist file exists.
411fn 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
504/// Manages a Windows service.
505fn 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
575/// Checks if a Windows service exists.
576fn 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
585/// Creates a Windows service.
586fn 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}