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