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