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