1use std::path::PathBuf;
17use std::process::Command;
18
19use anyhow::{Context, Result, anyhow, bail};
20
21const LAUNCHD_LABEL: &str = "sh.slancha.wire.daemon";
22const SYSTEMD_UNIT_NAME: &str = "wire-daemon.service";
23
24#[derive(Debug, Clone, serde::Serialize)]
27pub struct ServiceReport {
28 pub action: String,
29 pub platform: String,
30 pub unit_path: String,
31 pub status: String,
32 pub detail: String,
33}
34
35pub fn install() -> Result<ServiceReport> {
38 let exe = std::env::current_exe()?;
39 let exe_str = exe.to_string_lossy().to_string();
40
41 if cfg!(target_os = "macos") {
42 let plist_path = launchd_plist_path()?;
43 if let Some(parent) = plist_path.parent() {
44 std::fs::create_dir_all(parent)
45 .with_context(|| format!("creating {parent:?}"))?;
46 }
47 let plist = launchd_plist_xml(&exe_str);
48 std::fs::write(&plist_path, plist)
49 .with_context(|| format!("writing {plist_path:?}"))?;
50
51 let _ = Command::new("launchctl")
54 .args(["bootout", &launchctl_user_target()])
55 .status();
56 let load = Command::new("launchctl")
57 .args(["bootstrap", &launchctl_user_target(), plist_path.to_str().unwrap_or("")])
58 .status();
59 let loaded = load.map(|s| s.success()).unwrap_or(false);
60
61 return Ok(ServiceReport {
62 action: "install".into(),
63 platform: "macos-launchd".into(),
64 unit_path: plist_path.to_string_lossy().to_string(),
65 status: if loaded { "loaded".into() } else { "written".into() },
66 detail: if loaded {
67 "plist written and bootstrapped via launchctl".into()
68 } else {
69 "plist written; `launchctl bootstrap` failed — try manually".into()
70 },
71 });
72 }
73 if cfg!(target_os = "linux") {
74 let unit_path = systemd_unit_path()?;
75 if let Some(parent) = unit_path.parent() {
76 std::fs::create_dir_all(parent)
77 .with_context(|| format!("creating {parent:?}"))?;
78 }
79 let unit = systemd_unit_text(&exe_str);
80 std::fs::write(&unit_path, unit)
81 .with_context(|| format!("writing {unit_path:?}"))?;
82
83 let _ = Command::new("systemctl")
85 .args(["--user", "daemon-reload"])
86 .status();
87 let enabled = Command::new("systemctl")
88 .args(["--user", "enable", "--now", SYSTEMD_UNIT_NAME])
89 .status()
90 .map(|s| s.success())
91 .unwrap_or(false);
92
93 return Ok(ServiceReport {
94 action: "install".into(),
95 platform: "linux-systemd-user".into(),
96 unit_path: unit_path.to_string_lossy().to_string(),
97 status: if enabled { "enabled".into() } else { "written".into() },
98 detail: if enabled {
99 "service unit written, daemon-reload + enable --now succeeded".into()
100 } else {
101 "unit written; `systemctl --user enable --now` failed — try manually".into()
102 },
103 });
104 }
105 bail!("wire service install: unsupported platform")
106}
107
108pub fn uninstall() -> Result<ServiceReport> {
109 if cfg!(target_os = "macos") {
110 let plist_path = launchd_plist_path()?;
111 let _ = Command::new("launchctl")
112 .args(["bootout", &launchctl_user_target()])
113 .status();
114 let removed = if plist_path.exists() {
115 std::fs::remove_file(&plist_path).ok();
116 true
117 } else {
118 false
119 };
120 return Ok(ServiceReport {
121 action: "uninstall".into(),
122 platform: "macos-launchd".into(),
123 unit_path: plist_path.to_string_lossy().to_string(),
124 status: if removed { "removed".into() } else { "absent".into() },
125 detail: "launchctl bootout + plist file removed".into(),
126 });
127 }
128 if cfg!(target_os = "linux") {
129 let unit_path = systemd_unit_path()?;
130 let _ = Command::new("systemctl")
131 .args(["--user", "disable", "--now", SYSTEMD_UNIT_NAME])
132 .status();
133 let removed = if unit_path.exists() {
134 std::fs::remove_file(&unit_path).ok();
135 true
136 } else {
137 false
138 };
139 let _ = Command::new("systemctl")
140 .args(["--user", "daemon-reload"])
141 .status();
142 return Ok(ServiceReport {
143 action: "uninstall".into(),
144 platform: "linux-systemd-user".into(),
145 unit_path: unit_path.to_string_lossy().to_string(),
146 status: if removed { "removed".into() } else { "absent".into() },
147 detail: "systemctl disable --now + unit file removed".into(),
148 });
149 }
150 bail!("wire service uninstall: unsupported platform")
151}
152
153pub fn status() -> Result<ServiceReport> {
154 if cfg!(target_os = "macos") {
155 let plist_path = launchd_plist_path()?;
156 let exists = plist_path.exists();
157 let listed = Command::new("launchctl")
160 .args(["list", LAUNCHD_LABEL])
161 .output()
162 .map(|o| o.status.success())
163 .unwrap_or(false);
164 return Ok(ServiceReport {
165 action: "status".into(),
166 platform: "macos-launchd".into(),
167 unit_path: plist_path.to_string_lossy().to_string(),
168 status: if listed {
169 "loaded".into()
170 } else if exists {
171 "installed (not loaded)".into()
172 } else {
173 "absent".into()
174 },
175 detail: format!("plist exists={exists}, launchctl-list-success={listed}"),
176 });
177 }
178 if cfg!(target_os = "linux") {
179 let unit_path = systemd_unit_path()?;
180 let exists = unit_path.exists();
181 let active = Command::new("systemctl")
182 .args(["--user", "is-active", SYSTEMD_UNIT_NAME])
183 .output()
184 .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
185 .unwrap_or(false);
186 return Ok(ServiceReport {
187 action: "status".into(),
188 platform: "linux-systemd-user".into(),
189 unit_path: unit_path.to_string_lossy().to_string(),
190 status: if active {
191 "active".into()
192 } else if exists {
193 "installed (inactive)".into()
194 } else {
195 "absent".into()
196 },
197 detail: format!("unit exists={exists}, is-active={active}"),
198 });
199 }
200 bail!("wire service status: unsupported platform")
201}
202
203fn launchd_plist_path() -> Result<PathBuf> {
204 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
205 Ok(PathBuf::from(home)
206 .join("Library")
207 .join("LaunchAgents")
208 .join(format!("{LAUNCHD_LABEL}.plist")))
209}
210
211fn launchctl_user_target() -> String {
212 let uid = Command::new("id")
215 .args(["-u"])
216 .output()
217 .ok()
218 .and_then(|o| {
219 if o.status.success() {
220 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
221 } else {
222 None
223 }
224 })
225 .unwrap_or_else(|| "0".to_string());
226 format!("gui/{uid}")
227}
228
229fn launchd_plist_xml(exe: &str) -> String {
230 format!(
234 r#"<?xml version="1.0" encoding="UTF-8"?>
235<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
236<plist version="1.0">
237<dict>
238 <key>Label</key>
239 <string>{LAUNCHD_LABEL}</string>
240 <key>ProgramArguments</key>
241 <array>
242 <string>{exe}</string>
243 <string>daemon</string>
244 <string>--interval</string>
245 <string>5</string>
246 </array>
247 <key>RunAtLoad</key>
248 <true/>
249 <key>KeepAlive</key>
250 <true/>
251 <key>ProcessType</key>
252 <string>Background</string>
253 <key>StandardOutPath</key>
254 <string>/dev/null</string>
255 <key>StandardErrorPath</key>
256 <string>/dev/null</string>
257</dict>
258</plist>
259"#
260 )
261}
262
263fn systemd_unit_path() -> Result<PathBuf> {
264 let home = std::env::var("HOME").map_err(|_| anyhow!("HOME env var unset"))?;
265 Ok(PathBuf::from(home)
266 .join(".config")
267 .join("systemd")
268 .join("user")
269 .join(SYSTEMD_UNIT_NAME))
270}
271
272fn systemd_unit_text(exe: &str) -> String {
273 format!(
277 r#"[Unit]
278Description=wire — magic-wormhole for AI agents (daemon)
279After=network-online.target
280Wants=network-online.target
281
282[Service]
283Type=simple
284ExecStart={exe} daemon --interval 5
285Restart=on-failure
286RestartSec=5
287
288[Install]
289WantedBy=default.target
290"#
291 )
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn launchd_plist_xml_contains_required_keys() {
300 let xml = launchd_plist_xml("/usr/local/bin/wire");
303 assert!(xml.contains("<key>Label</key>"));
304 assert!(xml.contains(LAUNCHD_LABEL));
305 assert!(xml.contains("/usr/local/bin/wire"));
306 assert!(xml.contains("<key>KeepAlive</key>"));
307 assert!(xml.contains("<key>RunAtLoad</key>"));
308 assert!(xml.contains("<true/>"));
311 }
312
313 #[test]
314 fn systemd_unit_text_contains_required_directives() {
315 let unit = systemd_unit_text("/usr/local/bin/wire");
316 assert!(unit.contains("[Unit]"));
317 assert!(unit.contains("[Service]"));
318 assert!(unit.contains("[Install]"));
319 assert!(unit.contains("/usr/local/bin/wire daemon"));
320 assert!(unit.contains("Restart=on-failure"));
321 assert!(unit.contains("WantedBy=default.target"));
325 }
326}