Skip to main content

zoi_install/
service.rs

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}