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. macOS-only — launchd's
88    /// `StandardOutPath` directive redirects daemon stdout/stderr to a
89    /// real file under `~/Library/Logs/`. On Linux the systemd unit
90    /// has no equivalent file redirect (it logs to journald instead,
91    /// which is the idiomatic Linux pattern; `journalctl --user -u
92    /// <unit>` reads it). v0.5.23: stopped reporting a log-file path
93    /// to Linux operators since no file was ever written there —
94    /// previously the install detail message named a phantom location
95    /// in `~/.cache/wire/` that confused anyone who went looking for
96    /// the actual log.
97    fn log_basename(self) -> &'static str {
98        match self {
99            ServiceKind::Daemon => "wire-daemon.log",
100            ServiceKind::LocalRelay => "wire-local-relay.log",
101        }
102    }
103}
104
105/// Outcome of `wire service install` etc., suitable for both human + JSON
106/// rendering.
107#[derive(Debug, Clone, serde::Serialize)]
108pub struct ServiceReport {
109    pub action: String,
110    pub platform: String,
111    pub unit_path: String,
112    pub status: String,
113    pub detail: String,
114    /// v0.5.22: which service kind this report is about ("daemon" or
115    /// "local-relay"). Lets JSON consumers distinguish multiple reports.
116    #[serde(default)]
117    pub kind: String,
118}
119
120/// Back-compat shim — `wire service install` with no flags installs
121/// the daemon, matching pre-v0.5.22 behavior.
122pub fn install() -> Result<ServiceReport> {
123    install_kind(ServiceKind::Daemon)
124}
125pub fn uninstall() -> Result<ServiceReport> {
126    uninstall_kind(ServiceKind::Daemon)
127}
128pub fn status() -> Result<ServiceReport> {
129    status_kind(ServiceKind::Daemon)
130}
131
132/// Install a user-scope service unit for the given kind.
133pub fn install_kind(kind: ServiceKind) -> Result<ServiceReport> {
134    let exe = std::env::current_exe()?;
135    let exe_str = exe.to_string_lossy().to_string();
136
137    // v0.5.23: log path is macOS-only — launchd's StandardOutPath
138    // directive redirects to a file; systemd defaults to journald
139    // and we don't add an explicit file-redirect directive (let
140    // operators use `journalctl --user -u <unit>` which is the
141    // idiomatic Linux read path).
142    let log_str = if cfg!(target_os = "macos") {
143        ensure_macos_log_path(kind)?.to_string_lossy().to_string()
144    } else {
145        String::new()
146    };
147
148    if cfg!(target_os = "macos") {
149        let plist_path = launchd_plist_path(kind)?;
150        if let Some(parent) = plist_path.parent() {
151            std::fs::create_dir_all(parent)
152                .with_context(|| format!("creating {parent:?}"))?;
153        }
154        let plist = launchd_plist_xml(kind, &exe_str, &log_str);
155        std::fs::write(&plist_path, plist)
156            .with_context(|| format!("writing {plist_path:?}"))?;
157
158        // launchctl bootstrap is idempotent if we bootout first.
159        let _ = Command::new("launchctl")
160            .args(["bootout", &launchctl_target_for(kind)])
161            .status();
162        let load = Command::new("launchctl")
163            .args([
164                "bootstrap",
165                &launchctl_user_target(),
166                plist_path.to_str().unwrap_or(""),
167            ])
168            .status();
169        let loaded = load.map(|s| s.success()).unwrap_or(false);
170
171        return Ok(ServiceReport {
172            action: "install".into(),
173            platform: "macos-launchd".into(),
174            unit_path: plist_path.to_string_lossy().to_string(),
175            status: if loaded { "loaded".into() } else { "written".into() },
176            detail: if loaded {
177                format!(
178                    "plist written + bootstrapped; logs at {log_str}"
179                )
180            } else {
181                format!(
182                    "plist written; `launchctl bootstrap` failed — try `launchctl bootstrap {} {}` manually",
183                    launchctl_user_target(),
184                    plist_path.display()
185                )
186            },
187            kind: kind_label(kind).into(),
188        });
189    }
190    if cfg!(target_os = "linux") {
191        let unit_path = systemd_unit_path(kind)?;
192        if let Some(parent) = unit_path.parent() {
193            std::fs::create_dir_all(parent)
194                .with_context(|| format!("creating {parent:?}"))?;
195        }
196        let unit = systemd_unit_text(kind, &exe_str);
197        std::fs::write(&unit_path, unit)
198            .with_context(|| format!("writing {unit_path:?}"))?;
199
200        // Reload + enable + start. Each is idempotent on linux.
201        let _ = Command::new("systemctl")
202            .args(["--user", "daemon-reload"])
203            .status();
204        let enabled = Command::new("systemctl")
205            .args(["--user", "enable", "--now", kind.systemd_unit_name()])
206            .status()
207            .map(|s| s.success())
208            .unwrap_or(false);
209
210        // v0.5.23: surface the "user-scope unit only starts after first
211        // login" footgun. systemd user units require `loginctl enable-
212        // linger <user>` to start at boot without a console login
213        // session. Operators logging in via SSH frequently miss this
214        // and discover the service is "down at boot" only later.
215        // Check the current state and only nag if linger is OFF.
216        let linger_note = if enabled && !linger_enabled() {
217            let user = std::env::var("USER").unwrap_or_else(|_| "$USER".into());
218            format!(
219                " NOTE: linger is OFF — service starts at *first login*, \
220                 not at boot. For boot-time start (e.g. headless SSH boxes), \
221                 run `sudo loginctl enable-linger {user}` once."
222            )
223        } else {
224            String::new()
225        };
226
227        return Ok(ServiceReport {
228            action: "install".into(),
229            platform: "linux-systemd-user".into(),
230            unit_path: unit_path.to_string_lossy().to_string(),
231            status: if enabled { "enabled".into() } else { "written".into() },
232            detail: if enabled {
233                format!(
234                    "unit written + enable --now succeeded; logs via \
235                     `journalctl --user -u {}`{linger_note}",
236                    kind.systemd_unit_name()
237                )
238            } else {
239                format!(
240                    "unit written; `systemctl --user enable --now {}` failed — try manually",
241                    kind.systemd_unit_name()
242                )
243            },
244            kind: kind_label(kind).into(),
245        });
246    }
247    bail!("wire service install: unsupported platform")
248}
249
250pub fn uninstall_kind(kind: ServiceKind) -> Result<ServiceReport> {
251    if cfg!(target_os = "macos") {
252        let plist_path = launchd_plist_path(kind)?;
253        let _ = Command::new("launchctl")
254            .args(["bootout", &launchctl_target_for(kind)])
255            .status();
256        let removed = if plist_path.exists() {
257            std::fs::remove_file(&plist_path).ok();
258            true
259        } else {
260            false
261        };
262        return Ok(ServiceReport {
263            action: "uninstall".into(),
264            platform: "macos-launchd".into(),
265            unit_path: plist_path.to_string_lossy().to_string(),
266            status: if removed { "removed".into() } else { "absent".into() },
267            detail: "launchctl bootout + plist file removed".into(),
268            kind: kind_label(kind).into(),
269        });
270    }
271    if cfg!(target_os = "linux") {
272        let unit_path = systemd_unit_path(kind)?;
273        let _ = Command::new("systemctl")
274            .args(["--user", "disable", "--now", kind.systemd_unit_name()])
275            .status();
276        let removed = if unit_path.exists() {
277            std::fs::remove_file(&unit_path).ok();
278            true
279        } else {
280            false
281        };
282        let _ = Command::new("systemctl")
283            .args(["--user", "daemon-reload"])
284            .status();
285        return Ok(ServiceReport {
286            action: "uninstall".into(),
287            platform: "linux-systemd-user".into(),
288            unit_path: unit_path.to_string_lossy().to_string(),
289            status: if removed { "removed".into() } else { "absent".into() },
290            detail: "systemctl disable --now + unit file removed".into(),
291            kind: kind_label(kind).into(),
292        });
293    }
294    bail!("wire service uninstall: unsupported platform")
295}
296
297pub fn status_kind(kind: ServiceKind) -> Result<ServiceReport> {
298    if cfg!(target_os = "macos") {
299        let plist_path = launchd_plist_path(kind)?;
300        let exists = plist_path.exists();
301        let listed = Command::new("launchctl")
302            .args(["list", kind.label()])
303            .output()
304            .map(|o| o.status.success())
305            .unwrap_or(false);
306        return Ok(ServiceReport {
307            action: "status".into(),
308            platform: "macos-launchd".into(),
309            unit_path: plist_path.to_string_lossy().to_string(),
310            status: if listed {
311                "loaded".into()
312            } else if exists {
313                "installed (not loaded)".into()
314            } else {
315                "absent".into()
316            },
317            detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
318            kind: kind_label(kind).into(),
319        });
320    }
321    if cfg!(target_os = "linux") {
322        let unit_path = systemd_unit_path(kind)?;
323        let exists = unit_path.exists();
324        let active = Command::new("systemctl")
325            .args(["--user", "is-active", kind.systemd_unit_name()])
326            .output()
327            .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
328            .unwrap_or(false);
329        return Ok(ServiceReport {
330            action: "status".into(),
331            platform: "linux-systemd-user".into(),
332            unit_path: unit_path.to_string_lossy().to_string(),
333            status: if active {
334                "active".into()
335            } else if exists {
336                "installed (inactive)".into()
337            } else {
338                "absent".into()
339            },
340            detail: format!("unit exists={exists}, is-active={active}"),
341            kind: kind_label(kind).into(),
342        });
343    }
344    bail!("wire service status: unsupported platform")
345}
346
347/// v0.5.23 (linux only): true iff `loginctl show-user --property=Linger`
348/// returns `Linger=yes`. Used to suppress the install-time linger nag
349/// when the operator has already enabled it. Best-effort: returns false
350/// on any error (missing `loginctl`, $USER unset, command failure) so
351/// the nag fires by default rather than silently going missing.
352#[cfg(target_os = "linux")]
353fn linger_enabled() -> bool {
354    let user = match std::env::var("USER") {
355        Ok(u) if !u.is_empty() => u,
356        _ => return false,
357    };
358    Command::new("loginctl")
359        .args(["show-user", &user, "--property=Linger"])
360        .output()
361        .ok()
362        .and_then(|o| {
363            if o.status.success() {
364                Some(String::from_utf8_lossy(&o.stdout).into_owned())
365            } else {
366                None
367            }
368        })
369        .map(|s| s.trim().eq_ignore_ascii_case("Linger=yes"))
370        .unwrap_or(false)
371}
372
373#[cfg(not(target_os = "linux"))]
374fn linger_enabled() -> bool {
375    // Non-linux platforms don't have systemd's linger concept.
376    // Compiled but never called from the macOS / Windows / BSD
377    // branches; provided so cross-target unit tests compile.
378    false
379}
380
381fn kind_label(kind: ServiceKind) -> &'static str {
382    match kind {
383        ServiceKind::Daemon => "daemon",
384        ServiceKind::LocalRelay => "local-relay",
385    }
386}
387
388fn launchd_plist_path(kind: ServiceKind) -> Result<PathBuf> {
389    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
390    Ok(PathBuf::from(home)
391        .join("Library")
392        .join("LaunchAgents")
393        .join(format!("{}.plist", kind.label())))
394}
395
396fn launchctl_user_target() -> String {
397    let uid = Command::new("id")
398        .args(["-u"])
399        .output()
400        .ok()
401        .and_then(|o| {
402            if o.status.success() {
403                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
404            } else {
405                None
406            }
407        })
408        .unwrap_or_else(|| "0".to_string());
409    format!("gui/{uid}")
410}
411
412fn launchctl_target_for(kind: ServiceKind) -> String {
413    format!("{}/{}", launchctl_user_target(), kind.label())
414}
415
416/// Resolve the macOS log destination for a service kind and ensure
417/// the parent directory exists. Returns the absolute path that
418/// launchd's `StandardOutPath` will redirect the service's stdout/
419/// stderr to (`~/Library/Logs/wire-<kind>.log`).
420///
421/// v0.5.23: macOS-only. The previous version had a Linux branch that
422/// computed a path nothing would ever write to, because the Linux
423/// systemd unit logs to journald rather than a file. Caused a
424/// confusing "logs at ~/.cache/wire/..." message on `wire service
425/// install` when no such file ever appeared.
426#[cfg(target_os = "macos")]
427fn ensure_macos_log_path(kind: ServiceKind) -> Result<PathBuf> {
428    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
429    let dir = PathBuf::from(&home).join("Library").join("Logs");
430    std::fs::create_dir_all(&dir).with_context(|| format!("creating log dir {dir:?}"))?;
431    Ok(dir.join(kind.log_basename()))
432}
433
434/// Stub for non-macOS targets so the macOS branch in `install_kind`
435/// type-checks under cross-platform builds. Never called in practice
436/// because the corresponding `cfg!(target_os = "macos")` guard skips
437/// it. Returns an empty path; if you ever see this in a non-macOS
438/// log message, it's a bug.
439#[cfg(not(target_os = "macos"))]
440fn ensure_macos_log_path(_kind: ServiceKind) -> Result<PathBuf> {
441    Ok(PathBuf::new())
442}
443
444fn launchd_plist_xml(kind: ServiceKind, exe: &str, log_path: &str) -> String {
445    let args_xml = kind
446        .binary_args()
447        .iter()
448        .map(|a| format!("        <string>{a}</string>"))
449        .collect::<Vec<_>>()
450        .join("\n");
451    let label = kind.label();
452    format!(
453        r#"<?xml version="1.0" encoding="UTF-8"?>
454<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
455<plist version="1.0">
456<dict>
457    <key>Label</key>
458    <string>{label}</string>
459    <key>ProgramArguments</key>
460    <array>
461        <string>{exe}</string>
462{args_xml}
463    </array>
464    <key>RunAtLoad</key>
465    <true/>
466    <key>KeepAlive</key>
467    <true/>
468    <key>ProcessType</key>
469    <string>Background</string>
470    <key>StandardOutPath</key>
471    <string>{log_path}</string>
472    <key>StandardErrorPath</key>
473    <string>{log_path}</string>
474</dict>
475</plist>
476"#
477    )
478}
479
480fn systemd_unit_path(kind: ServiceKind) -> Result<PathBuf> {
481    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
482    Ok(PathBuf::from(home)
483        .join(".config")
484        .join("systemd")
485        .join("user")
486        .join(kind.systemd_unit_name()))
487}
488
489fn systemd_unit_text(kind: ServiceKind, exe: &str) -> String {
490    let args = kind.binary_args().join(" ");
491    let desc = kind.description();
492    format!(
493        r#"[Unit]
494Description={desc}
495After=network-online.target
496Wants=network-online.target
497
498[Service]
499Type=simple
500ExecStart={exe} {args}
501Restart=on-failure
502RestartSec=5
503
504[Install]
505WantedBy=default.target
506"#
507    )
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn launchd_plist_xml_for_daemon_contains_required_keys() {
516        let xml = launchd_plist_xml(
517            ServiceKind::Daemon,
518            "/usr/local/bin/wire",
519            "/tmp/wire-daemon.log",
520        );
521        assert!(xml.contains("<key>Label</key>"));
522        assert!(xml.contains(ServiceKind::Daemon.label()));
523        assert!(xml.contains("/usr/local/bin/wire"));
524        assert!(xml.contains("<string>daemon</string>"));
525        assert!(xml.contains("<string>--interval</string>"));
526        assert!(xml.contains("<key>KeepAlive</key>"));
527        assert!(xml.contains("<key>RunAtLoad</key>"));
528        assert!(xml.contains("<true/>"));
529        // v0.5.22: log path is honored, not /dev/null.
530        assert!(xml.contains("/tmp/wire-daemon.log"));
531        assert!(!xml.contains("/dev/null"));
532    }
533
534    #[test]
535    fn launchd_plist_xml_for_local_relay_uses_correct_args() {
536        let xml = launchd_plist_xml(
537            ServiceKind::LocalRelay,
538            "/usr/local/bin/wire",
539            "/tmp/wire-local-relay.log",
540        );
541        assert!(xml.contains(ServiceKind::LocalRelay.label()));
542        assert!(xml.contains("<string>relay-server</string>"));
543        assert!(xml.contains("<string>--bind</string>"));
544        assert!(xml.contains("<string>127.0.0.1:8771</string>"));
545        assert!(xml.contains("<string>--local-only</string>"));
546        // Must NOT include daemon args.
547        assert!(!xml.contains("<string>daemon</string>"));
548    }
549
550    #[test]
551    fn systemd_unit_text_for_daemon_contains_required_directives() {
552        let unit = systemd_unit_text(ServiceKind::Daemon, "/usr/local/bin/wire");
553        assert!(unit.contains("[Unit]"));
554        assert!(unit.contains("[Service]"));
555        assert!(unit.contains("[Install]"));
556        assert!(unit.contains("/usr/local/bin/wire daemon --interval 5"));
557        assert!(unit.contains("Restart=on-failure"));
558        assert!(unit.contains("WantedBy=default.target"));
559    }
560
561    #[test]
562    fn systemd_unit_text_for_local_relay_uses_correct_exec() {
563        let unit = systemd_unit_text(ServiceKind::LocalRelay, "/usr/local/bin/wire");
564        assert!(unit.contains(
565            "/usr/local/bin/wire relay-server --bind 127.0.0.1:8771 --local-only"
566        ));
567        assert!(!unit.contains("daemon --interval"));
568    }
569
570    #[test]
571    fn label_and_unit_name_distinct_per_kind() {
572        // Both kinds MUST have distinct identifiers so they can coexist
573        // on the same machine.
574        assert_ne!(
575            ServiceKind::Daemon.label(),
576            ServiceKind::LocalRelay.label()
577        );
578        assert_ne!(
579            ServiceKind::Daemon.systemd_unit_name(),
580            ServiceKind::LocalRelay.systemd_unit_name()
581        );
582        assert_ne!(
583            ServiceKind::Daemon.log_basename(),
584            ServiceKind::LocalRelay.log_basename()
585        );
586    }
587}