1use std::path::PathBuf;
30use std::process::Command;
31
32use anyhow::{Context, Result, anyhow, bail};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ServiceKind {
39 Daemon,
41 LocalRelay,
45}
46
47impl ServiceKind {
48 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 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 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 fn binary_args(self) -> &'static [&'static str] {
76 match self {
77 ServiceKind::Daemon => &["daemon", "--interval", "5"],
78 ServiceKind::LocalRelay => &[
79 "relay-server",
80 "--bind",
81 "127.0.0.1:8771",
82 "--local-only",
83 ],
84 }
85 }
86
87 fn log_basename(self) -> &'static str {
98 match self {
99 ServiceKind::Daemon => "wire-daemon.log",
100 ServiceKind::LocalRelay => "wire-local-relay.log",
101 }
102 }
103}
104
105#[derive(Debug, Clone, serde::Serialize)]
108pub struct ServiceReport {
109 pub action: String,
110 pub platform: String,
111 pub unit_path: String,
112 pub status: String,
113 pub detail: String,
114 #[serde(default)]
117 pub kind: String,
118}
119
120pub fn install() -> Result<ServiceReport> {
123 install_kind(ServiceKind::Daemon)
124}
125pub fn uninstall() -> Result<ServiceReport> {
126 uninstall_kind(ServiceKind::Daemon)
127}
128pub fn status() -> Result<ServiceReport> {
129 status_kind(ServiceKind::Daemon)
130}
131
132pub fn install_kind(kind: ServiceKind) -> Result<ServiceReport> {
134 let exe = std::env::current_exe()?;
135 let exe_str = exe.to_string_lossy().to_string();
136
137 let log_str = if cfg!(target_os = "macos") {
143 ensure_macos_log_path(kind)?.to_string_lossy().to_string()
144 } else {
145 String::new()
146 };
147
148 if cfg!(target_os = "macos") {
149 let plist_path = launchd_plist_path(kind)?;
150 if let Some(parent) = plist_path.parent() {
151 std::fs::create_dir_all(parent)
152 .with_context(|| format!("creating {parent:?}"))?;
153 }
154 let plist = launchd_plist_xml(kind, &exe_str, &log_str);
155 std::fs::write(&plist_path, plist)
156 .with_context(|| format!("writing {plist_path:?}"))?;
157
158 let _ = Command::new("launchctl")
160 .args(["bootout", &launchctl_target_for(kind)])
161 .status();
162 let load = Command::new("launchctl")
163 .args([
164 "bootstrap",
165 &launchctl_user_target(),
166 plist_path.to_str().unwrap_or(""),
167 ])
168 .status();
169 let loaded = load.map(|s| s.success()).unwrap_or(false);
170
171 return Ok(ServiceReport {
172 action: "install".into(),
173 platform: "macos-launchd".into(),
174 unit_path: plist_path.to_string_lossy().to_string(),
175 status: if loaded { "loaded".into() } else { "written".into() },
176 detail: if loaded {
177 format!(
178 "plist written + bootstrapped; logs at {log_str}"
179 )
180 } else {
181 format!(
182 "plist written; `launchctl bootstrap` failed — try `launchctl bootstrap {} {}` manually",
183 launchctl_user_target(),
184 plist_path.display()
185 )
186 },
187 kind: kind_label(kind).into(),
188 });
189 }
190 if cfg!(target_os = "linux") {
191 let unit_path = systemd_unit_path(kind)?;
192 if let Some(parent) = unit_path.parent() {
193 std::fs::create_dir_all(parent)
194 .with_context(|| format!("creating {parent:?}"))?;
195 }
196 let unit = systemd_unit_text(kind, &exe_str);
197 std::fs::write(&unit_path, unit)
198 .with_context(|| format!("writing {unit_path:?}"))?;
199
200 let _ = Command::new("systemctl")
202 .args(["--user", "daemon-reload"])
203 .status();
204 let enabled = Command::new("systemctl")
205 .args(["--user", "enable", "--now", kind.systemd_unit_name()])
206 .status()
207 .map(|s| s.success())
208 .unwrap_or(false);
209
210 let linger_note = if enabled && !linger_enabled() {
217 let user = std::env::var("USER").unwrap_or_else(|_| "$USER".into());
218 format!(
219 " NOTE: linger is OFF — service starts at *first login*, \
220 not at boot. For boot-time start (e.g. headless SSH boxes), \
221 run `sudo loginctl enable-linger {user}` once."
222 )
223 } else {
224 String::new()
225 };
226
227 return Ok(ServiceReport {
228 action: "install".into(),
229 platform: "linux-systemd-user".into(),
230 unit_path: unit_path.to_string_lossy().to_string(),
231 status: if enabled { "enabled".into() } else { "written".into() },
232 detail: if enabled {
233 format!(
234 "unit written + enable --now succeeded; logs via \
235 `journalctl --user -u {}`{linger_note}",
236 kind.systemd_unit_name()
237 )
238 } else {
239 format!(
240 "unit written; `systemctl --user enable --now {}` failed — try manually",
241 kind.systemd_unit_name()
242 )
243 },
244 kind: kind_label(kind).into(),
245 });
246 }
247 bail!("wire service install: unsupported platform")
248}
249
250pub fn uninstall_kind(kind: ServiceKind) -> Result<ServiceReport> {
251 if cfg!(target_os = "macos") {
252 let plist_path = launchd_plist_path(kind)?;
253 let _ = Command::new("launchctl")
254 .args(["bootout", &launchctl_target_for(kind)])
255 .status();
256 let removed = if plist_path.exists() {
257 std::fs::remove_file(&plist_path).ok();
258 true
259 } else {
260 false
261 };
262 return Ok(ServiceReport {
263 action: "uninstall".into(),
264 platform: "macos-launchd".into(),
265 unit_path: plist_path.to_string_lossy().to_string(),
266 status: if removed { "removed".into() } else { "absent".into() },
267 detail: "launchctl bootout + plist file removed".into(),
268 kind: kind_label(kind).into(),
269 });
270 }
271 if cfg!(target_os = "linux") {
272 let unit_path = systemd_unit_path(kind)?;
273 let _ = Command::new("systemctl")
274 .args(["--user", "disable", "--now", kind.systemd_unit_name()])
275 .status();
276 let removed = if unit_path.exists() {
277 std::fs::remove_file(&unit_path).ok();
278 true
279 } else {
280 false
281 };
282 let _ = Command::new("systemctl")
283 .args(["--user", "daemon-reload"])
284 .status();
285 return Ok(ServiceReport {
286 action: "uninstall".into(),
287 platform: "linux-systemd-user".into(),
288 unit_path: unit_path.to_string_lossy().to_string(),
289 status: if removed { "removed".into() } else { "absent".into() },
290 detail: "systemctl disable --now + unit file removed".into(),
291 kind: kind_label(kind).into(),
292 });
293 }
294 bail!("wire service uninstall: unsupported platform")
295}
296
297pub fn status_kind(kind: ServiceKind) -> Result<ServiceReport> {
298 if cfg!(target_os = "macos") {
299 let plist_path = launchd_plist_path(kind)?;
300 let exists = plist_path.exists();
301 let listed = Command::new("launchctl")
302 .args(["list", kind.label()])
303 .output()
304 .map(|o| o.status.success())
305 .unwrap_or(false);
306 return Ok(ServiceReport {
307 action: "status".into(),
308 platform: "macos-launchd".into(),
309 unit_path: plist_path.to_string_lossy().to_string(),
310 status: if listed {
311 "loaded".into()
312 } else if exists {
313 "installed (not loaded)".into()
314 } else {
315 "absent".into()
316 },
317 detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
318 kind: kind_label(kind).into(),
319 });
320 }
321 if cfg!(target_os = "linux") {
322 let unit_path = systemd_unit_path(kind)?;
323 let exists = unit_path.exists();
324 let active = Command::new("systemctl")
325 .args(["--user", "is-active", kind.systemd_unit_name()])
326 .output()
327 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
328 .unwrap_or(false);
329 return Ok(ServiceReport {
330 action: "status".into(),
331 platform: "linux-systemd-user".into(),
332 unit_path: unit_path.to_string_lossy().to_string(),
333 status: if active {
334 "active".into()
335 } else if exists {
336 "installed (inactive)".into()
337 } else {
338 "absent".into()
339 },
340 detail: format!("unit exists={exists}, is-active={active}"),
341 kind: kind_label(kind).into(),
342 });
343 }
344 bail!("wire service status: unsupported platform")
345}
346
347#[cfg(target_os = "linux")]
353fn linger_enabled() -> bool {
354 let user = match std::env::var("USER") {
355 Ok(u) if !u.is_empty() => u,
356 _ => return false,
357 };
358 Command::new("loginctl")
359 .args(["show-user", &user, "--property=Linger"])
360 .output()
361 .ok()
362 .and_then(|o| {
363 if o.status.success() {
364 Some(String::from_utf8_lossy(&o.stdout).into_owned())
365 } else {
366 None
367 }
368 })
369 .map(|s| s.trim().eq_ignore_ascii_case("Linger=yes"))
370 .unwrap_or(false)
371}
372
373#[cfg(not(target_os = "linux"))]
374fn linger_enabled() -> bool {
375 false
379}
380
381fn kind_label(kind: ServiceKind) -> &'static str {
382 match kind {
383 ServiceKind::Daemon => "daemon",
384 ServiceKind::LocalRelay => "local-relay",
385 }
386}
387
388fn launchd_plist_path(kind: ServiceKind) -> Result<PathBuf> {
389 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
390 Ok(PathBuf::from(home)
391 .join("Library")
392 .join("LaunchAgents")
393 .join(format!("{}.plist", kind.label())))
394}
395
396fn launchctl_user_target() -> String {
397 let uid = Command::new("id")
398 .args(["-u"])
399 .output()
400 .ok()
401 .and_then(|o| {
402 if o.status.success() {
403 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
404 } else {
405 None
406 }
407 })
408 .unwrap_or_else(|| "0".to_string());
409 format!("gui/{uid}")
410}
411
412fn launchctl_target_for(kind: ServiceKind) -> String {
413 format!("{}/{}", launchctl_user_target(), kind.label())
414}
415
416#[cfg(target_os = "macos")]
427fn ensure_macos_log_path(kind: ServiceKind) -> Result<PathBuf> {
428 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
429 let dir = PathBuf::from(&home).join("Library").join("Logs");
430 std::fs::create_dir_all(&dir).with_context(|| format!("creating log dir {dir:?}"))?;
431 Ok(dir.join(kind.log_basename()))
432}
433
434#[cfg(not(target_os = "macos"))]
440fn ensure_macos_log_path(_kind: ServiceKind) -> Result<PathBuf> {
441 Ok(PathBuf::new())
442}
443
444fn launchd_plist_xml(kind: ServiceKind, exe: &str, log_path: &str) -> String {
445 let args_xml = kind
446 .binary_args()
447 .iter()
448 .map(|a| format!(" <string>{a}</string>"))
449 .collect::<Vec<_>>()
450 .join("\n");
451 let label = kind.label();
452 format!(
453 r#"<?xml version="1.0" encoding="UTF-8"?>
454<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
455<plist version="1.0">
456<dict>
457 <key>Label</key>
458 <string>{label}</string>
459 <key>ProgramArguments</key>
460 <array>
461 <string>{exe}</string>
462{args_xml}
463 </array>
464 <key>RunAtLoad</key>
465 <true/>
466 <key>KeepAlive</key>
467 <true/>
468 <key>ProcessType</key>
469 <string>Background</string>
470 <key>StandardOutPath</key>
471 <string>{log_path}</string>
472 <key>StandardErrorPath</key>
473 <string>{log_path}</string>
474</dict>
475</plist>
476"#
477 )
478}
479
480fn systemd_unit_path(kind: ServiceKind) -> Result<PathBuf> {
481 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
482 Ok(PathBuf::from(home)
483 .join(".config")
484 .join("systemd")
485 .join("user")
486 .join(kind.systemd_unit_name()))
487}
488
489fn systemd_unit_text(kind: ServiceKind, exe: &str) -> String {
490 let args = kind.binary_args().join(" ");
491 let desc = kind.description();
492 format!(
493 r#"[Unit]
494Description={desc}
495After=network-online.target
496Wants=network-online.target
497
498[Service]
499Type=simple
500ExecStart={exe} {args}
501Restart=on-failure
502RestartSec=5
503
504[Install]
505WantedBy=default.target
506"#
507 )
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn launchd_plist_xml_for_daemon_contains_required_keys() {
516 let xml = launchd_plist_xml(
517 ServiceKind::Daemon,
518 "/usr/local/bin/wire",
519 "/tmp/wire-daemon.log",
520 );
521 assert!(xml.contains("<key>Label</key>"));
522 assert!(xml.contains(ServiceKind::Daemon.label()));
523 assert!(xml.contains("/usr/local/bin/wire"));
524 assert!(xml.contains("<string>daemon</string>"));
525 assert!(xml.contains("<string>--interval</string>"));
526 assert!(xml.contains("<key>KeepAlive</key>"));
527 assert!(xml.contains("<key>RunAtLoad</key>"));
528 assert!(xml.contains("<true/>"));
529 assert!(xml.contains("/tmp/wire-daemon.log"));
531 assert!(!xml.contains("/dev/null"));
532 }
533
534 #[test]
535 fn launchd_plist_xml_for_local_relay_uses_correct_args() {
536 let xml = launchd_plist_xml(
537 ServiceKind::LocalRelay,
538 "/usr/local/bin/wire",
539 "/tmp/wire-local-relay.log",
540 );
541 assert!(xml.contains(ServiceKind::LocalRelay.label()));
542 assert!(xml.contains("<string>relay-server</string>"));
543 assert!(xml.contains("<string>--bind</string>"));
544 assert!(xml.contains("<string>127.0.0.1:8771</string>"));
545 assert!(xml.contains("<string>--local-only</string>"));
546 assert!(!xml.contains("<string>daemon</string>"));
548 }
549
550 #[test]
551 fn systemd_unit_text_for_daemon_contains_required_directives() {
552 let unit = systemd_unit_text(ServiceKind::Daemon, "/usr/local/bin/wire");
553 assert!(unit.contains("[Unit]"));
554 assert!(unit.contains("[Service]"));
555 assert!(unit.contains("[Install]"));
556 assert!(unit.contains("/usr/local/bin/wire daemon --interval 5"));
557 assert!(unit.contains("Restart=on-failure"));
558 assert!(unit.contains("WantedBy=default.target"));
559 }
560
561 #[test]
562 fn systemd_unit_text_for_local_relay_uses_correct_exec() {
563 let unit = systemd_unit_text(ServiceKind::LocalRelay, "/usr/local/bin/wire");
564 assert!(unit.contains(
565 "/usr/local/bin/wire relay-server --bind 127.0.0.1:8771 --local-only"
566 ));
567 assert!(!unit.contains("daemon --interval"));
568 }
569
570 #[test]
571 fn label_and_unit_name_distinct_per_kind() {
572 assert_ne!(
575 ServiceKind::Daemon.label(),
576 ServiceKind::LocalRelay.label()
577 );
578 assert_ne!(
579 ServiceKind::Daemon.systemd_unit_name(),
580 ServiceKind::LocalRelay.systemd_unit_name()
581 );
582 assert_ne!(
583 ServiceKind::Daemon.log_basename(),
584 ServiceKind::LocalRelay.log_basename()
585 );
586 }
587}