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 windows_task_name(self) -> &'static str {
91 match self {
92 ServiceKind::Daemon => "wire-daemon",
93 ServiceKind::LocalRelay => "wire-local-relay",
94 }
95 }
96
97 #[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#[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 #[serde(default)]
128 pub kind: String,
129}
130
131pub 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
143pub 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 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 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 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 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 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 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 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 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#[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 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#[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#[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
634fn windows_task_xml(kind: ServiceKind, exe: &str) -> String {
645 let desc = kind.description();
646 let args = kind.binary_args().join(" ");
647 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('&', "&")
705 .replace('<', "<")
706 .replace('>', ">")
707 .replace('"', """)
708 .replace('\'', "'")
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 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 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 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 assert!(xml.contains(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
796 assert!(xml.contains(r#"<Task version="1.2""#));
797 assert!(xml.contains("<LogonTrigger>"));
800 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"));
803 assert!(xml.contains("<LogonType>InteractiveToken</LogonType>"));
804 assert!(xml.contains("<Hidden>true</Hidden>"));
806 assert!(xml.contains("<RestartOnFailure>"));
809 assert!(xml.contains("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"));
812 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 assert!(!xml.contains("daemon --interval"));
827 }
828
829 #[test]
830 fn xml_escape_handles_xml_metacharacters_v0_7_2() {
831 assert_eq!(xml_escape("a & b"), "a & b");
834 assert_eq!(xml_escape("<tag>"), "<tag>");
835 assert_eq!(xml_escape(r#"say "hi""#), "say "hi"");
836 assert_eq!(xml_escape("it's"), "it's");
837 }
838}