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
13//!   --all-sessions --interval 5` (v0.14.2+). Supervisor process forks
14//!   one child daemon per initialized session so every session syncs at
15//!   login, not just whichever one launchd's cwd happens to resolve.
16//!   ONE per identity-mesh. Label: `sh.slancha.wire.daemon`.
17//!
18//! - **LocalRelay** (`wire service install --local-relay`) — runs
19//!   `wire relay-server --bind 127.0.0.1:8771 --local-only`. The
20//!   loopback transport for sister-agents on the same box (v0.5.17
21//!   dual-slot). ONE per machine. Label: `sh.slancha.wire.local-relay`.
22//!
23//! ## Unit paths
24//!
25//! - macOS: `~/Library/LaunchAgents/<label>.plist`
26//! - linux: `~/.config/systemd/user/wire-<kind>.service`
27//!
28//! Units auto-start on login + restart on crash. Pair with
29//! `wire upgrade` (P0.5) for atomic version swaps without unit churn.
30
31use std::path::PathBuf;
32use std::process::Command;
33
34use anyhow::{Context, Result, anyhow, bail};
35
36/// Which wire service is being managed. Each kind has its own launchd
37/// label / systemd unit name / log path so the two kinds can coexist
38/// on the same machine without colliding.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ServiceKind {
41    /// `wire daemon --all-sessions --interval 5`. One per
42    /// identity-mesh. The default.
43    Daemon,
44    /// `wire relay-server --bind 127.0.0.1:8771 --local-only`. One
45    /// per machine — provides the loopback transport that sister
46    /// agents' sessions route through (v0.5.17 dual-slot).
47    LocalRelay,
48}
49
50impl ServiceKind {
51    /// launchd Label / systemd unit base name (without `.service`).
52    fn label(self) -> &'static str {
53        match self {
54            ServiceKind::Daemon => "sh.slancha.wire.daemon",
55            ServiceKind::LocalRelay => "sh.slancha.wire.local-relay",
56        }
57    }
58
59    /// systemd unit filename (`wire-daemon.service` etc.).
60    fn systemd_unit_name(self) -> &'static str {
61        match self {
62            ServiceKind::Daemon => "wire-daemon.service",
63            ServiceKind::LocalRelay => "wire-local-relay.service",
64        }
65    }
66
67    /// Human-readable name for `Description=` / log messages.
68    fn description(self) -> &'static str {
69        match self {
70            ServiceKind::Daemon => "wire — daemon (push/pull sync)",
71            ServiceKind::LocalRelay => "wire — local-only relay (127.0.0.1:8771)",
72        }
73    }
74
75    /// Arguments to pass to the `wire` binary in the ProgramArguments
76    /// / ExecStart line. The first element of the wider arg vector is
77    /// the binary itself, supplied separately by callers.
78    ///
79    /// v0.14.2 (#162): `Daemon` uses `--all-sessions` so the OS-managed
80    /// daemon covers every initialized session, not just the
81    /// "default" one whatever the launchd / systemd / Task Scheduler
82    /// cwd happens to resolve. Closes honey-pine's launchd-vs-session
83    /// isolation gap. Operators upgrading from a pre-0.14.2 install
84    /// must re-run `wire service install` (or `wire upgrade
85    /// --restart-service`) to pick up the new ProgramArguments line.
86    fn binary_args(self) -> &'static [&'static str] {
87        match self {
88            ServiceKind::Daemon => &["daemon", "--all-sessions", "--interval", "5"],
89            ServiceKind::LocalRelay => {
90                &["relay-server", "--bind", "127.0.0.1:8771", "--local-only"]
91            }
92        }
93    }
94
95    /// Windows Task Scheduler task name. v0.7.2: parity with launchd
96    /// labels + systemd unit names. Must be filesystem-safe and stable
97    /// across versions so install / uninstall / status all key on the
98    /// same string. `schtasks /TN` uses backslash as a folder
99    /// separator, so the names are kept flat (no `\wire\daemon`-style
100    /// nesting).
101    fn windows_task_name(self) -> &'static str {
102        match self {
103            ServiceKind::Daemon => "wire-daemon",
104            ServiceKind::LocalRelay => "wire-local-relay",
105        }
106    }
107
108    /// Per-kind log file basename. macOS-only — launchd's
109    /// `StandardOutPath` directive redirects daemon stdout/stderr to a
110    /// real file under `~/Library/Logs/`. On Linux the systemd unit
111    /// has no equivalent file redirect (it logs to journald instead,
112    /// which is the idiomatic Linux pattern; `journalctl --user -u
113    /// <unit>` reads it). v0.5.23: stopped reporting a log-file path
114    /// to Linux operators since no file was ever written there —
115    /// previously the install detail message named a phantom location
116    /// in `~/.cache/wire/` that confused anyone who went looking for
117    /// the actual log.
118    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
119    fn log_basename(self) -> &'static str {
120        match self {
121            ServiceKind::Daemon => "wire-daemon.log",
122            ServiceKind::LocalRelay => "wire-local-relay.log",
123        }
124    }
125}
126
127/// Outcome of `wire service install` etc., suitable for both human + JSON
128/// rendering.
129#[derive(Debug, Clone, serde::Serialize)]
130pub struct ServiceReport {
131    pub action: String,
132    pub platform: String,
133    pub unit_path: String,
134    pub status: String,
135    pub detail: String,
136    /// v0.5.22: which service kind this report is about ("daemon" or
137    /// "local-relay"). Lets JSON consumers distinguish multiple reports.
138    #[serde(default)]
139    pub kind: String,
140}
141
142/// Back-compat shim — `wire service install` with no flags installs
143/// the daemon, matching pre-v0.5.22 behavior.
144pub fn install() -> Result<ServiceReport> {
145    install_kind(ServiceKind::Daemon)
146}
147pub fn uninstall() -> Result<ServiceReport> {
148    uninstall_kind(ServiceKind::Daemon)
149}
150pub fn status() -> Result<ServiceReport> {
151    status_kind(ServiceKind::Daemon)
152}
153
154/// Install a user-scope service unit for the given kind.
155pub fn install_kind(kind: ServiceKind) -> Result<ServiceReport> {
156    // Robust to a `cargo install` in-place replace mid-`wire upgrade`: the
157    // kernel marks `/proc/self/exe` with a trailing ` (deleted)` that, written
158    // verbatim into ExecStart=, corrupts the unit (issue #274).
159    let exe = crate::platform::current_exe_resolved()?;
160    let exe_str = exe.to_string_lossy().to_string();
161
162    // v0.5.23: log path is macOS-only — launchd's StandardOutPath
163    // directive redirects to a file; systemd defaults to journald
164    // and we don't add an explicit file-redirect directive (let
165    // operators use `journalctl --user -u <unit>` which is the
166    // idiomatic Linux read path).
167    let log_str = if cfg!(target_os = "macos") {
168        ensure_macos_log_path(kind)?.to_string_lossy().to_string()
169    } else {
170        String::new()
171    };
172
173    if cfg!(target_os = "macos") {
174        let plist_path = launchd_plist_path(kind)?;
175        if let Some(parent) = plist_path.parent() {
176            std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
177        }
178        let plist = launchd_plist_xml(kind, &exe_str, &log_str);
179        std::fs::write(&plist_path, plist).with_context(|| format!("writing {plist_path:?}"))?;
180
181        // launchctl bootstrap is idempotent if we bootout first.
182        let _ = Command::new("launchctl")
183            .args(["bootout", &launchctl_target_for(kind)])
184            .status();
185        let load = Command::new("launchctl")
186            .args([
187                "bootstrap",
188                &launchctl_user_target(),
189                plist_path.to_str().unwrap_or(""),
190            ])
191            .status();
192        let loaded = load.map(|s| s.success()).unwrap_or(false);
193
194        return Ok(ServiceReport {
195            action: "install".into(),
196            platform: "macos-launchd".into(),
197            unit_path: plist_path.to_string_lossy().to_string(),
198            status: if loaded {
199                "loaded".into()
200            } else {
201                "written".into()
202            },
203            detail: if loaded {
204                format!("plist written + bootstrapped; logs at {log_str}")
205            } else {
206                format!(
207                    "plist written; `launchctl bootstrap` failed — try `launchctl bootstrap {} {}` manually",
208                    launchctl_user_target(),
209                    plist_path.display()
210                )
211            },
212            kind: kind_label(kind).into(),
213        });
214    }
215    if cfg!(target_os = "linux") {
216        let unit_path = systemd_unit_path(kind)?;
217        if let Some(parent) = unit_path.parent() {
218            std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
219        }
220        let unit = systemd_unit_text(kind, &exe_str);
221        std::fs::write(&unit_path, unit).with_context(|| format!("writing {unit_path:?}"))?;
222
223        // Reload + enable + start. Each is idempotent on linux.
224        let _ = Command::new("systemctl")
225            .args(["--user", "daemon-reload"])
226            .status();
227        let enabled = Command::new("systemctl")
228            .args(["--user", "enable", "--now", kind.systemd_unit_name()])
229            .status()
230            .map(|s| s.success())
231            .unwrap_or(false);
232
233        // v0.5.23: surface the "user-scope unit only starts after first
234        // login" footgun. systemd user units require `loginctl enable-
235        // linger <user>` to start at boot without a console login
236        // session. Operators logging in via SSH frequently miss this
237        // and discover the service is "down at boot" only later.
238        // Check the current state and only nag if linger is OFF.
239        let linger_note = if enabled && !linger_enabled() {
240            let user = std::env::var("USER").unwrap_or_else(|_| "$USER".into());
241            format!(
242                " NOTE: linger is OFF — service starts at *first login*, \
243                 not at boot. For boot-time start (e.g. headless SSH boxes), \
244                 run `sudo loginctl enable-linger {user}` once."
245            )
246        } else {
247            String::new()
248        };
249
250        return Ok(ServiceReport {
251            action: "install".into(),
252            platform: "linux-systemd-user".into(),
253            unit_path: unit_path.to_string_lossy().to_string(),
254            status: if enabled {
255                "enabled".into()
256            } else {
257                "written".into()
258            },
259            detail: if enabled {
260                format!(
261                    "unit written + enable --now succeeded; logs via \
262                     `journalctl --user -u {}`{linger_note}",
263                    kind.systemd_unit_name()
264                )
265            } else {
266                format!(
267                    "unit written; `systemctl --user enable --now {}` failed — try manually",
268                    kind.systemd_unit_name()
269                )
270            },
271            kind: kind_label(kind).into(),
272        });
273    }
274    if cfg!(target_os = "windows") {
275        let task_name = kind.windows_task_name();
276        let xml = windows_task_xml(kind, &exe_str);
277        // schtasks /Create /XML reads the file at the given path. UTF-8
278        // without BOM is accepted on Win10+; older builds expected
279        // UTF-16LE-BOM. We write UTF-8 — if a user hits a parse error
280        // on an old Windows, the fix is to re-encode the file (or use
281        // /Create with CLI flags), not a code change.
282        let xml_path = std::env::temp_dir().join(format!("{task_name}.xml"));
283        std::fs::write(&xml_path, xml).with_context(|| format!("writing {xml_path:?}"))?;
284        // /F = force-overwrite any prior registration (idempotent).
285        let create = Command::new("schtasks.exe")
286            .args([
287                "/Create",
288                "/TN",
289                task_name,
290                "/XML",
291                xml_path.to_str().unwrap_or(""),
292                "/F",
293            ])
294            .status();
295        let registered = create.map(|s| s.success()).unwrap_or(false);
296        // Run it now so the operator doesn't have to log out + back in.
297        if registered {
298            let _ = Command::new("schtasks.exe")
299                .args(["/Run", "/TN", task_name])
300                .status();
301        }
302        return Ok(ServiceReport {
303            action: "install".into(),
304            platform: "windows-schtasks".into(),
305            unit_path: xml_path.to_string_lossy().to_string(),
306            status: if registered {
307                "registered".into()
308            } else {
309                "written".into()
310            },
311            detail: if registered {
312                format!(
313                    "task `{task_name}` registered + started; will auto-start at logon. \
314                     Check with `schtasks /Query /TN {task_name}` or open Task Scheduler."
315                )
316            } else {
317                format!(
318                    "task XML written to {} but `schtasks /Create` failed — try manually: \
319                     schtasks /Create /TN {task_name} /XML \"{}\" /F",
320                    xml_path.display(),
321                    xml_path.display()
322                )
323            },
324            kind: kind_label(kind).into(),
325        });
326    }
327    bail!("wire service install: unsupported platform")
328}
329
330pub fn uninstall_kind(kind: ServiceKind) -> Result<ServiceReport> {
331    if cfg!(target_os = "macos") {
332        let plist_path = launchd_plist_path(kind)?;
333        let _ = Command::new("launchctl")
334            .args(["bootout", &launchctl_target_for(kind)])
335            .status();
336        let removed = if plist_path.exists() {
337            std::fs::remove_file(&plist_path).ok();
338            true
339        } else {
340            false
341        };
342        return Ok(ServiceReport {
343            action: "uninstall".into(),
344            platform: "macos-launchd".into(),
345            unit_path: plist_path.to_string_lossy().to_string(),
346            status: if removed {
347                "removed".into()
348            } else {
349                "absent".into()
350            },
351            detail: "launchctl bootout + plist file removed".into(),
352            kind: kind_label(kind).into(),
353        });
354    }
355    if cfg!(target_os = "linux") {
356        let unit_path = systemd_unit_path(kind)?;
357        let _ = Command::new("systemctl")
358            .args(["--user", "disable", "--now", kind.systemd_unit_name()])
359            .status();
360        let removed = if unit_path.exists() {
361            std::fs::remove_file(&unit_path).ok();
362            true
363        } else {
364            false
365        };
366        let _ = Command::new("systemctl")
367            .args(["--user", "daemon-reload"])
368            .status();
369        return Ok(ServiceReport {
370            action: "uninstall".into(),
371            platform: "linux-systemd-user".into(),
372            unit_path: unit_path.to_string_lossy().to_string(),
373            status: if removed {
374                "removed".into()
375            } else {
376                "absent".into()
377            },
378            detail: "systemctl disable --now + unit file removed".into(),
379            kind: kind_label(kind).into(),
380        });
381    }
382    if cfg!(target_os = "windows") {
383        let task_name = kind.windows_task_name();
384        let delete = Command::new("schtasks.exe")
385            .args(["/Delete", "/TN", task_name, "/F"])
386            .status();
387        let removed = delete.map(|s| s.success()).unwrap_or(false);
388        return Ok(ServiceReport {
389            action: "uninstall".into(),
390            platform: "windows-schtasks".into(),
391            unit_path: String::new(),
392            status: if removed {
393                "removed".into()
394            } else {
395                "absent".into()
396            },
397            detail: format!(
398                "schtasks /Delete /TN {task_name} /F (removed={removed}); \
399                 if task was foreign or never registered, `absent` is the expected state"
400            ),
401            kind: kind_label(kind).into(),
402        });
403    }
404    bail!("wire service uninstall: unsupported platform")
405}
406
407pub fn status_kind(kind: ServiceKind) -> Result<ServiceReport> {
408    if cfg!(target_os = "macos") {
409        let plist_path = launchd_plist_path(kind)?;
410        let exists = plist_path.exists();
411        let listed = Command::new("launchctl")
412            .args(["list", kind.label()])
413            .output()
414            .map(|o| o.status.success())
415            .unwrap_or(false);
416        return Ok(ServiceReport {
417            action: "status".into(),
418            platform: "macos-launchd".into(),
419            unit_path: plist_path.to_string_lossy().to_string(),
420            status: if listed {
421                "loaded".into()
422            } else if exists {
423                "installed (not loaded)".into()
424            } else {
425                "absent".into()
426            },
427            detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
428            kind: kind_label(kind).into(),
429        });
430    }
431    if cfg!(target_os = "linux") {
432        let unit_path = systemd_unit_path(kind)?;
433        let exists = unit_path.exists();
434        let active = Command::new("systemctl")
435            .args(["--user", "is-active", kind.systemd_unit_name()])
436            .output()
437            .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
438            .unwrap_or(false);
439        return Ok(ServiceReport {
440            action: "status".into(),
441            platform: "linux-systemd-user".into(),
442            unit_path: unit_path.to_string_lossy().to_string(),
443            status: if active {
444                "active".into()
445            } else if exists {
446                "installed (inactive)".into()
447            } else {
448                "absent".into()
449            },
450            detail: format!("unit exists={exists}, is-active={active}"),
451            kind: kind_label(kind).into(),
452        });
453    }
454    if cfg!(target_os = "windows") {
455        let task_name = kind.windows_task_name();
456        // CSV output with no header gives a single row we can parse for
457        // the "Status" column (Ready / Running / Disabled). Missing task
458        // → schtasks exits non-zero, which we treat as `absent`.
459        let query = Command::new("schtasks.exe")
460            .args(["/Query", "/TN", task_name, "/FO", "CSV", "/NH"])
461            .output();
462        let (exists, raw) = match query {
463            Ok(o) if o.status.success() => (true, String::from_utf8_lossy(&o.stdout).into_owned()),
464            _ => (false, String::new()),
465        };
466        let running = raw.to_lowercase().contains("running");
467        return Ok(ServiceReport {
468            action: "status".into(),
469            platform: "windows-schtasks".into(),
470            unit_path: String::new(),
471            status: if running {
472                "running".into()
473            } else if exists {
474                "installed (idle)".into()
475            } else {
476                "absent".into()
477            },
478            detail: format!("schtasks /Query: exists={exists} running={running}"),
479            kind: kind_label(kind).into(),
480        });
481    }
482    bail!("wire service status: unsupported platform")
483}
484
485/// v0.5.23 (linux only): true iff `loginctl show-user --property=Linger`
486/// returns `Linger=yes`. Used to suppress the install-time linger nag
487/// when the operator has already enabled it. Best-effort: returns false
488/// on any error (missing `loginctl`, $USER unset, command failure) so
489/// the nag fires by default rather than silently going missing.
490#[cfg(target_os = "linux")]
491fn linger_enabled() -> bool {
492    let user = match std::env::var("USER") {
493        Ok(u) if !u.is_empty() => u,
494        _ => return false,
495    };
496    Command::new("loginctl")
497        .args(["show-user", &user, "--property=Linger"])
498        .output()
499        .ok()
500        .and_then(|o| {
501            if o.status.success() {
502                Some(String::from_utf8_lossy(&o.stdout).into_owned())
503            } else {
504                None
505            }
506        })
507        .map(|s| s.trim().eq_ignore_ascii_case("Linger=yes"))
508        .unwrap_or(false)
509}
510
511#[cfg(not(target_os = "linux"))]
512fn linger_enabled() -> bool {
513    // Non-linux platforms don't have systemd's linger concept.
514    // Compiled but never called from the macOS / Windows / BSD
515    // branches; provided so cross-target unit tests compile.
516    false
517}
518
519fn kind_label(kind: ServiceKind) -> &'static str {
520    match kind {
521        ServiceKind::Daemon => "daemon",
522        ServiceKind::LocalRelay => "local-relay",
523    }
524}
525
526fn launchd_plist_path(kind: ServiceKind) -> Result<PathBuf> {
527    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
528    Ok(PathBuf::from(home)
529        .join("Library")
530        .join("LaunchAgents")
531        .join(format!("{}.plist", kind.label())))
532}
533
534fn launchctl_user_target() -> String {
535    let uid = Command::new("id")
536        .args(["-u"])
537        .output()
538        .ok()
539        .and_then(|o| {
540            if o.status.success() {
541                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
542            } else {
543                None
544            }
545        })
546        .unwrap_or_else(|| "0".to_string());
547    format!("gui/{uid}")
548}
549
550fn launchctl_target_for(kind: ServiceKind) -> String {
551    format!("{}/{}", launchctl_user_target(), kind.label())
552}
553
554/// Resolve the macOS log destination for a service kind and ensure
555/// the parent directory exists. Returns the absolute path that
556/// launchd's `StandardOutPath` will redirect the service's stdout/
557/// stderr to (`~/Library/Logs/wire-<kind>.log`).
558///
559/// v0.5.23: macOS-only. The previous version had a Linux branch that
560/// computed a path nothing would ever write to, because the Linux
561/// systemd unit logs to journald rather than a file. Caused a
562/// confusing "logs at ~/.cache/wire/..." message on `wire service
563/// install` when no such file ever appeared.
564#[cfg(target_os = "macos")]
565fn ensure_macos_log_path(kind: ServiceKind) -> Result<PathBuf> {
566    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
567    let dir = PathBuf::from(&home).join("Library").join("Logs");
568    std::fs::create_dir_all(&dir).with_context(|| format!("creating log dir {dir:?}"))?;
569    Ok(dir.join(kind.log_basename()))
570}
571
572/// Stub for non-macOS targets so the macOS branch in `install_kind`
573/// type-checks under cross-platform builds. Never called in practice
574/// because the corresponding `cfg!(target_os = "macos")` guard skips
575/// it. Returns an empty path; if you ever see this in a non-macOS
576/// log message, it's a bug.
577#[cfg(not(target_os = "macos"))]
578fn ensure_macos_log_path(_kind: ServiceKind) -> Result<PathBuf> {
579    Ok(PathBuf::new())
580}
581
582fn launchd_plist_xml(kind: ServiceKind, exe: &str, log_path: &str) -> String {
583    let args_xml = kind
584        .binary_args()
585        .iter()
586        .map(|a| format!("        <string>{a}</string>"))
587        .collect::<Vec<_>>()
588        .join("\n");
589    let label = kind.label();
590    format!(
591        r#"<?xml version="1.0" encoding="UTF-8"?>
592<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
593<plist version="1.0">
594<dict>
595    <key>Label</key>
596    <string>{label}</string>
597    <key>ProgramArguments</key>
598    <array>
599        <string>{exe}</string>
600{args_xml}
601    </array>
602    <key>RunAtLoad</key>
603    <true/>
604    <key>KeepAlive</key>
605    <true/>
606    <key>ProcessType</key>
607    <string>Background</string>
608    <key>StandardOutPath</key>
609    <string>{log_path}</string>
610    <key>StandardErrorPath</key>
611    <string>{log_path}</string>
612</dict>
613</plist>
614"#
615    )
616}
617
618fn systemd_unit_path(kind: ServiceKind) -> Result<PathBuf> {
619    let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
620    Ok(PathBuf::from(home)
621        .join(".config")
622        .join("systemd")
623        .join("user")
624        .join(kind.systemd_unit_name()))
625}
626
627fn systemd_unit_text(kind: ServiceKind, exe: &str) -> String {
628    let args = kind.binary_args().join(" ");
629    let desc = kind.description();
630    format!(
631        r#"[Unit]
632Description={desc}
633After=network-online.target
634Wants=network-online.target
635
636[Service]
637Type=simple
638ExecStart={exe} {args}
639Restart=on-failure
640RestartSec=5
641
642[Install]
643WantedBy=default.target
644"#
645    )
646}
647
648/// v0.7.2: Windows Task Scheduler 1.2 schema XML for a wire service.
649/// Mirrors the launchd plist + systemd unit shape: run-at-logon,
650/// auto-restart on failure, hidden console, user-scope LeastPrivilege
651/// with InteractiveToken so we never prompt for a stored password.
652///
653/// The `<Arguments>` field is XML-escaped because args may include
654/// metacharacters like `&` in future flag values.
655///
656/// Returned as a String for `cfg!(test)` cross-target compilation; the
657/// caller writes it to disk via `std::fs::write` which handles encoding.
658fn windows_task_xml(kind: ServiceKind, exe: &str) -> String {
659    let desc = kind.description();
660    let args = kind.binary_args().join(" ");
661    // Escape XML special chars in fields that take operator-influenced
662    // strings. exe is `std::env::current_exe()` (trusted) but args may
663    // grow operator-passed values later.
664    let exe_xml = xml_escape(exe);
665    let args_xml = xml_escape(&args);
666    let desc_xml = xml_escape(desc);
667    format!(
668        r#"<?xml version="1.0" encoding="UTF-8"?>
669<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
670  <RegistrationInfo>
671    <Description>{desc_xml}</Description>
672    <Author>wire (slancha)</Author>
673  </RegistrationInfo>
674  <Triggers>
675    <LogonTrigger>
676      <Enabled>true</Enabled>
677    </LogonTrigger>
678  </Triggers>
679  <Principals>
680    <Principal id="Author">
681      <LogonType>InteractiveToken</LogonType>
682      <RunLevel>LeastPrivilege</RunLevel>
683    </Principal>
684  </Principals>
685  <Settings>
686    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
687    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
688    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
689    <AllowHardTerminate>true</AllowHardTerminate>
690    <StartWhenAvailable>true</StartWhenAvailable>
691    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
692    <IdleSettings>
693      <StopOnIdleEnd>false</StopOnIdleEnd>
694      <RestartOnIdle>false</RestartOnIdle>
695    </IdleSettings>
696    <AllowStartOnDemand>true</AllowStartOnDemand>
697    <Enabled>true</Enabled>
698    <Hidden>true</Hidden>
699    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
700    <Priority>7</Priority>
701    <RestartOnFailure>
702      <Interval>PT1M</Interval>
703      <Count>3</Count>
704    </RestartOnFailure>
705  </Settings>
706  <Actions Context="Author">
707    <Exec>
708      <Command>{exe_xml}</Command>
709      <Arguments>{args_xml}</Arguments>
710    </Exec>
711  </Actions>
712</Task>
713"#
714    )
715}
716
717fn xml_escape(s: &str) -> String {
718    s.replace('&', "&amp;")
719        .replace('<', "&lt;")
720        .replace('>', "&gt;")
721        .replace('"', "&quot;")
722        .replace('\'', "&apos;")
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    #[test]
730    fn launchd_plist_xml_for_daemon_contains_required_keys() {
731        let xml = launchd_plist_xml(
732            ServiceKind::Daemon,
733            "/usr/local/bin/wire",
734            "/tmp/wire-daemon.log",
735        );
736        assert!(xml.contains("<key>Label</key>"));
737        assert!(xml.contains(ServiceKind::Daemon.label()));
738        assert!(xml.contains("/usr/local/bin/wire"));
739        assert!(xml.contains("<string>daemon</string>"));
740        assert!(xml.contains("<string>--all-sessions</string>"));
741        assert!(xml.contains("<string>--interval</string>"));
742        assert!(xml.contains("<key>KeepAlive</key>"));
743        assert!(xml.contains("<key>RunAtLoad</key>"));
744        assert!(xml.contains("<true/>"));
745        // v0.5.22: log path is honored, not /dev/null.
746        assert!(xml.contains("/tmp/wire-daemon.log"));
747        assert!(!xml.contains("/dev/null"));
748    }
749
750    #[test]
751    fn launchd_plist_xml_for_local_relay_uses_correct_args() {
752        let xml = launchd_plist_xml(
753            ServiceKind::LocalRelay,
754            "/usr/local/bin/wire",
755            "/tmp/wire-local-relay.log",
756        );
757        assert!(xml.contains(ServiceKind::LocalRelay.label()));
758        assert!(xml.contains("<string>relay-server</string>"));
759        assert!(xml.contains("<string>--bind</string>"));
760        assert!(xml.contains("<string>127.0.0.1:8771</string>"));
761        assert!(xml.contains("<string>--local-only</string>"));
762        // Must NOT include daemon args.
763        assert!(!xml.contains("<string>daemon</string>"));
764    }
765
766    #[test]
767    fn systemd_unit_text_for_daemon_contains_required_directives() {
768        let unit = systemd_unit_text(ServiceKind::Daemon, "/usr/local/bin/wire");
769        assert!(unit.contains("[Unit]"));
770        assert!(unit.contains("[Service]"));
771        assert!(unit.contains("[Install]"));
772        assert!(unit.contains("/usr/local/bin/wire daemon --all-sessions --interval 5"));
773        assert!(unit.contains("Restart=on-failure"));
774        assert!(unit.contains("WantedBy=default.target"));
775    }
776
777    #[test]
778    fn systemd_unit_text_for_local_relay_uses_correct_exec() {
779        let unit = systemd_unit_text(ServiceKind::LocalRelay, "/usr/local/bin/wire");
780        assert!(
781            unit.contains("/usr/local/bin/wire relay-server --bind 127.0.0.1:8771 --local-only")
782        );
783        assert!(!unit.contains("daemon --interval"));
784    }
785
786    #[test]
787    fn label_and_unit_name_distinct_per_kind() {
788        // Both kinds MUST have distinct identifiers so they can coexist
789        // on the same machine.
790        assert_ne!(ServiceKind::Daemon.label(), ServiceKind::LocalRelay.label());
791        assert_ne!(
792            ServiceKind::Daemon.systemd_unit_name(),
793            ServiceKind::LocalRelay.systemd_unit_name()
794        );
795        assert_ne!(
796            ServiceKind::Daemon.log_basename(),
797            ServiceKind::LocalRelay.log_basename()
798        );
799        assert_ne!(
800            ServiceKind::Daemon.windows_task_name(),
801            ServiceKind::LocalRelay.windows_task_name()
802        );
803    }
804
805    #[test]
806    fn windows_task_xml_for_daemon_contains_required_elements_v0_7_2() {
807        let xml = windows_task_xml(ServiceKind::Daemon, r"C:\Program Files\wire\wire.exe");
808        // Schema declaration + 1.2 task version (Win 7+ / matches what
809        // schtasks /XML expects).
810        assert!(xml.contains(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
811        assert!(xml.contains(r#"<Task version="1.2""#));
812        // Logon-trigger pattern — service starts when the user logs in,
813        // mirroring systemd --user / launchd-user-domain semantics.
814        assert!(xml.contains("<LogonTrigger>"));
815        // User-scope, not elevated. Critical: matches launchd's
816        // gui/<uid> domain and systemd's --user mode.
817        assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"));
818        assert!(xml.contains("<LogonType>InteractiveToken</LogonType>"));
819        // Hidden console — no flashing cmd window at logon.
820        assert!(xml.contains("<Hidden>true</Hidden>"));
821        // Restart-on-failure parity with `Restart=on-failure` (systemd)
822        // and `KeepAlive` (launchd).
823        assert!(xml.contains("<RestartOnFailure>"));
824        // Battery + network policies relaxed: a laptop unplugging
825        // shouldn't kill the daemon.
826        assert!(xml.contains("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"));
827        // Actual exec line uses XML-escaped exe path + correct daemon
828        // args.
829        assert!(xml.contains(r"C:\Program Files\wire\wire.exe"));
830        assert!(xml.contains("<Arguments>daemon --all-sessions --interval 5</Arguments>"));
831    }
832
833    #[test]
834    fn windows_task_xml_for_local_relay_uses_correct_args_v0_7_2() {
835        let xml = windows_task_xml(ServiceKind::LocalRelay, r"C:\wire\wire.exe");
836        assert!(xml.contains(r"C:\wire\wire.exe"));
837        assert!(
838            xml.contains("<Arguments>relay-server --bind 127.0.0.1:8771 --local-only</Arguments>")
839        );
840        // Must NOT include daemon args.
841        assert!(!xml.contains("daemon --interval"));
842    }
843
844    #[test]
845    fn xml_escape_handles_xml_metacharacters_v0_7_2() {
846        // Defensive — exe paths today are ASCII Program-Files paths but
847        // future operator-passed args may include `&` or quotes.
848        assert_eq!(xml_escape("a & b"), "a &amp; b");
849        assert_eq!(xml_escape("<tag>"), "&lt;tag&gt;");
850        assert_eq!(xml_escape(r#"say "hi""#), "say &quot;hi&quot;");
851        assert_eq!(xml_escape("it's"), "it&apos;s");
852    }
853}