Skip to main content

wire/
service.rs

1//! P1.9 (0.5.11): install + manage an OS service unit that runs
2//! `wire daemon` automatically.
3//!
4//! Today's onboarding tells operators "run `wire daemon &` in a tmux
5//! pane or write a launchd plist yourself" — friction that gets skipped,
6//! leading to the "daemon dies on reboot, peer sends evaporate" silent
7//! class. Bake the unit install into `wire service install` so it's one
8//! command, idempotent, cross-platform.
9//!
10//! macOS: `~/Library/LaunchAgents/sh.slancha.wire.daemon.plist`
11//! linux: `~/.config/systemd/user/wire-daemon.service`
12//!
13//! The unit auto-starts on login + restarts on crash. Pair with
14//! `wire upgrade` (P0.5) for atomic version swaps without unit churn.
15
16use std::path::PathBuf;
17use std::process::Command;
18
19use anyhow::{Context, Result, anyhow, bail};
20
21const LAUNCHD_LABEL: &str = "sh.slancha.wire.daemon";
22const SYSTEMD_UNIT_NAME: &str = "wire-daemon.service";
23
24/// Outcome of `wire service install` etc., suitable for both human + JSON
25/// rendering.
26#[derive(Debug, Clone, serde::Serialize)]
27pub struct ServiceReport {
28    pub action: String,
29    pub platform: String,
30    pub unit_path: String,
31    pub status: String,
32    pub detail: String,
33}
34
35/// Install a user-scope service unit that runs `wire daemon` and writes
36/// a [P0.4 versioned pidfile](crate::ensure_up::DaemonPid).
37pub fn install() -> Result<ServiceReport> {
38    let exe = std::env::current_exe()?;
39    let exe_str = exe.to_string_lossy().to_string();
40
41    if cfg!(target_os = "macos") {
42        let plist_path = launchd_plist_path()?;
43        if let Some(parent) = plist_path.parent() {
44            std::fs::create_dir_all(parent)
45                .with_context(|| format!("creating {parent:?}"))?;
46        }
47        let plist = launchd_plist_xml(&exe_str);
48        std::fs::write(&plist_path, plist)
49            .with_context(|| format!("writing {plist_path:?}"))?;
50
51        // launchctl load is idempotent — bootout first to avoid the
52        // "service already loaded" error on re-install.
53        let _ = Command::new("launchctl")
54            .args(["bootout", &launchctl_user_target()])
55            .status();
56        let load = Command::new("launchctl")
57            .args(["bootstrap", &launchctl_user_target(), plist_path.to_str().unwrap_or("")])
58            .status();
59        let loaded = load.map(|s| s.success()).unwrap_or(false);
60
61        return Ok(ServiceReport {
62            action: "install".into(),
63            platform: "macos-launchd".into(),
64            unit_path: plist_path.to_string_lossy().to_string(),
65            status: if loaded { "loaded".into() } else { "written".into() },
66            detail: if loaded {
67                "plist written and bootstrapped via launchctl".into()
68            } else {
69                "plist written; `launchctl bootstrap` failed — try manually".into()
70            },
71        });
72    }
73    if cfg!(target_os = "linux") {
74        let unit_path = systemd_unit_path()?;
75        if let Some(parent) = unit_path.parent() {
76            std::fs::create_dir_all(parent)
77                .with_context(|| format!("creating {parent:?}"))?;
78        }
79        let unit = systemd_unit_text(&exe_str);
80        std::fs::write(&unit_path, unit)
81            .with_context(|| format!("writing {unit_path:?}"))?;
82
83        // Reload + enable + start. Each is idempotent on linux.
84        let _ = Command::new("systemctl")
85            .args(["--user", "daemon-reload"])
86            .status();
87        let enabled = Command::new("systemctl")
88            .args(["--user", "enable", "--now", SYSTEMD_UNIT_NAME])
89            .status()
90            .map(|s| s.success())
91            .unwrap_or(false);
92
93        return Ok(ServiceReport {
94            action: "install".into(),
95            platform: "linux-systemd-user".into(),
96            unit_path: unit_path.to_string_lossy().to_string(),
97            status: if enabled { "enabled".into() } else { "written".into() },
98            detail: if enabled {
99                "service unit written, daemon-reload + enable --now succeeded".into()
100            } else {
101                "unit written; `systemctl --user enable --now` failed — try manually".into()
102            },
103        });
104    }
105    bail!("wire service install: unsupported platform")
106}
107
108pub fn uninstall() -> Result<ServiceReport> {
109    if cfg!(target_os = "macos") {
110        let plist_path = launchd_plist_path()?;
111        let _ = Command::new("launchctl")
112            .args(["bootout", &launchctl_user_target()])
113            .status();
114        let removed = if plist_path.exists() {
115            std::fs::remove_file(&plist_path).ok();
116            true
117        } else {
118            false
119        };
120        return Ok(ServiceReport {
121            action: "uninstall".into(),
122            platform: "macos-launchd".into(),
123            unit_path: plist_path.to_string_lossy().to_string(),
124            status: if removed { "removed".into() } else { "absent".into() },
125            detail: "launchctl bootout + plist file removed".into(),
126        });
127    }
128    if cfg!(target_os = "linux") {
129        let unit_path = systemd_unit_path()?;
130        let _ = Command::new("systemctl")
131            .args(["--user", "disable", "--now", SYSTEMD_UNIT_NAME])
132            .status();
133        let removed = if unit_path.exists() {
134            std::fs::remove_file(&unit_path).ok();
135            true
136        } else {
137            false
138        };
139        let _ = Command::new("systemctl")
140            .args(["--user", "daemon-reload"])
141            .status();
142        return Ok(ServiceReport {
143            action: "uninstall".into(),
144            platform: "linux-systemd-user".into(),
145            unit_path: unit_path.to_string_lossy().to_string(),
146            status: if removed { "removed".into() } else { "absent".into() },
147            detail: "systemctl disable --now + unit file removed".into(),
148        });
149    }
150    bail!("wire service uninstall: unsupported platform")
151}
152
153pub fn status() -> Result<ServiceReport> {
154    if cfg!(target_os = "macos") {
155        let plist_path = launchd_plist_path()?;
156        let exists = plist_path.exists();
157        // launchctl print on a user target succeeds if the service is
158        // loaded; failure = not loaded.
159        let listed = Command::new("launchctl")
160            .args(["list", LAUNCHD_LABEL])
161            .output()
162            .map(|o| o.status.success())
163            .unwrap_or(false);
164        return Ok(ServiceReport {
165            action: "status".into(),
166            platform: "macos-launchd".into(),
167            unit_path: plist_path.to_string_lossy().to_string(),
168            status: if listed {
169                "loaded".into()
170            } else if exists {
171                "installed (not loaded)".into()
172            } else {
173                "absent".into()
174            },
175            detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
176        });
177    }
178    if cfg!(target_os = "linux") {
179        let unit_path = systemd_unit_path()?;
180        let exists = unit_path.exists();
181        let active = Command::new("systemctl")
182            .args(["--user", "is-active", SYSTEMD_UNIT_NAME])
183            .output()
184            .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
185            .unwrap_or(false);
186        return Ok(ServiceReport {
187            action: "status".into(),
188            platform: "linux-systemd-user".into(),
189            unit_path: unit_path.to_string_lossy().to_string(),
190            status: if active {
191                "active".into()
192            } else if exists {
193                "installed (inactive)".into()
194            } else {
195                "absent".into()
196            },
197            detail: format!("unit exists={exists}, is-active={active}"),
198        });
199    }
200    bail!("wire service status: unsupported platform")
201}
202
203fn launchd_plist_path() -> Result<PathBuf> {
204    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
205    Ok(PathBuf::from(home)
206        .join("Library")
207        .join("LaunchAgents")
208        .join(format!("{LAUNCHD_LABEL}.plist")))
209}
210
211fn launchctl_user_target() -> String {
212    // `gui/<uid>` is the modern domain for user-scope LaunchAgents. Use
213    // `id -u` rather than pulling in libc just for getuid().
214    let uid = Command::new("id")
215        .args(["-u"])
216        .output()
217        .ok()
218        .and_then(|o| {
219            if o.status.success() {
220                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
221            } else {
222                None
223            }
224        })
225        .unwrap_or_else(|| "0".to_string());
226    format!("gui/{uid}")
227}
228
229fn launchd_plist_xml(exe: &str) -> String {
230    // Minimal launchd plist. KeepAlive=true keeps the daemon up across
231    // any crash; RunAtLoad=true starts it on launchctl bootstrap +
232    // every login.
233    format!(
234        r#"<?xml version="1.0" encoding="UTF-8"?>
235<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
236<plist version="1.0">
237<dict>
238    <key>Label</key>
239    <string>{LAUNCHD_LABEL}</string>
240    <key>ProgramArguments</key>
241    <array>
242        <string>{exe}</string>
243        <string>daemon</string>
244        <string>--interval</string>
245        <string>5</string>
246    </array>
247    <key>RunAtLoad</key>
248    <true/>
249    <key>KeepAlive</key>
250    <true/>
251    <key>ProcessType</key>
252    <string>Background</string>
253    <key>StandardOutPath</key>
254    <string>/dev/null</string>
255    <key>StandardErrorPath</key>
256    <string>/dev/null</string>
257</dict>
258</plist>
259"#
260    )
261}
262
263fn systemd_unit_path() -> Result<PathBuf> {
264    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
265    Ok(PathBuf::from(home)
266        .join(".config")
267        .join("systemd")
268        .join("user")
269        .join(SYSTEMD_UNIT_NAME))
270}
271
272fn systemd_unit_text(exe: &str) -> String {
273    // User-scope unit. Restart=on-failure + RestartSec=5 keep daemon
274    // alive through transient crashes; Install.WantedBy=default.target
275    // makes it survive logout/login.
276    format!(
277        r#"[Unit]
278Description=wire — magic-wormhole for AI agents (daemon)
279After=network-online.target
280Wants=network-online.target
281
282[Service]
283Type=simple
284ExecStart={exe} daemon --interval 5
285Restart=on-failure
286RestartSec=5
287
288[Install]
289WantedBy=default.target
290"#
291    )
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn launchd_plist_xml_contains_required_keys() {
300        // P1.9: catch the "minimal plist forgot KeepAlive and the daemon
301        // died silently when terminal closed" class.
302        let xml = launchd_plist_xml("/usr/local/bin/wire");
303        assert!(xml.contains("<key>Label</key>"));
304        assert!(xml.contains(LAUNCHD_LABEL));
305        assert!(xml.contains("/usr/local/bin/wire"));
306        assert!(xml.contains("<key>KeepAlive</key>"));
307        assert!(xml.contains("<key>RunAtLoad</key>"));
308        // <true/> form, not <bool>true</bool>, is the only one launchd
309        // accepts for plist booleans.
310        assert!(xml.contains("<true/>"));
311    }
312
313    #[test]
314    fn systemd_unit_text_contains_required_directives() {
315        let unit = systemd_unit_text("/usr/local/bin/wire");
316        assert!(unit.contains("[Unit]"));
317        assert!(unit.contains("[Service]"));
318        assert!(unit.contains("[Install]"));
319        assert!(unit.contains("/usr/local/bin/wire daemon"));
320        assert!(unit.contains("Restart=on-failure"));
321        // WantedBy must be default.target for user-scope units (not
322        // multi-user.target which is system-scope and unprivileged users
323        // can't enable).
324        assert!(unit.contains("WantedBy=default.target"));
325    }
326}