Skip to main content

omni_cli/
service.rs

1//! omni daemon service-unit generation (bd-b5c55a).
2//!
3//! Render (and optionally install) a host service unit that runs `omni daemon`
4//! as a managed service: systemd (Linux), launchd (macOS), or supervisord.
5//! Generation is pure + unit-tested; installation writes the rendered unit to
6//! the platform's standard path and returns the enable command to run.
7
8use std::path::PathBuf;
9
10use crate::AppError;
11
12/// Stable service/program name + launchd label.
13pub const SERVICE_NAME: &str = "omni-daemon";
14/// launchd reverse-DNS label.
15pub const LAUNCHD_LABEL: &str = "com.harryaskham.omni.daemon";
16
17/// A host service manager.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Platform {
20    Systemd,
21    Launchd,
22    Supervisord,
23}
24
25impl Platform {
26    /// Parse a platform name; `auto` resolves from the build target OS.
27    ///
28    /// # Errors
29    /// [`AppError::validation`] for an unknown platform.
30    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    /// Default platform for this OS (launchd on macOS, else systemd).
46    #[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/// What the service unit runs.
66#[derive(Debug, Clone)]
67pub struct ServiceSpec {
68    /// Full argv to exec, e.g. `["/usr/bin/omni","daemon","--listen","127.0.0.1:8766","--backend","xdotool"]`.
69    pub exec: Vec<String>,
70    /// Env var that holds the shared token (referenced via an EnvironmentFile,
71    /// never baked into the unit). `None` = no auth wiring.
72    pub token_env: Option<String>,
73    /// User-scope service (systemd --user / LaunchAgent) vs. system-scope.
74    pub user: bool,
75}
76
77impl ServiceSpec {
78    fn exec_line(&self) -> String {
79        self.exec.join(" ")
80    }
81}
82
83/// Render the unit text for `platform`.
84#[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        // `-` prefix: missing file is not an error. Set the token var here.
102        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('&', "&amp;")
178        .replace('<', "&lt;")
179        .replace('>', "&gt;")
180}
181
182/// The standard install path for a platform's unit.
183///
184/// # Errors
185/// [`AppError`] when `$HOME` is required (user scope) but unset.
186pub 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/// The command to enable/load the unit after installing it.
222#[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
243/// Render and write the unit to its standard path; returns the path + the
244/// enable command to run.
245///
246/// # Errors
247/// [`AppError`] on path resolution or write failure.
248pub 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        // system scope -> multi-user.target + /etc env path
307        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        // user systemd path uses $HOME (inject a deterministic one)
338        // (avoid mutating process env; just exercise the system-scope branches)
339        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}