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