1use std::path::PathBuf;
32use std::process::Command;
33
34use anyhow::{Context, Result, anyhow, bail};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum ServiceKind {
41 Daemon,
44 LocalRelay,
48}
49
50impl ServiceKind {
51 fn label(self) -> &'static str {
53 match self {
54 ServiceKind::Daemon => "sh.slancha.wire.daemon",
55 ServiceKind::LocalRelay => "sh.slancha.wire.local-relay",
56 }
57 }
58
59 fn systemd_unit_name(self) -> &'static str {
61 match self {
62 ServiceKind::Daemon => "wire-daemon.service",
63 ServiceKind::LocalRelay => "wire-local-relay.service",
64 }
65 }
66
67 fn description(self) -> &'static str {
69 match self {
70 ServiceKind::Daemon => "wire — daemon (push/pull sync)",
71 ServiceKind::LocalRelay => "wire — local-only relay (127.0.0.1:8771)",
72 }
73 }
74
75 fn binary_args(self) -> &'static [&'static str] {
87 match self {
88 ServiceKind::Daemon => &["daemon", "--all-sessions", "--interval", "5"],
89 ServiceKind::LocalRelay => {
90 &["relay-server", "--bind", "127.0.0.1:8771", "--local-only"]
91 }
92 }
93 }
94
95 fn windows_task_name(self) -> &'static str {
102 match self {
103 ServiceKind::Daemon => "wire-daemon",
104 ServiceKind::LocalRelay => "wire-local-relay",
105 }
106 }
107
108 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
119 fn log_basename(self) -> &'static str {
120 match self {
121 ServiceKind::Daemon => "wire-daemon.log",
122 ServiceKind::LocalRelay => "wire-local-relay.log",
123 }
124 }
125}
126
127#[derive(Debug, Clone, serde::Serialize)]
130pub struct ServiceReport {
131 pub action: String,
132 pub platform: String,
133 pub unit_path: String,
134 pub status: String,
135 pub detail: String,
136 #[serde(default)]
139 pub kind: String,
140}
141
142pub fn install() -> Result<ServiceReport> {
145 install_kind(ServiceKind::Daemon)
146}
147pub fn uninstall() -> Result<ServiceReport> {
148 uninstall_kind(ServiceKind::Daemon)
149}
150pub fn status() -> Result<ServiceReport> {
151 status_kind(ServiceKind::Daemon)
152}
153
154pub fn install_kind(kind: ServiceKind) -> Result<ServiceReport> {
156 let exe = std::env::current_exe()?;
157 let exe_str = exe.to_string_lossy().to_string();
158
159 let log_str = if cfg!(target_os = "macos") {
165 ensure_macos_log_path(kind)?.to_string_lossy().to_string()
166 } else {
167 String::new()
168 };
169
170 if cfg!(target_os = "macos") {
171 let plist_path = launchd_plist_path(kind)?;
172 if let Some(parent) = plist_path.parent() {
173 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
174 }
175 let plist = launchd_plist_xml(kind, &exe_str, &log_str);
176 std::fs::write(&plist_path, plist).with_context(|| format!("writing {plist_path:?}"))?;
177
178 let _ = Command::new("launchctl")
180 .args(["bootout", &launchctl_target_for(kind)])
181 .status();
182 let load = Command::new("launchctl")
183 .args([
184 "bootstrap",
185 &launchctl_user_target(),
186 plist_path.to_str().unwrap_or(""),
187 ])
188 .status();
189 let loaded = load.map(|s| s.success()).unwrap_or(false);
190
191 return Ok(ServiceReport {
192 action: "install".into(),
193 platform: "macos-launchd".into(),
194 unit_path: plist_path.to_string_lossy().to_string(),
195 status: if loaded {
196 "loaded".into()
197 } else {
198 "written".into()
199 },
200 detail: if loaded {
201 format!("plist written + bootstrapped; logs at {log_str}")
202 } else {
203 format!(
204 "plist written; `launchctl bootstrap` failed — try `launchctl bootstrap {} {}` manually",
205 launchctl_user_target(),
206 plist_path.display()
207 )
208 },
209 kind: kind_label(kind).into(),
210 });
211 }
212 if cfg!(target_os = "linux") {
213 let unit_path = systemd_unit_path(kind)?;
214 if let Some(parent) = unit_path.parent() {
215 std::fs::create_dir_all(parent).with_context(|| format!("creating {parent:?}"))?;
216 }
217 let unit = systemd_unit_text(kind, &exe_str);
218 std::fs::write(&unit_path, unit).with_context(|| format!("writing {unit_path:?}"))?;
219
220 let _ = Command::new("systemctl")
222 .args(["--user", "daemon-reload"])
223 .status();
224 let enabled = Command::new("systemctl")
225 .args(["--user", "enable", "--now", kind.systemd_unit_name()])
226 .status()
227 .map(|s| s.success())
228 .unwrap_or(false);
229
230 let linger_note = if enabled && !linger_enabled() {
237 let user = std::env::var("USER").unwrap_or_else(|_| "$USER".into());
238 format!(
239 " NOTE: linger is OFF — service starts at *first login*, \
240 not at boot. For boot-time start (e.g. headless SSH boxes), \
241 run `sudo loginctl enable-linger {user}` once."
242 )
243 } else {
244 String::new()
245 };
246
247 return Ok(ServiceReport {
248 action: "install".into(),
249 platform: "linux-systemd-user".into(),
250 unit_path: unit_path.to_string_lossy().to_string(),
251 status: if enabled {
252 "enabled".into()
253 } else {
254 "written".into()
255 },
256 detail: if enabled {
257 format!(
258 "unit written + enable --now succeeded; logs via \
259 `journalctl --user -u {}`{linger_note}",
260 kind.systemd_unit_name()
261 )
262 } else {
263 format!(
264 "unit written; `systemctl --user enable --now {}` failed — try manually",
265 kind.systemd_unit_name()
266 )
267 },
268 kind: kind_label(kind).into(),
269 });
270 }
271 if cfg!(target_os = "windows") {
272 let task_name = kind.windows_task_name();
273 let xml = windows_task_xml(kind, &exe_str);
274 let xml_path = std::env::temp_dir().join(format!("{task_name}.xml"));
280 std::fs::write(&xml_path, xml).with_context(|| format!("writing {xml_path:?}"))?;
281 let create = Command::new("schtasks.exe")
283 .args([
284 "/Create",
285 "/TN",
286 task_name,
287 "/XML",
288 xml_path.to_str().unwrap_or(""),
289 "/F",
290 ])
291 .status();
292 let registered = create.map(|s| s.success()).unwrap_or(false);
293 if registered {
295 let _ = Command::new("schtasks.exe")
296 .args(["/Run", "/TN", task_name])
297 .status();
298 }
299 return Ok(ServiceReport {
300 action: "install".into(),
301 platform: "windows-schtasks".into(),
302 unit_path: xml_path.to_string_lossy().to_string(),
303 status: if registered {
304 "registered".into()
305 } else {
306 "written".into()
307 },
308 detail: if registered {
309 format!(
310 "task `{task_name}` registered + started; will auto-start at logon. \
311 Check with `schtasks /Query /TN {task_name}` or open Task Scheduler."
312 )
313 } else {
314 format!(
315 "task XML written to {} but `schtasks /Create` failed — try manually: \
316 schtasks /Create /TN {task_name} /XML \"{}\" /F",
317 xml_path.display(),
318 xml_path.display()
319 )
320 },
321 kind: kind_label(kind).into(),
322 });
323 }
324 bail!("wire service install: unsupported platform")
325}
326
327pub fn uninstall_kind(kind: ServiceKind) -> Result<ServiceReport> {
328 if cfg!(target_os = "macos") {
329 let plist_path = launchd_plist_path(kind)?;
330 let _ = Command::new("launchctl")
331 .args(["bootout", &launchctl_target_for(kind)])
332 .status();
333 let removed = if plist_path.exists() {
334 std::fs::remove_file(&plist_path).ok();
335 true
336 } else {
337 false
338 };
339 return Ok(ServiceReport {
340 action: "uninstall".into(),
341 platform: "macos-launchd".into(),
342 unit_path: plist_path.to_string_lossy().to_string(),
343 status: if removed {
344 "removed".into()
345 } else {
346 "absent".into()
347 },
348 detail: "launchctl bootout + plist file removed".into(),
349 kind: kind_label(kind).into(),
350 });
351 }
352 if cfg!(target_os = "linux") {
353 let unit_path = systemd_unit_path(kind)?;
354 let _ = Command::new("systemctl")
355 .args(["--user", "disable", "--now", kind.systemd_unit_name()])
356 .status();
357 let removed = if unit_path.exists() {
358 std::fs::remove_file(&unit_path).ok();
359 true
360 } else {
361 false
362 };
363 let _ = Command::new("systemctl")
364 .args(["--user", "daemon-reload"])
365 .status();
366 return Ok(ServiceReport {
367 action: "uninstall".into(),
368 platform: "linux-systemd-user".into(),
369 unit_path: unit_path.to_string_lossy().to_string(),
370 status: if removed {
371 "removed".into()
372 } else {
373 "absent".into()
374 },
375 detail: "systemctl disable --now + unit file removed".into(),
376 kind: kind_label(kind).into(),
377 });
378 }
379 if cfg!(target_os = "windows") {
380 let task_name = kind.windows_task_name();
381 let delete = Command::new("schtasks.exe")
382 .args(["/Delete", "/TN", task_name, "/F"])
383 .status();
384 let removed = delete.map(|s| s.success()).unwrap_or(false);
385 return Ok(ServiceReport {
386 action: "uninstall".into(),
387 platform: "windows-schtasks".into(),
388 unit_path: String::new(),
389 status: if removed {
390 "removed".into()
391 } else {
392 "absent".into()
393 },
394 detail: format!(
395 "schtasks /Delete /TN {task_name} /F (removed={removed}); \
396 if task was foreign or never registered, `absent` is the expected state"
397 ),
398 kind: kind_label(kind).into(),
399 });
400 }
401 bail!("wire service uninstall: unsupported platform")
402}
403
404pub fn status_kind(kind: ServiceKind) -> Result<ServiceReport> {
405 if cfg!(target_os = "macos") {
406 let plist_path = launchd_plist_path(kind)?;
407 let exists = plist_path.exists();
408 let listed = Command::new("launchctl")
409 .args(["list", kind.label()])
410 .output()
411 .map(|o| o.status.success())
412 .unwrap_or(false);
413 return Ok(ServiceReport {
414 action: "status".into(),
415 platform: "macos-launchd".into(),
416 unit_path: plist_path.to_string_lossy().to_string(),
417 status: if listed {
418 "loaded".into()
419 } else if exists {
420 "installed (not loaded)".into()
421 } else {
422 "absent".into()
423 },
424 detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
425 kind: kind_label(kind).into(),
426 });
427 }
428 if cfg!(target_os = "linux") {
429 let unit_path = systemd_unit_path(kind)?;
430 let exists = unit_path.exists();
431 let active = Command::new("systemctl")
432 .args(["--user", "is-active", kind.systemd_unit_name()])
433 .output()
434 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
435 .unwrap_or(false);
436 return Ok(ServiceReport {
437 action: "status".into(),
438 platform: "linux-systemd-user".into(),
439 unit_path: unit_path.to_string_lossy().to_string(),
440 status: if active {
441 "active".into()
442 } else if exists {
443 "installed (inactive)".into()
444 } else {
445 "absent".into()
446 },
447 detail: format!("unit exists={exists}, is-active={active}"),
448 kind: kind_label(kind).into(),
449 });
450 }
451 if cfg!(target_os = "windows") {
452 let task_name = kind.windows_task_name();
453 let query = Command::new("schtasks.exe")
457 .args(["/Query", "/TN", task_name, "/FO", "CSV", "/NH"])
458 .output();
459 let (exists, raw) = match query {
460 Ok(o) if o.status.success() => (true, String::from_utf8_lossy(&o.stdout).into_owned()),
461 _ => (false, String::new()),
462 };
463 let running = raw.to_lowercase().contains("running");
464 return Ok(ServiceReport {
465 action: "status".into(),
466 platform: "windows-schtasks".into(),
467 unit_path: String::new(),
468 status: if running {
469 "running".into()
470 } else if exists {
471 "installed (idle)".into()
472 } else {
473 "absent".into()
474 },
475 detail: format!("schtasks /Query: exists={exists} running={running}"),
476 kind: kind_label(kind).into(),
477 });
478 }
479 bail!("wire service status: unsupported platform")
480}
481
482#[cfg(target_os = "linux")]
488fn linger_enabled() -> bool {
489 let user = match std::env::var("USER") {
490 Ok(u) if !u.is_empty() => u,
491 _ => return false,
492 };
493 Command::new("loginctl")
494 .args(["show-user", &user, "--property=Linger"])
495 .output()
496 .ok()
497 .and_then(|o| {
498 if o.status.success() {
499 Some(String::from_utf8_lossy(&o.stdout).into_owned())
500 } else {
501 None
502 }
503 })
504 .map(|s| s.trim().eq_ignore_ascii_case("Linger=yes"))
505 .unwrap_or(false)
506}
507
508#[cfg(not(target_os = "linux"))]
509fn linger_enabled() -> bool {
510 false
514}
515
516fn kind_label(kind: ServiceKind) -> &'static str {
517 match kind {
518 ServiceKind::Daemon => "daemon",
519 ServiceKind::LocalRelay => "local-relay",
520 }
521}
522
523fn launchd_plist_path(kind: ServiceKind) -> Result<PathBuf> {
524 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
525 Ok(PathBuf::from(home)
526 .join("Library")
527 .join("LaunchAgents")
528 .join(format!("{}.plist", kind.label())))
529}
530
531fn launchctl_user_target() -> String {
532 let uid = Command::new("id")
533 .args(["-u"])
534 .output()
535 .ok()
536 .and_then(|o| {
537 if o.status.success() {
538 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
539 } else {
540 None
541 }
542 })
543 .unwrap_or_else(|| "0".to_string());
544 format!("gui/{uid}")
545}
546
547fn launchctl_target_for(kind: ServiceKind) -> String {
548 format!("{}/{}", launchctl_user_target(), kind.label())
549}
550
551#[cfg(target_os = "macos")]
562fn ensure_macos_log_path(kind: ServiceKind) -> Result<PathBuf> {
563 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
564 let dir = PathBuf::from(&home).join("Library").join("Logs");
565 std::fs::create_dir_all(&dir).with_context(|| format!("creating log dir {dir:?}"))?;
566 Ok(dir.join(kind.log_basename()))
567}
568
569#[cfg(not(target_os = "macos"))]
575fn ensure_macos_log_path(_kind: ServiceKind) -> Result<PathBuf> {
576 Ok(PathBuf::new())
577}
578
579fn launchd_plist_xml(kind: ServiceKind, exe: &str, log_path: &str) -> String {
580 let args_xml = kind
581 .binary_args()
582 .iter()
583 .map(|a| format!(" <string>{a}</string>"))
584 .collect::<Vec<_>>()
585 .join("\n");
586 let label = kind.label();
587 format!(
588 r#"<?xml version="1.0" encoding="UTF-8"?>
589<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
590<plist version="1.0">
591<dict>
592 <key>Label</key>
593 <string>{label}</string>
594 <key>ProgramArguments</key>
595 <array>
596 <string>{exe}</string>
597{args_xml}
598 </array>
599 <key>RunAtLoad</key>
600 <true/>
601 <key>KeepAlive</key>
602 <true/>
603 <key>ProcessType</key>
604 <string>Background</string>
605 <key>StandardOutPath</key>
606 <string>{log_path}</string>
607 <key>StandardErrorPath</key>
608 <string>{log_path}</string>
609</dict>
610</plist>
611"#
612 )
613}
614
615fn systemd_unit_path(kind: ServiceKind) -> Result<PathBuf> {
616 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
617 Ok(PathBuf::from(home)
618 .join(".config")
619 .join("systemd")
620 .join("user")
621 .join(kind.systemd_unit_name()))
622}
623
624fn systemd_unit_text(kind: ServiceKind, exe: &str) -> String {
625 let args = kind.binary_args().join(" ");
626 let desc = kind.description();
627 format!(
628 r#"[Unit]
629Description={desc}
630After=network-online.target
631Wants=network-online.target
632
633[Service]
634Type=simple
635ExecStart={exe} {args}
636Restart=on-failure
637RestartSec=5
638
639[Install]
640WantedBy=default.target
641"#
642 )
643}
644
645fn windows_task_xml(kind: ServiceKind, exe: &str) -> String {
656 let desc = kind.description();
657 let args = kind.binary_args().join(" ");
658 let exe_xml = xml_escape(exe);
662 let args_xml = xml_escape(&args);
663 let desc_xml = xml_escape(desc);
664 format!(
665 r#"<?xml version="1.0" encoding="UTF-8"?>
666<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
667 <RegistrationInfo>
668 <Description>{desc_xml}</Description>
669 <Author>wire (slancha)</Author>
670 </RegistrationInfo>
671 <Triggers>
672 <LogonTrigger>
673 <Enabled>true</Enabled>
674 </LogonTrigger>
675 </Triggers>
676 <Principals>
677 <Principal id="Author">
678 <LogonType>InteractiveToken</LogonType>
679 <RunLevel>LeastPrivilege</RunLevel>
680 </Principal>
681 </Principals>
682 <Settings>
683 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
684 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
685 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
686 <AllowHardTerminate>true</AllowHardTerminate>
687 <StartWhenAvailable>true</StartWhenAvailable>
688 <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
689 <IdleSettings>
690 <StopOnIdleEnd>false</StopOnIdleEnd>
691 <RestartOnIdle>false</RestartOnIdle>
692 </IdleSettings>
693 <AllowStartOnDemand>true</AllowStartOnDemand>
694 <Enabled>true</Enabled>
695 <Hidden>true</Hidden>
696 <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
697 <Priority>7</Priority>
698 <RestartOnFailure>
699 <Interval>PT1M</Interval>
700 <Count>3</Count>
701 </RestartOnFailure>
702 </Settings>
703 <Actions Context="Author">
704 <Exec>
705 <Command>{exe_xml}</Command>
706 <Arguments>{args_xml}</Arguments>
707 </Exec>
708 </Actions>
709</Task>
710"#
711 )
712}
713
714fn xml_escape(s: &str) -> String {
715 s.replace('&', "&")
716 .replace('<', "<")
717 .replace('>', ">")
718 .replace('"', """)
719 .replace('\'', "'")
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 #[test]
727 fn launchd_plist_xml_for_daemon_contains_required_keys() {
728 let xml = launchd_plist_xml(
729 ServiceKind::Daemon,
730 "/usr/local/bin/wire",
731 "/tmp/wire-daemon.log",
732 );
733 assert!(xml.contains("<key>Label</key>"));
734 assert!(xml.contains(ServiceKind::Daemon.label()));
735 assert!(xml.contains("/usr/local/bin/wire"));
736 assert!(xml.contains("<string>daemon</string>"));
737 assert!(xml.contains("<string>--all-sessions</string>"));
738 assert!(xml.contains("<string>--interval</string>"));
739 assert!(xml.contains("<key>KeepAlive</key>"));
740 assert!(xml.contains("<key>RunAtLoad</key>"));
741 assert!(xml.contains("<true/>"));
742 assert!(xml.contains("/tmp/wire-daemon.log"));
744 assert!(!xml.contains("/dev/null"));
745 }
746
747 #[test]
748 fn launchd_plist_xml_for_local_relay_uses_correct_args() {
749 let xml = launchd_plist_xml(
750 ServiceKind::LocalRelay,
751 "/usr/local/bin/wire",
752 "/tmp/wire-local-relay.log",
753 );
754 assert!(xml.contains(ServiceKind::LocalRelay.label()));
755 assert!(xml.contains("<string>relay-server</string>"));
756 assert!(xml.contains("<string>--bind</string>"));
757 assert!(xml.contains("<string>127.0.0.1:8771</string>"));
758 assert!(xml.contains("<string>--local-only</string>"));
759 assert!(!xml.contains("<string>daemon</string>"));
761 }
762
763 #[test]
764 fn systemd_unit_text_for_daemon_contains_required_directives() {
765 let unit = systemd_unit_text(ServiceKind::Daemon, "/usr/local/bin/wire");
766 assert!(unit.contains("[Unit]"));
767 assert!(unit.contains("[Service]"));
768 assert!(unit.contains("[Install]"));
769 assert!(unit.contains("/usr/local/bin/wire daemon --all-sessions --interval 5"));
770 assert!(unit.contains("Restart=on-failure"));
771 assert!(unit.contains("WantedBy=default.target"));
772 }
773
774 #[test]
775 fn systemd_unit_text_for_local_relay_uses_correct_exec() {
776 let unit = systemd_unit_text(ServiceKind::LocalRelay, "/usr/local/bin/wire");
777 assert!(
778 unit.contains("/usr/local/bin/wire relay-server --bind 127.0.0.1:8771 --local-only")
779 );
780 assert!(!unit.contains("daemon --interval"));
781 }
782
783 #[test]
784 fn label_and_unit_name_distinct_per_kind() {
785 assert_ne!(ServiceKind::Daemon.label(), ServiceKind::LocalRelay.label());
788 assert_ne!(
789 ServiceKind::Daemon.systemd_unit_name(),
790 ServiceKind::LocalRelay.systemd_unit_name()
791 );
792 assert_ne!(
793 ServiceKind::Daemon.log_basename(),
794 ServiceKind::LocalRelay.log_basename()
795 );
796 assert_ne!(
797 ServiceKind::Daemon.windows_task_name(),
798 ServiceKind::LocalRelay.windows_task_name()
799 );
800 }
801
802 #[test]
803 fn windows_task_xml_for_daemon_contains_required_elements_v0_7_2() {
804 let xml = windows_task_xml(ServiceKind::Daemon, r"C:\Program Files\wire\wire.exe");
805 assert!(xml.contains(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
808 assert!(xml.contains(r#"<Task version="1.2""#));
809 assert!(xml.contains("<LogonTrigger>"));
812 assert!(xml.contains("<RunLevel>LeastPrivilege</RunLevel>"));
815 assert!(xml.contains("<LogonType>InteractiveToken</LogonType>"));
816 assert!(xml.contains("<Hidden>true</Hidden>"));
818 assert!(xml.contains("<RestartOnFailure>"));
821 assert!(xml.contains("<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>"));
824 assert!(xml.contains(r"C:\Program Files\wire\wire.exe"));
827 assert!(xml.contains("<Arguments>daemon --all-sessions --interval 5</Arguments>"));
828 }
829
830 #[test]
831 fn windows_task_xml_for_local_relay_uses_correct_args_v0_7_2() {
832 let xml = windows_task_xml(ServiceKind::LocalRelay, r"C:\wire\wire.exe");
833 assert!(xml.contains(r"C:\wire\wire.exe"));
834 assert!(
835 xml.contains("<Arguments>relay-server --bind 127.0.0.1:8771 --local-only</Arguments>")
836 );
837 assert!(!xml.contains("daemon --interval"));
839 }
840
841 #[test]
842 fn xml_escape_handles_xml_metacharacters_v0_7_2() {
843 assert_eq!(xml_escape("a & b"), "a & b");
846 assert_eq!(xml_escape("<tag>"), "<tag>");
847 assert_eq!(xml_escape(r#"say "hi""#), "say "hi"");
848 assert_eq!(xml_escape("it's"), "it's");
849 }
850}