Skip to main content

wire/
service.rs

1//! Install + manage OS service units that run wire components
2//! automatically across reboots.
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//! ## Service kinds (v0.5.22)
11//!
12//! - **Daemon** (`wire service install`) — runs `wire daemon --interval 5`.
13//!   Pulls/pushes the operator's own inbox/outbox. ONE per identity.
14//!   Label: `sh.slancha.wire.daemon`.
15//!
16//! - **LocalRelay** (`wire service install --local-relay`) — runs
17//!   `wire relay-server --bind 127.0.0.1:8771 --local-only`. The
18//!   loopback transport for sister-agents on the same box (v0.5.17
19//!   dual-slot). ONE per machine. Label: `sh.slancha.wire.local-relay`.
20//!
21//! ## Unit paths
22//!
23//! - macOS: `~/Library/LaunchAgents/<label>.plist`
24//! - linux: `~/.config/systemd/user/wire-<kind>.service`
25//!
26//! Units auto-start on login + restart on crash. Pair with
27//! `wire upgrade` (P0.5) for atomic version swaps without unit churn.
28
29use std::path::PathBuf;
30use std::process::Command;
31
32use anyhow::{Context, Result, anyhow, bail};
33
34/// Which wire service is being managed. Each kind has its own launchd
35/// label / systemd unit name / log path so the two kinds can coexist
36/// on the same machine without colliding.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ServiceKind {
39    /// `wire daemon --interval 5`. One per identity. The default.
40    Daemon,
41    /// `wire relay-server --bind 127.0.0.1:8771 --local-only`. One
42    /// per machine — provides the loopback transport that sister
43    /// agents' sessions route through (v0.5.17 dual-slot).
44    LocalRelay,
45}
46
47impl ServiceKind {
48    /// launchd Label / systemd unit base name (without `.service`).
49    fn label(self) -> &'static str {
50        match self {
51            ServiceKind::Daemon => "sh.slancha.wire.daemon",
52            ServiceKind::LocalRelay => "sh.slancha.wire.local-relay",
53        }
54    }
55
56    /// systemd unit filename (`wire-daemon.service` etc.).
57    fn systemd_unit_name(self) -> &'static str {
58        match self {
59            ServiceKind::Daemon => "wire-daemon.service",
60            ServiceKind::LocalRelay => "wire-local-relay.service",
61        }
62    }
63
64    /// Human-readable name for `Description=` / log messages.
65    fn description(self) -> &'static str {
66        match self {
67            ServiceKind::Daemon => "wire — daemon (push/pull sync)",
68            ServiceKind::LocalRelay => "wire — local-only relay (127.0.0.1:8771)",
69        }
70    }
71
72    /// Arguments to pass to the `wire` binary in the ProgramArguments
73    /// / ExecStart line. The first element of the wider arg vector is
74    /// the binary itself, supplied separately by callers.
75    fn binary_args(self) -> &'static [&'static str] {
76        match self {
77            ServiceKind::Daemon => &["daemon", "--interval", "5"],
78            ServiceKind::LocalRelay => &[
79                "relay-server",
80                "--bind",
81                "127.0.0.1:8771",
82                "--local-only",
83            ],
84        }
85    }
86
87    /// Per-kind log file basename.
88    ///
89    /// macOS: `~/Library/Logs/wire-<kind>.log` — surfaces in
90    /// Console.app so operators can read crash output. Daemon previously
91    /// redirected to /dev/null; v0.5.22 switches to a real log file
92    /// (one-time behavior change, matches typical macOS service ergonomics).
93    ///
94    /// linux: `$XDG_STATE_HOME/wire/<kind>.log` so the location stays
95    /// stable across re-installs.
96    fn log_basename(self) -> &'static str {
97        match self {
98            ServiceKind::Daemon => "wire-daemon.log",
99            ServiceKind::LocalRelay => "wire-local-relay.log",
100        }
101    }
102}
103
104/// Outcome of `wire service install` etc., suitable for both human + JSON
105/// rendering.
106#[derive(Debug, Clone, serde::Serialize)]
107pub struct ServiceReport {
108    pub action: String,
109    pub platform: String,
110    pub unit_path: String,
111    pub status: String,
112    pub detail: String,
113    /// v0.5.22: which service kind this report is about ("daemon" or
114    /// "local-relay"). Lets JSON consumers distinguish multiple reports.
115    #[serde(default)]
116    pub kind: String,
117}
118
119/// Back-compat shim — `wire service install` with no flags installs
120/// the daemon, matching pre-v0.5.22 behavior.
121pub fn install() -> Result<ServiceReport> {
122    install_kind(ServiceKind::Daemon)
123}
124pub fn uninstall() -> Result<ServiceReport> {
125    uninstall_kind(ServiceKind::Daemon)
126}
127pub fn status() -> Result<ServiceReport> {
128    status_kind(ServiceKind::Daemon)
129}
130
131/// Install a user-scope service unit for the given kind.
132pub fn install_kind(kind: ServiceKind) -> Result<ServiceReport> {
133    let exe = std::env::current_exe()?;
134    let exe_str = exe.to_string_lossy().to_string();
135
136    let log_path = ensure_log_path(kind)?;
137    let log_str = log_path.to_string_lossy().to_string();
138
139    if cfg!(target_os = "macos") {
140        let plist_path = launchd_plist_path(kind)?;
141        if let Some(parent) = plist_path.parent() {
142            std::fs::create_dir_all(parent)
143                .with_context(|| format!("creating {parent:?}"))?;
144        }
145        let plist = launchd_plist_xml(kind, &exe_str, &log_str);
146        std::fs::write(&plist_path, plist)
147            .with_context(|| format!("writing {plist_path:?}"))?;
148
149        // launchctl bootstrap is idempotent if we bootout first.
150        let _ = Command::new("launchctl")
151            .args(["bootout", &launchctl_target_for(kind)])
152            .status();
153        let load = Command::new("launchctl")
154            .args([
155                "bootstrap",
156                &launchctl_user_target(),
157                plist_path.to_str().unwrap_or(""),
158            ])
159            .status();
160        let loaded = load.map(|s| s.success()).unwrap_or(false);
161
162        return Ok(ServiceReport {
163            action: "install".into(),
164            platform: "macos-launchd".into(),
165            unit_path: plist_path.to_string_lossy().to_string(),
166            status: if loaded { "loaded".into() } else { "written".into() },
167            detail: if loaded {
168                format!(
169                    "plist written + bootstrapped; logs at {log_str}"
170                )
171            } else {
172                format!(
173                    "plist written; `launchctl bootstrap` failed — try `launchctl bootstrap {} {}` manually",
174                    launchctl_user_target(),
175                    plist_path.display()
176                )
177            },
178            kind: kind_label(kind).into(),
179        });
180    }
181    if cfg!(target_os = "linux") {
182        let unit_path = systemd_unit_path(kind)?;
183        if let Some(parent) = unit_path.parent() {
184            std::fs::create_dir_all(parent)
185                .with_context(|| format!("creating {parent:?}"))?;
186        }
187        let unit = systemd_unit_text(kind, &exe_str);
188        std::fs::write(&unit_path, unit)
189            .with_context(|| format!("writing {unit_path:?}"))?;
190
191        // Reload + enable + start. Each is idempotent on linux.
192        let _ = Command::new("systemctl")
193            .args(["--user", "daemon-reload"])
194            .status();
195        let enabled = Command::new("systemctl")
196            .args(["--user", "enable", "--now", kind.systemd_unit_name()])
197            .status()
198            .map(|s| s.success())
199            .unwrap_or(false);
200
201        return Ok(ServiceReport {
202            action: "install".into(),
203            platform: "linux-systemd-user".into(),
204            unit_path: unit_path.to_string_lossy().to_string(),
205            status: if enabled { "enabled".into() } else { "written".into() },
206            detail: if enabled {
207                format!("unit written + enable --now succeeded; logs at {log_str}")
208            } else {
209                format!(
210                    "unit written; `systemctl --user enable --now {}` failed — try manually",
211                    kind.systemd_unit_name()
212                )
213            },
214            kind: kind_label(kind).into(),
215        });
216    }
217    bail!("wire service install: unsupported platform")
218}
219
220pub fn uninstall_kind(kind: ServiceKind) -> Result<ServiceReport> {
221    if cfg!(target_os = "macos") {
222        let plist_path = launchd_plist_path(kind)?;
223        let _ = Command::new("launchctl")
224            .args(["bootout", &launchctl_target_for(kind)])
225            .status();
226        let removed = if plist_path.exists() {
227            std::fs::remove_file(&plist_path).ok();
228            true
229        } else {
230            false
231        };
232        return Ok(ServiceReport {
233            action: "uninstall".into(),
234            platform: "macos-launchd".into(),
235            unit_path: plist_path.to_string_lossy().to_string(),
236            status: if removed { "removed".into() } else { "absent".into() },
237            detail: "launchctl bootout + plist file removed".into(),
238            kind: kind_label(kind).into(),
239        });
240    }
241    if cfg!(target_os = "linux") {
242        let unit_path = systemd_unit_path(kind)?;
243        let _ = Command::new("systemctl")
244            .args(["--user", "disable", "--now", kind.systemd_unit_name()])
245            .status();
246        let removed = if unit_path.exists() {
247            std::fs::remove_file(&unit_path).ok();
248            true
249        } else {
250            false
251        };
252        let _ = Command::new("systemctl")
253            .args(["--user", "daemon-reload"])
254            .status();
255        return Ok(ServiceReport {
256            action: "uninstall".into(),
257            platform: "linux-systemd-user".into(),
258            unit_path: unit_path.to_string_lossy().to_string(),
259            status: if removed { "removed".into() } else { "absent".into() },
260            detail: "systemctl disable --now + unit file removed".into(),
261            kind: kind_label(kind).into(),
262        });
263    }
264    bail!("wire service uninstall: unsupported platform")
265}
266
267pub fn status_kind(kind: ServiceKind) -> Result<ServiceReport> {
268    if cfg!(target_os = "macos") {
269        let plist_path = launchd_plist_path(kind)?;
270        let exists = plist_path.exists();
271        let listed = Command::new("launchctl")
272            .args(["list", kind.label()])
273            .output()
274            .map(|o| o.status.success())
275            .unwrap_or(false);
276        return Ok(ServiceReport {
277            action: "status".into(),
278            platform: "macos-launchd".into(),
279            unit_path: plist_path.to_string_lossy().to_string(),
280            status: if listed {
281                "loaded".into()
282            } else if exists {
283                "installed (not loaded)".into()
284            } else {
285                "absent".into()
286            },
287            detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
288            kind: kind_label(kind).into(),
289        });
290    }
291    if cfg!(target_os = "linux") {
292        let unit_path = systemd_unit_path(kind)?;
293        let exists = unit_path.exists();
294        let active = Command::new("systemctl")
295            .args(["--user", "is-active", kind.systemd_unit_name()])
296            .output()
297            .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
298            .unwrap_or(false);
299        return Ok(ServiceReport {
300            action: "status".into(),
301            platform: "linux-systemd-user".into(),
302            unit_path: unit_path.to_string_lossy().to_string(),
303            status: if active {
304                "active".into()
305            } else if exists {
306                "installed (inactive)".into()
307            } else {
308                "absent".into()
309            },
310            detail: format!("unit exists={exists}, is-active={active}"),
311            kind: kind_label(kind).into(),
312        });
313    }
314    bail!("wire service status: unsupported platform")
315}
316
317fn kind_label(kind: ServiceKind) -> &'static str {
318    match kind {
319        ServiceKind::Daemon => "daemon",
320        ServiceKind::LocalRelay => "local-relay",
321    }
322}
323
324fn launchd_plist_path(kind: ServiceKind) -> Result<PathBuf> {
325    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
326    Ok(PathBuf::from(home)
327        .join("Library")
328        .join("LaunchAgents")
329        .join(format!("{}.plist", kind.label())))
330}
331
332fn launchctl_user_target() -> String {
333    let uid = Command::new("id")
334        .args(["-u"])
335        .output()
336        .ok()
337        .and_then(|o| {
338            if o.status.success() {
339                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
340            } else {
341                None
342            }
343        })
344        .unwrap_or_else(|| "0".to_string());
345    format!("gui/{uid}")
346}
347
348fn launchctl_target_for(kind: ServiceKind) -> String {
349    format!("{}/{}", launchctl_user_target(), kind.label())
350}
351
352/// Resolve the log destination for a service kind and ensure the
353/// parent directory exists. Returns the absolute path the service
354/// should write stdout/stderr to.
355fn ensure_log_path(kind: ServiceKind) -> Result<PathBuf> {
356    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
357    let dir = if cfg!(target_os = "macos") {
358        PathBuf::from(&home).join("Library").join("Logs")
359    } else {
360        // Linux: prefer XDG_STATE_HOME/wire/, fall back to ~/.cache/wire/.
361        std::env::var("XDG_STATE_HOME")
362            .ok()
363            .map(|p| PathBuf::from(p).join("wire"))
364            .or_else(|| {
365                std::env::var("XDG_CACHE_HOME")
366                    .ok()
367                    .map(|p| PathBuf::from(p).join("wire"))
368            })
369            .unwrap_or_else(|| PathBuf::from(&home).join(".cache").join("wire"))
370    };
371    std::fs::create_dir_all(&dir).with_context(|| format!("creating log dir {dir:?}"))?;
372    Ok(dir.join(kind.log_basename()))
373}
374
375fn launchd_plist_xml(kind: ServiceKind, exe: &str, log_path: &str) -> String {
376    let args_xml = kind
377        .binary_args()
378        .iter()
379        .map(|a| format!("        <string>{a}</string>"))
380        .collect::<Vec<_>>()
381        .join("\n");
382    let label = kind.label();
383    format!(
384        r#"<?xml version="1.0" encoding="UTF-8"?>
385<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
386<plist version="1.0">
387<dict>
388    <key>Label</key>
389    <string>{label}</string>
390    <key>ProgramArguments</key>
391    <array>
392        <string>{exe}</string>
393{args_xml}
394    </array>
395    <key>RunAtLoad</key>
396    <true/>
397    <key>KeepAlive</key>
398    <true/>
399    <key>ProcessType</key>
400    <string>Background</string>
401    <key>StandardOutPath</key>
402    <string>{log_path}</string>
403    <key>StandardErrorPath</key>
404    <string>{log_path}</string>
405</dict>
406</plist>
407"#
408    )
409}
410
411fn systemd_unit_path(kind: ServiceKind) -> Result<PathBuf> {
412    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
413    Ok(PathBuf::from(home)
414        .join(".config")
415        .join("systemd")
416        .join("user")
417        .join(kind.systemd_unit_name()))
418}
419
420fn systemd_unit_text(kind: ServiceKind, exe: &str) -> String {
421    let args = kind.binary_args().join(" ");
422    let desc = kind.description();
423    format!(
424        r#"[Unit]
425Description={desc}
426After=network-online.target
427Wants=network-online.target
428
429[Service]
430Type=simple
431ExecStart={exe} {args}
432Restart=on-failure
433RestartSec=5
434
435[Install]
436WantedBy=default.target
437"#
438    )
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn launchd_plist_xml_for_daemon_contains_required_keys() {
447        let xml = launchd_plist_xml(
448            ServiceKind::Daemon,
449            "/usr/local/bin/wire",
450            "/tmp/wire-daemon.log",
451        );
452        assert!(xml.contains("<key>Label</key>"));
453        assert!(xml.contains(ServiceKind::Daemon.label()));
454        assert!(xml.contains("/usr/local/bin/wire"));
455        assert!(xml.contains("<string>daemon</string>"));
456        assert!(xml.contains("<string>--interval</string>"));
457        assert!(xml.contains("<key>KeepAlive</key>"));
458        assert!(xml.contains("<key>RunAtLoad</key>"));
459        assert!(xml.contains("<true/>"));
460        // v0.5.22: log path is honored, not /dev/null.
461        assert!(xml.contains("/tmp/wire-daemon.log"));
462        assert!(!xml.contains("/dev/null"));
463    }
464
465    #[test]
466    fn launchd_plist_xml_for_local_relay_uses_correct_args() {
467        let xml = launchd_plist_xml(
468            ServiceKind::LocalRelay,
469            "/usr/local/bin/wire",
470            "/tmp/wire-local-relay.log",
471        );
472        assert!(xml.contains(ServiceKind::LocalRelay.label()));
473        assert!(xml.contains("<string>relay-server</string>"));
474        assert!(xml.contains("<string>--bind</string>"));
475        assert!(xml.contains("<string>127.0.0.1:8771</string>"));
476        assert!(xml.contains("<string>--local-only</string>"));
477        // Must NOT include daemon args.
478        assert!(!xml.contains("<string>daemon</string>"));
479    }
480
481    #[test]
482    fn systemd_unit_text_for_daemon_contains_required_directives() {
483        let unit = systemd_unit_text(ServiceKind::Daemon, "/usr/local/bin/wire");
484        assert!(unit.contains("[Unit]"));
485        assert!(unit.contains("[Service]"));
486        assert!(unit.contains("[Install]"));
487        assert!(unit.contains("/usr/local/bin/wire daemon --interval 5"));
488        assert!(unit.contains("Restart=on-failure"));
489        assert!(unit.contains("WantedBy=default.target"));
490    }
491
492    #[test]
493    fn systemd_unit_text_for_local_relay_uses_correct_exec() {
494        let unit = systemd_unit_text(ServiceKind::LocalRelay, "/usr/local/bin/wire");
495        assert!(unit.contains(
496            "/usr/local/bin/wire relay-server --bind 127.0.0.1:8771 --local-only"
497        ));
498        assert!(!unit.contains("daemon --interval"));
499    }
500
501    #[test]
502    fn label_and_unit_name_distinct_per_kind() {
503        // Both kinds MUST have distinct identifiers so they can coexist
504        // on the same machine.
505        assert_ne!(
506            ServiceKind::Daemon.label(),
507            ServiceKind::LocalRelay.label()
508        );
509        assert_ne!(
510            ServiceKind::Daemon.systemd_unit_name(),
511            ServiceKind::LocalRelay.systemd_unit_name()
512        );
513        assert_ne!(
514            ServiceKind::Daemon.log_basename(),
515            ServiceKind::LocalRelay.log_basename()
516        );
517    }
518}