1use std::path::PathBuf;
9
10use crate::AppError;
11
12pub const SERVICE_NAME: &str = "omni-daemon";
14pub const LAUNCHD_LABEL: &str = "com.harryaskham.omni.daemon";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Platform {
20 Systemd,
21 Launchd,
22 Supervisord,
23}
24
25impl Platform {
26 pub fn parse(s: &str) -> Result<Self, AppError> {
31 match s {
32 "systemd" => Ok(Platform::Systemd),
33 "launchd" => Ok(Platform::Launchd),
34 "supervisord" => Ok(Platform::Supervisord),
35 "auto" => Ok(Self::autodetect()),
36 other => Err(AppError::validation(
37 "service_platform",
38 format!(
39 "unknown service platform {other} (expected systemd|launchd|supervisord|auto)"
40 ),
41 )),
42 }
43 }
44
45 #[must_use]
47 pub fn autodetect() -> Self {
48 if cfg!(target_os = "macos") {
49 Platform::Launchd
50 } else {
51 Platform::Systemd
52 }
53 }
54
55 #[must_use]
56 pub fn label(self) -> &'static str {
57 match self {
58 Platform::Systemd => "systemd",
59 Platform::Launchd => "launchd",
60 Platform::Supervisord => "supervisord",
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct ServiceSpec {
68 pub exec: Vec<String>,
70 pub token_env: Option<String>,
73 pub user: bool,
75}
76
77impl ServiceSpec {
78 fn exec_line(&self) -> String {
79 self.exec.join(" ")
80 }
81}
82
83#[must_use]
85pub fn render(platform: Platform, spec: &ServiceSpec) -> String {
86 match platform {
87 Platform::Systemd => render_systemd(spec),
88 Platform::Launchd => render_launchd(spec),
89 Platform::Supervisord => render_supervisord(spec),
90 }
91}
92
93fn render_systemd(spec: &ServiceSpec) -> String {
94 let wanted_by = if spec.user {
95 "default.target"
96 } else {
97 "multi-user.target"
98 };
99 let mut env_line = String::new();
100 if spec.token_env.is_some() {
101 let path = if spec.user {
103 "-%h/.config/omni/daemon.env"
104 } else {
105 "-/etc/omni/daemon.env"
106 };
107 env_line = format!("EnvironmentFile={path}\n");
108 }
109 format!(
110 "[Unit]\n\
111 Description=omni daemon — replay omni keystrokes on this host ({LAUNCHD_LABEL})\n\
112 After=network.target\n\
113 \n\
114 [Service]\n\
115 ExecStart={exec}\n\
116 Restart=on-failure\n\
117 RestartSec=2\n\
118 {env_line}\n\
119 [Install]\n\
120 WantedBy={wanted_by}\n",
121 exec = spec.exec_line(),
122 )
123}
124
125fn render_launchd(spec: &ServiceSpec) -> String {
126 let args = spec
127 .exec
128 .iter()
129 .map(|a| format!(" <string>{}</string>", xml_escape(a)))
130 .collect::<Vec<_>>()
131 .join("\n");
132 let token_comment = if let Some(var) = &spec.token_env {
133 format!(
134 " <!-- Set {var} in an EnvironmentVariables dict or a wrapper; do not bake the secret here. -->\n"
135 )
136 } else {
137 String::new()
138 };
139 format!(
140 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
141 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
142 <plist version=\"1.0\">\n\
143 <dict>\n\
144 {token_comment} <key>Label</key>\n\
145 \x20\x20<string>{LAUNCHD_LABEL}</string>\n\
146 \x20\x20<key>ProgramArguments</key>\n\
147 \x20\x20<array>\n\
148 {args}\n\
149 \x20\x20</array>\n\
150 \x20\x20<key>RunAtLoad</key>\n\
151 \x20\x20<true/>\n\
152 \x20\x20<key>KeepAlive</key>\n\
153 \x20\x20<true/>\n\
154 </dict>\n\
155 </plist>\n",
156 )
157}
158
159fn render_supervisord(spec: &ServiceSpec) -> String {
160 let token_comment = if let Some(var) = &spec.token_env {
161 format!("; set {var} in [supervisord] environment= or this program's environment=\n")
162 } else {
163 String::new()
164 };
165 format!(
166 "[program:{SERVICE_NAME}]\n\
167 {token_comment}command={exec}\n\
168 autostart=true\n\
169 autorestart=true\n\
170 startsecs=2\n\
171 stopsignal=TERM\n",
172 exec = spec.exec_line(),
173 )
174}
175
176fn xml_escape(s: &str) -> String {
177 s.replace('&', "&")
178 .replace('<', "<")
179 .replace('>', ">")
180}
181
182pub fn unit_path(platform: Platform, user: bool) -> Result<PathBuf, AppError> {
187 let home = || {
188 std::env::var("HOME")
189 .ok()
190 .filter(|h| !h.is_empty())
191 .map(PathBuf::from)
192 .ok_or_else(|| {
193 AppError::validation("service_home", "$HOME is required for a user-scope unit")
194 })
195 };
196 Ok(match platform {
197 Platform::Systemd => {
198 if user {
199 home()?
200 .join(".config/systemd/user")
201 .join(format!("{SERVICE_NAME}.service"))
202 } else {
203 PathBuf::from("/etc/systemd/system").join(format!("{SERVICE_NAME}.service"))
204 }
205 }
206 Platform::Launchd => {
207 if user {
208 home()?
209 .join("Library/LaunchAgents")
210 .join(format!("{LAUNCHD_LABEL}.plist"))
211 } else {
212 PathBuf::from("/Library/LaunchDaemons").join(format!("{LAUNCHD_LABEL}.plist"))
213 }
214 }
215 Platform::Supervisord => {
216 PathBuf::from("/etc/supervisor/conf.d").join(format!("{SERVICE_NAME}.conf"))
217 }
218 })
219}
220
221#[must_use]
223pub fn enable_hint(platform: Platform, user: bool, path: &std::path::Path) -> String {
224 match platform {
225 Platform::Systemd => {
226 if user {
227 format!(
228 "systemctl --user daemon-reload && systemctl --user enable --now {SERVICE_NAME}"
229 )
230 } else {
231 format!(
232 "sudo systemctl daemon-reload && sudo systemctl enable --now {SERVICE_NAME}"
233 )
234 }
235 }
236 Platform::Launchd => format!("launchctl load -w {}", path.display()),
237 Platform::Supervisord => {
238 "sudo supervisorctl reread && sudo supervisorctl update".to_owned()
239 }
240 }
241}
242
243pub fn install(platform: Platform, spec: &ServiceSpec) -> Result<(PathBuf, String), AppError> {
249 let path = unit_path(platform, spec.user)?;
250 if let Some(dir) = path.parent() {
251 std::fs::create_dir_all(dir)
252 .map_err(|e| AppError::internal("service_mkdir", format!("{}: {e}", dir.display())))?;
253 }
254 std::fs::write(&path, render(platform, spec))
255 .map_err(|e| AppError::internal("service_write", format!("{}: {e}", path.display())))?;
256 let hint = enable_hint(platform, spec.user, &path);
257 Ok((path, hint))
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn spec() -> ServiceSpec {
265 ServiceSpec {
266 exec: vec![
267 "/usr/bin/omni".into(),
268 "daemon".into(),
269 "--listen".into(),
270 "127.0.0.1:8766".into(),
271 "--backend".into(),
272 "xdotool".into(),
273 ],
274 token_env: Some("OMNI_RELAY_TOKEN".into()),
275 user: true,
276 }
277 }
278
279 #[test]
280 fn platform_parse() {
281 assert_eq!(Platform::parse("systemd").unwrap(), Platform::Systemd);
282 assert_eq!(Platform::parse("launchd").unwrap(), Platform::Launchd);
283 assert_eq!(
284 Platform::parse("supervisord").unwrap(),
285 Platform::Supervisord
286 );
287 assert!(Platform::parse("auto").is_ok());
288 assert_eq!(
289 Platform::parse("nope").unwrap_err().code(),
290 "service_platform"
291 );
292 }
293
294 #[test]
295 fn systemd_unit_has_execstart_and_install() {
296 let u = render(Platform::Systemd, &spec());
297 assert!(
298 u.contains("ExecStart=/usr/bin/omni daemon --listen 127.0.0.1:8766 --backend xdotool")
299 );
300 assert!(u.contains("Restart=on-failure"));
301 assert!(
302 u.contains("WantedBy=default.target"),
303 "user unit -> default.target"
304 );
305 assert!(u.contains("EnvironmentFile=-%h/.config/omni/daemon.env"));
306 let mut sys = spec();
308 sys.user = false;
309 let u = render(Platform::Systemd, &sys);
310 assert!(u.contains("WantedBy=multi-user.target"));
311 assert!(u.contains("EnvironmentFile=-/etc/omni/daemon.env"));
312 }
313
314 #[test]
315 fn launchd_plist_has_program_arguments() {
316 let u = render(Platform::Launchd, &spec());
317 assert!(u.contains("<key>Label</key>"));
318 assert!(u.contains(&format!("<string>{LAUNCHD_LABEL}</string>")));
319 assert!(u.contains("<string>--listen</string>"));
320 assert!(u.contains("<string>127.0.0.1:8766</string>"));
321 assert!(u.contains("<key>KeepAlive</key>"));
322 assert!(u.contains("<key>RunAtLoad</key>"));
323 }
324
325 #[test]
326 fn supervisord_program_block() {
327 let u = render(Platform::Supervisord, &spec());
328 assert!(u.contains(&format!("[program:{SERVICE_NAME}]")));
329 assert!(
330 u.contains("command=/usr/bin/omni daemon --listen 127.0.0.1:8766 --backend xdotool")
331 );
332 assert!(u.contains("autorestart=true"));
333 }
334
335 #[test]
336 fn unit_paths_resolve() {
337 let sys = unit_path(Platform::Systemd, false).unwrap();
340 assert!(sys.ends_with("omni-daemon.service"));
341 assert!(sys.starts_with("/etc/systemd/system"));
342 let sup = unit_path(Platform::Supervisord, false).unwrap();
343 assert!(sup.ends_with("omni-daemon.conf"));
344 }
345
346 #[test]
347 fn enable_hints() {
348 let p = PathBuf::from("/x/omni-daemon.service");
349 assert!(enable_hint(Platform::Systemd, true, &p).contains("--user"));
350 assert!(enable_hint(Platform::Systemd, false, &p).contains("sudo systemctl"));
351 assert!(enable_hint(Platform::Launchd, true, &p).contains("launchctl load"));
352 assert!(enable_hint(Platform::Supervisord, true, &p).contains("supervisorctl"));
353 }
354}