vtcode_core/scheduler/
service.rs1use anyhow::{Context, Result, anyhow};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use super::{LAUNCHD_LABEL, SERVICE_NAME, atomic_write};
6use vtcode_commons::VtCodePaths;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ServiceManager {
10 Launchd,
11 SystemdUser,
12}
13
14impl ServiceManager {
15 #[must_use]
16 pub fn current() -> Option<Self> {
17 #[cfg(target_os = "macos")]
18 {
19 return Some(Self::Launchd);
20 }
21 #[cfg(all(unix, not(target_os = "macos")))]
22 {
23 return Some(Self::SystemdUser);
24 }
25 #[cfg_attr(
26 unix,
27 allow(
28 unreachable_code,
29 reason = "Platform-specific returns make the fallback unreachable on Unix."
30 )
31 )]
32 None
33 }
34}
35
36#[derive(Debug, Clone)]
37pub struct ServiceInstallPlan {
38 pub manager: ServiceManager,
39 pub path: PathBuf,
40 pub contents: String,
41}
42
43pub fn render_service_install_plan(executable_path: &Path) -> Result<ServiceInstallPlan> {
44 let manager = ServiceManager::current()
45 .ok_or_else(|| anyhow!("Durable scheduler services are unsupported on this platform"))?;
46 let path = match manager {
47 ServiceManager::Launchd => dirs::home_dir()
48 .ok_or_else(|| anyhow!("Failed to resolve home directory"))?
49 .join("Library/LaunchAgents")
50 .join(format!("{LAUNCHD_LABEL}.plist")),
51 ServiceManager::SystemdUser => dirs::home_dir()
52 .ok_or_else(|| anyhow!("Failed to resolve home directory"))?
53 .join(".config/systemd/user")
54 .join(format!("{SERVICE_NAME}.service")),
55 };
56 let contents = match manager {
57 ServiceManager::Launchd => render_launchd_plist(executable_path),
58 ServiceManager::SystemdUser => render_systemd_unit(executable_path),
59 };
60 Ok(ServiceInstallPlan { manager, path, contents })
61}
62
63pub fn install_service_file(executable_path: &Path) -> Result<ServiceInstallPlan> {
64 let plan = render_service_install_plan(executable_path)?;
65 if let Some(parent) = plan.path.parent() {
66 VtCodePaths::ensure_user_dir(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
67 }
68 atomic_write(&plan.path, plan.contents.as_bytes())?;
69 Ok(plan)
70}
71
72pub fn uninstall_service_file() -> Result<Option<(ServiceManager, PathBuf, bool)>> {
73 let Some(manager) = ServiceManager::current() else {
74 return Ok(None);
75 };
76 let path = render_service_install_plan(Path::new("/tmp/vtcode"))?.path;
77 if path.exists() {
78 fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
79 return Ok(Some((manager, path, true)));
80 }
81 Ok(Some((manager, path, false)))
82}
83
84#[must_use]
85pub fn render_launchd_plist(executable_path: &Path) -> String {
86 format!(
87 r#"<?xml version="1.0" encoding="UTF-8"?>
88<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
89<plist version="1.0">
90 <dict>
91 <key>Label</key>
92 <string>{LAUNCHD_LABEL}</string>
93 <key>ProgramArguments</key>
94 <array>
95 <string>{}</string>
96 <string>schedule</string>
97 <string>serve</string>
98 </array>
99 <key>RunAtLoad</key>
100 <true/>
101 <key>KeepAlive</key>
102 <true/>
103 </dict>
104</plist>
105"#,
106 xml_escape(executable_path.display().to_string())
107 )
108}
109
110#[must_use]
111pub fn render_systemd_unit(executable_path: &Path) -> String {
112 format!(
113 "[Unit]\nDescription=VT Code scheduler\n\n[Service]\nType=simple\nExecStart={} schedule serve\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n",
114 shell_words::quote(executable_path.to_string_lossy().as_ref())
115 )
116}
117
118fn xml_escape(value: String) -> String {
119 value
120 .replace('&', "&")
121 .replace('<', "<")
122 .replace('>', ">")
123 .replace('"', """)
124 .replace('\'', "'")
125}