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 {
97 match self {
98 ServiceKind::Daemon => "wire-daemon.log",
99 ServiceKind::LocalRelay => "wire-local-relay.log",
100 }
101 }
102}
103
104#[derive(Debug, Clone, serde::Serialize)]
107pub struct ServiceReport {
108 pub action: String,
109 pub platform: String,
110 pub unit_path: String,
111 pub status: String,
112 pub detail: String,
113 #[serde(default)]
116 pub kind: String,
117}
118
119pub fn install() -> Result<ServiceReport> {
122 install_kind(ServiceKind::Daemon)
123}
124pub fn uninstall() -> Result<ServiceReport> {
125 uninstall_kind(ServiceKind::Daemon)
126}
127pub fn status() -> Result<ServiceReport> {
128 status_kind(ServiceKind::Daemon)
129}
130
131pub fn install_kind(kind: ServiceKind) -> Result<ServiceReport> {
133 let exe = std::env::current_exe()?;
134 let exe_str = exe.to_string_lossy().to_string();
135
136 let log_path = ensure_log_path(kind)?;
137 let log_str = log_path.to_string_lossy().to_string();
138
139 if cfg!(target_os = "macos") {
140 let plist_path = launchd_plist_path(kind)?;
141 if let Some(parent) = plist_path.parent() {
142 std::fs::create_dir_all(parent)
143 .with_context(|| format!("creating {parent:?}"))?;
144 }
145 let plist = launchd_plist_xml(kind, &exe_str, &log_str);
146 std::fs::write(&plist_path, plist)
147 .with_context(|| format!("writing {plist_path:?}"))?;
148
149 let _ = Command::new("launchctl")
151 .args(["bootout", &launchctl_target_for(kind)])
152 .status();
153 let load = Command::new("launchctl")
154 .args([
155 "bootstrap",
156 &launchctl_user_target(),
157 plist_path.to_str().unwrap_or(""),
158 ])
159 .status();
160 let loaded = load.map(|s| s.success()).unwrap_or(false);
161
162 return Ok(ServiceReport {
163 action: "install".into(),
164 platform: "macos-launchd".into(),
165 unit_path: plist_path.to_string_lossy().to_string(),
166 status: if loaded { "loaded".into() } else { "written".into() },
167 detail: if loaded {
168 format!(
169 "plist written + bootstrapped; logs at {log_str}"
170 )
171 } else {
172 format!(
173 "plist written; `launchctl bootstrap` failed — try `launchctl bootstrap {} {}` manually",
174 launchctl_user_target(),
175 plist_path.display()
176 )
177 },
178 kind: kind_label(kind).into(),
179 });
180 }
181 if cfg!(target_os = "linux") {
182 let unit_path = systemd_unit_path(kind)?;
183 if let Some(parent) = unit_path.parent() {
184 std::fs::create_dir_all(parent)
185 .with_context(|| format!("creating {parent:?}"))?;
186 }
187 let unit = systemd_unit_text(kind, &exe_str);
188 std::fs::write(&unit_path, unit)
189 .with_context(|| format!("writing {unit_path:?}"))?;
190
191 let _ = Command::new("systemctl")
193 .args(["--user", "daemon-reload"])
194 .status();
195 let enabled = Command::new("systemctl")
196 .args(["--user", "enable", "--now", kind.systemd_unit_name()])
197 .status()
198 .map(|s| s.success())
199 .unwrap_or(false);
200
201 return Ok(ServiceReport {
202 action: "install".into(),
203 platform: "linux-systemd-user".into(),
204 unit_path: unit_path.to_string_lossy().to_string(),
205 status: if enabled { "enabled".into() } else { "written".into() },
206 detail: if enabled {
207 format!("unit written + enable --now succeeded; logs at {log_str}")
208 } else {
209 format!(
210 "unit written; `systemctl --user enable --now {}` failed — try manually",
211 kind.systemd_unit_name()
212 )
213 },
214 kind: kind_label(kind).into(),
215 });
216 }
217 bail!("wire service install: unsupported platform")
218}
219
220pub fn uninstall_kind(kind: ServiceKind) -> Result<ServiceReport> {
221 if cfg!(target_os = "macos") {
222 let plist_path = launchd_plist_path(kind)?;
223 let _ = Command::new("launchctl")
224 .args(["bootout", &launchctl_target_for(kind)])
225 .status();
226 let removed = if plist_path.exists() {
227 std::fs::remove_file(&plist_path).ok();
228 true
229 } else {
230 false
231 };
232 return Ok(ServiceReport {
233 action: "uninstall".into(),
234 platform: "macos-launchd".into(),
235 unit_path: plist_path.to_string_lossy().to_string(),
236 status: if removed { "removed".into() } else { "absent".into() },
237 detail: "launchctl bootout + plist file removed".into(),
238 kind: kind_label(kind).into(),
239 });
240 }
241 if cfg!(target_os = "linux") {
242 let unit_path = systemd_unit_path(kind)?;
243 let _ = Command::new("systemctl")
244 .args(["--user", "disable", "--now", kind.systemd_unit_name()])
245 .status();
246 let removed = if unit_path.exists() {
247 std::fs::remove_file(&unit_path).ok();
248 true
249 } else {
250 false
251 };
252 let _ = Command::new("systemctl")
253 .args(["--user", "daemon-reload"])
254 .status();
255 return Ok(ServiceReport {
256 action: "uninstall".into(),
257 platform: "linux-systemd-user".into(),
258 unit_path: unit_path.to_string_lossy().to_string(),
259 status: if removed { "removed".into() } else { "absent".into() },
260 detail: "systemctl disable --now + unit file removed".into(),
261 kind: kind_label(kind).into(),
262 });
263 }
264 bail!("wire service uninstall: unsupported platform")
265}
266
267pub fn status_kind(kind: ServiceKind) -> Result<ServiceReport> {
268 if cfg!(target_os = "macos") {
269 let plist_path = launchd_plist_path(kind)?;
270 let exists = plist_path.exists();
271 let listed = Command::new("launchctl")
272 .args(["list", kind.label()])
273 .output()
274 .map(|o| o.status.success())
275 .unwrap_or(false);
276 return Ok(ServiceReport {
277 action: "status".into(),
278 platform: "macos-launchd".into(),
279 unit_path: plist_path.to_string_lossy().to_string(),
280 status: if listed {
281 "loaded".into()
282 } else if exists {
283 "installed (not loaded)".into()
284 } else {
285 "absent".into()
286 },
287 detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
288 kind: kind_label(kind).into(),
289 });
290 }
291 if cfg!(target_os = "linux") {
292 let unit_path = systemd_unit_path(kind)?;
293 let exists = unit_path.exists();
294 let active = Command::new("systemctl")
295 .args(["--user", "is-active", kind.systemd_unit_name()])
296 .output()
297 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
298 .unwrap_or(false);
299 return Ok(ServiceReport {
300 action: "status".into(),
301 platform: "linux-systemd-user".into(),
302 unit_path: unit_path.to_string_lossy().to_string(),
303 status: if active {
304 "active".into()
305 } else if exists {
306 "installed (inactive)".into()
307 } else {
308 "absent".into()
309 },
310 detail: format!("unit exists={exists}, is-active={active}"),
311 kind: kind_label(kind).into(),
312 });
313 }
314 bail!("wire service status: unsupported platform")
315}
316
317fn kind_label(kind: ServiceKind) -> &'static str {
318 match kind {
319 ServiceKind::Daemon => "daemon",
320 ServiceKind::LocalRelay => "local-relay",
321 }
322}
323
324fn launchd_plist_path(kind: ServiceKind) -> Result<PathBuf> {
325 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
326 Ok(PathBuf::from(home)
327 .join("Library")
328 .join("LaunchAgents")
329 .join(format!("{}.plist", kind.label())))
330}
331
332fn launchctl_user_target() -> String {
333 let uid = Command::new("id")
334 .args(["-u"])
335 .output()
336 .ok()
337 .and_then(|o| {
338 if o.status.success() {
339 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
340 } else {
341 None
342 }
343 })
344 .unwrap_or_else(|| "0".to_string());
345 format!("gui/{uid}")
346}
347
348fn launchctl_target_for(kind: ServiceKind) -> String {
349 format!("{}/{}", launchctl_user_target(), kind.label())
350}
351
352fn ensure_log_path(kind: ServiceKind) -> Result<PathBuf> {
356 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
357 let dir = if cfg!(target_os = "macos") {
358 PathBuf::from(&home).join("Library").join("Logs")
359 } else {
360 std::env::var("XDG_STATE_HOME")
362 .ok()
363 .map(|p| PathBuf::from(p).join("wire"))
364 .or_else(|| {
365 std::env::var("XDG_CACHE_HOME")
366 .ok()
367 .map(|p| PathBuf::from(p).join("wire"))
368 })
369 .unwrap_or_else(|| PathBuf::from(&home).join(".cache").join("wire"))
370 };
371 std::fs::create_dir_all(&dir).with_context(|| format!("creating log dir {dir:?}"))?;
372 Ok(dir.join(kind.log_basename()))
373}
374
375fn launchd_plist_xml(kind: ServiceKind, exe: &str, log_path: &str) -> String {
376 let args_xml = kind
377 .binary_args()
378 .iter()
379 .map(|a| format!(" <string>{a}</string>"))
380 .collect::<Vec<_>>()
381 .join("\n");
382 let label = kind.label();
383 format!(
384 r#"<?xml version="1.0" encoding="UTF-8"?>
385<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
386<plist version="1.0">
387<dict>
388 <key>Label</key>
389 <string>{label}</string>
390 <key>ProgramArguments</key>
391 <array>
392 <string>{exe}</string>
393{args_xml}
394 </array>
395 <key>RunAtLoad</key>
396 <true/>
397 <key>KeepAlive</key>
398 <true/>
399 <key>ProcessType</key>
400 <string>Background</string>
401 <key>StandardOutPath</key>
402 <string>{log_path}</string>
403 <key>StandardErrorPath</key>
404 <string>{log_path}</string>
405</dict>
406</plist>
407"#
408 )
409}
410
411fn systemd_unit_path(kind: ServiceKind) -> Result<PathBuf> {
412 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
413 Ok(PathBuf::from(home)
414 .join(".config")
415 .join("systemd")
416 .join("user")
417 .join(kind.systemd_unit_name()))
418}
419
420fn systemd_unit_text(kind: ServiceKind, exe: &str) -> String {
421 let args = kind.binary_args().join(" ");
422 let desc = kind.description();
423 format!(
424 r#"[Unit]
425Description={desc}
426After=network-online.target
427Wants=network-online.target
428
429[Service]
430Type=simple
431ExecStart={exe} {args}
432Restart=on-failure
433RestartSec=5
434
435[Install]
436WantedBy=default.target
437"#
438 )
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn launchd_plist_xml_for_daemon_contains_required_keys() {
447 let xml = launchd_plist_xml(
448 ServiceKind::Daemon,
449 "/usr/local/bin/wire",
450 "/tmp/wire-daemon.log",
451 );
452 assert!(xml.contains("<key>Label</key>"));
453 assert!(xml.contains(ServiceKind::Daemon.label()));
454 assert!(xml.contains("/usr/local/bin/wire"));
455 assert!(xml.contains("<string>daemon</string>"));
456 assert!(xml.contains("<string>--interval</string>"));
457 assert!(xml.contains("<key>KeepAlive</key>"));
458 assert!(xml.contains("<key>RunAtLoad</key>"));
459 assert!(xml.contains("<true/>"));
460 assert!(xml.contains("/tmp/wire-daemon.log"));
462 assert!(!xml.contains("/dev/null"));
463 }
464
465 #[test]
466 fn launchd_plist_xml_for_local_relay_uses_correct_args() {
467 let xml = launchd_plist_xml(
468 ServiceKind::LocalRelay,
469 "/usr/local/bin/wire",
470 "/tmp/wire-local-relay.log",
471 );
472 assert!(xml.contains(ServiceKind::LocalRelay.label()));
473 assert!(xml.contains("<string>relay-server</string>"));
474 assert!(xml.contains("<string>--bind</string>"));
475 assert!(xml.contains("<string>127.0.0.1:8771</string>"));
476 assert!(xml.contains("<string>--local-only</string>"));
477 assert!(!xml.contains("<string>daemon</string>"));
479 }
480
481 #[test]
482 fn systemd_unit_text_for_daemon_contains_required_directives() {
483 let unit = systemd_unit_text(ServiceKind::Daemon, "/usr/local/bin/wire");
484 assert!(unit.contains("[Unit]"));
485 assert!(unit.contains("[Service]"));
486 assert!(unit.contains("[Install]"));
487 assert!(unit.contains("/usr/local/bin/wire daemon --interval 5"));
488 assert!(unit.contains("Restart=on-failure"));
489 assert!(unit.contains("WantedBy=default.target"));
490 }
491
492 #[test]
493 fn systemd_unit_text_for_local_relay_uses_correct_exec() {
494 let unit = systemd_unit_text(ServiceKind::LocalRelay, "/usr/local/bin/wire");
495 assert!(unit.contains(
496 "/usr/local/bin/wire relay-server --bind 127.0.0.1:8771 --local-only"
497 ));
498 assert!(!unit.contains("daemon --interval"));
499 }
500
501 #[test]
502 fn label_and_unit_name_distinct_per_kind() {
503 assert_ne!(
506 ServiceKind::Daemon.label(),
507 ServiceKind::LocalRelay.label()
508 );
509 assert_ne!(
510 ServiceKind::Daemon.systemd_unit_name(),
511 ServiceKind::LocalRelay.systemd_unit_name()
512 );
513 assert_ne!(
514 ServiceKind::Daemon.log_basename(),
515 ServiceKind::LocalRelay.log_basename()
516 );
517 }
518}