1use std::fmt::Write as _;
16use std::io;
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20use qframe::storage::config_dir;
21
22use crate::engine::HostUser;
23use crate::store::{Platform, Registry};
24
25pub const SERVICE_UNIT: &str = "qcode-reaper.service";
27
28pub const PATH_UNIT: &str = "qcode-reaper.path";
30
31pub const LAUNCHD_LABEL: &str = "io.quvyta.code.reaper";
33
34pub const REAPER_ARG: &str = "reaper";
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ServiceHost {
40 pub platform: Platform,
42 pub units: PathBuf,
45 pub registry: PathBuf,
47 pub program: PathBuf,
49 pub uid: Option<u32>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Step {
56 Write(PathBuf, String),
58 Remove(PathBuf),
60 Run(HostCommand),
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct HostCommand {
67 pub program: String,
69 pub args: Vec<String>,
71}
72
73impl HostCommand {
74 fn new(program: &str, args: &[&str]) -> Self {
75 Self { program: program.to_owned(), args: args.iter().map(|arg| (*arg).to_owned()).collect() }
76 }
77
78 #[must_use]
80 pub fn written(&self) -> String {
81 std::iter::once(self.program.as_str()).chain(self.args.iter().map(String::as_str)).collect::<Vec<_>>().join(" ")
82 }
83}
84
85impl ServiceHost {
86 #[must_use]
89 pub fn detect() -> Option<Self> {
90 let platform = Platform::host();
91 let units = match platform {
92 Platform::Windows => return None,
93 Platform::Linux => config_dir("systemd/user")?,
94 Platform::MacOs => {
95 let home = std::env::var_os("HOME").map(PathBuf::from).filter(|home| home.is_absolute())?;
96 home.join("Library").join("LaunchAgents")
97 }
98 };
99 let uid = match HostUser::current() {
100 Ok(HostUser::Ids { uid, .. }) => Some(uid),
101 _ => None,
102 };
103 Some(Self { platform, units, registry: Registry::file()?, program: std::env::current_exe().ok()?, uid })
104 }
105
106 #[must_use]
108 pub fn files(&self) -> Vec<(PathBuf, String)> {
109 match self.platform {
110 Platform::MacOs => vec![(self.units.join(format!("{LAUNCHD_LABEL}.plist")), self.plist())],
111 Platform::Linux | Platform::Windows => vec![
112 (self.units.join(SERVICE_UNIT), self.service_unit()),
113 (self.units.join(PATH_UNIT), self.path_unit()),
114 ],
115 }
116 }
117
118 #[must_use]
120 pub fn is_installed(&self) -> bool {
121 self.files().iter().all(|(path, _)| path.is_file())
122 }
123
124 #[must_use]
126 pub fn install(&self) -> Vec<Step> {
127 let mut steps: Vec<Step> = self.files().into_iter().map(|(path, text)| Step::Write(path, text)).collect();
128 match self.platform {
129 Platform::MacOs => {
130 let plist = self.units.join(format!("{LAUNCHD_LABEL}.plist"));
131 let plist = plist.display().to_string();
132 steps.push(Step::Run(HostCommand::new("launchctl", &["bootstrap", &self.domain(), &plist])));
133 }
134 Platform::Linux | Platform::Windows => {
135 steps.push(Step::Run(HostCommand::new("systemctl", &["--user", "daemon-reload"])));
136 steps.push(Step::Run(HostCommand::new("systemctl", &["--user", "enable", "--now", PATH_UNIT])));
137 }
138 }
139 steps
140 }
141
142 #[must_use]
144 pub fn uninstall(&self) -> Vec<Step> {
145 let mut steps = Vec::new();
146 match self.platform {
147 Platform::MacOs => {
148 let target = format!("{}/{LAUNCHD_LABEL}", self.domain());
149 steps.push(Step::Run(HostCommand::new("launchctl", &["bootout", &target])));
150 }
151 Platform::Linux | Platform::Windows => {
152 steps.push(Step::Run(HostCommand::new("systemctl", &["--user", "disable", "--now", PATH_UNIT])));
153 steps.push(Step::Run(HostCommand::new("systemctl", &["--user", "stop", SERVICE_UNIT])));
155 }
156 }
157 steps.extend(self.files().into_iter().map(|(path, _)| Step::Remove(path)));
158 if self.platform != Platform::MacOs {
159 steps.push(Step::Run(HostCommand::new("systemctl", &["--user", "daemon-reload"])));
160 }
161 steps
162 }
163
164 fn domain(&self) -> String {
166 match self.uid {
167 Some(uid) => format!("gui/{uid}"),
168 None => "gui".to_owned(),
170 }
171 }
172
173 fn service_unit(&self) -> String {
178 let mut text = String::from("[Unit]\nDescription=Stops the containers QCode started once no QCode is open\n");
179 let _ = write!(
180 text,
181 "\n[Service]\nType=exec\nExecStart={} {REAPER_ARG}\n",
182 systemd_quoted(&self.program.display().to_string())
183 );
184 text
185 }
186
187 fn path_unit(&self) -> String {
189 let mut text = String::from("[Unit]\nDescription=Watches the list of containers QCode started\n");
190 let _ = write!(
191 text,
192 "\n[Path]\nPathChanged={}\nUnit={SERVICE_UNIT}\n\n[Install]\nWantedBy=default.target\n",
193 systemd_escaped(&self.registry.display().to_string())
194 );
195 text
196 }
197
198 fn plist(&self) -> String {
200 format!(
201 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
202 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
203 <plist version=\"1.0\">\n\
204 <dict>\n\
205 \t<key>Label</key>\n\
206 \t<string>{LAUNCHD_LABEL}</string>\n\
207 \t<key>ProgramArguments</key>\n\
208 \t<array>\n\
209 \t\t<string>{}</string>\n\
210 \t\t<string>{REAPER_ARG}</string>\n\
211 \t</array>\n\
212 \t<key>WatchPaths</key>\n\
213 \t<array>\n\
214 \t\t<string>{}</string>\n\
215 \t</array>\n\
216 </dict>\n\
217 </plist>\n",
218 xml_escaped(&self.program.display().to_string()),
219 xml_escaped(&self.registry.display().to_string()),
220 )
221 }
222}
223
224fn systemd_escaped(text: &str) -> String {
226 text.replace('%', "%%")
227}
228
229fn systemd_quoted(text: &str) -> String {
232 let mut out = String::from("\"");
233 for character in text.chars() {
234 match character {
235 '"' => out.push_str("\\\""),
236 '\\' => out.push_str("\\\\"),
237 '%' => out.push_str("%%"),
238 '$' => out.push_str("$$"),
239 other => out.push(other),
240 }
241 }
242 out.push('"');
243 out
244}
245
246fn xml_escaped(text: &str) -> String {
248 text.replace('&', "&").replace('<', "<").replace('>', ">")
249}
250
251pub fn perform(steps: &[Step], run: &mut dyn FnMut(&HostCommand) -> Result<(), String>) -> Result<(), String> {
260 for step in steps {
261 match step {
262 Step::Write(path, text) => write(path, text).map_err(|error| format!("{}: {error}", path.display()))?,
263 Step::Remove(path) => match std::fs::remove_file(path) {
264 Ok(()) => {}
265 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
266 Err(error) => return Err(format!("{}: {error}", path.display())),
267 },
268 Step::Run(command) => run(command)?,
269 }
270 }
271 Ok(())
272}
273
274fn write(path: &Path, text: &str) -> io::Result<()> {
276 if let Some(dir) = path.parent() {
277 std::fs::create_dir_all(dir)?;
278 }
279 qframe::storage::atomic_write(path, text.as_bytes())
280}
281
282pub fn run_host(command: &HostCommand) -> Result<(), String> {
291 let output = Command::new(&command.program)
292 .args(&command.args)
293 .output()
294 .map_err(|error| format!("{}: {error}", command.program))?;
295 if output.status.success() {
296 return Ok(());
297 }
298 let said = String::from_utf8_lossy(&output.stderr).trim().to_owned();
299 let said = if said.is_empty() { String::from_utf8_lossy(&output.stdout).trim().to_owned() } else { said };
300 Err(if said.is_empty() { command.written() } else { format!("{}: {said}", command.written()) })
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn linux(root: &Path) -> ServiceHost {
308 ServiceHost {
309 platform: Platform::Linux,
310 units: root.join("config").join("systemd").join("user"),
311 registry: PathBuf::from("/home/ada/.local/share/quvyta/code/containers.toml"),
312 program: PathBuf::from("/home/ada/.cargo/bin/qcode"),
313 uid: Some(1000),
314 }
315 }
316
317 fn mac(root: &Path) -> ServiceHost {
318 ServiceHost {
319 platform: Platform::MacOs,
320 units: root.join("Library").join("LaunchAgents"),
321 registry: PathBuf::from("/Users/ada/Library/Application Support/quvyta/code/containers.toml"),
322 program: PathBuf::from("/opt/homebrew/bin/qcode"),
323 uid: Some(501),
324 }
325 }
326
327 struct Scratch(PathBuf);
329
330 impl Scratch {
331 fn new(name: &str) -> Self {
332 let path = std::env::temp_dir().join(format!("qcode-service-{name}-{}", std::process::id()));
333 let _ = std::fs::remove_dir_all(&path);
334 Self(path)
335 }
336 }
337
338 impl Drop for Scratch {
339 fn drop(&mut self) {
340 let _ = std::fs::remove_dir_all(&self.0);
341 }
342 }
343
344 fn commands(steps: &[Step]) -> Vec<String> {
345 steps
346 .iter()
347 .filter_map(|step| match step {
348 Step::Run(command) => Some(command.written()),
349 _ => None,
350 })
351 .collect()
352 }
353
354 #[test]
355 fn the_systemd_units_are_exactly_these() {
356 let host = linux(Path::new("/tmp/x"));
357 let files = host.files();
358 assert_eq!(files[0].0, Path::new("/tmp/x/config/systemd/user/qcode-reaper.service"));
359 assert_eq!(
360 files[0].1,
361 "[Unit]\nDescription=Stops the containers QCode started once no QCode is open\n\n\
362 [Service]\nType=exec\nExecStart=\"/home/ada/.cargo/bin/qcode\" reaper\n"
363 );
364 assert_eq!(files[1].0, Path::new("/tmp/x/config/systemd/user/qcode-reaper.path"));
365 assert_eq!(
366 files[1].1,
367 "[Unit]\nDescription=Watches the list of containers QCode started\n\n\
368 [Path]\nPathChanged=/home/ada/.local/share/quvyta/code/containers.toml\nUnit=qcode-reaper.service\n\n\
369 [Install]\nWantedBy=default.target\n"
370 );
371 }
372
373 #[test]
374 fn a_path_systemd_would_expand_or_split_is_written_so_it_is_not() {
375 let mut host = linux(Path::new("/tmp/x"));
376 host.program = PathBuf::from("/home/ada/My \"Apps\" 100%/$bin\\qcode");
377 host.registry = PathBuf::from("/home/ada/50% data/containers.toml");
378 let files = host.files();
379 assert!(
380 files[0].1.contains("ExecStart=\"/home/ada/My \\\"Apps\\\" 100%%/$$bin\\\\qcode\" reaper\n"),
381 "{}",
382 files[0].1
383 );
384 assert!(files[1].1.contains("PathChanged=/home/ada/50%% data/containers.toml\n"), "{}", files[1].1);
385 }
386
387 #[test]
388 fn the_launchd_agent_is_exactly_this() {
389 let host = mac(Path::new("/tmp/x"));
390 let files = host.files();
391 assert_eq!(files.len(), 1);
392 assert_eq!(files[0].0, Path::new("/tmp/x/Library/LaunchAgents/io.quvyta.code.reaper.plist"));
393 assert_eq!(
394 files[0].1,
395 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
396 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
397 <plist version=\"1.0\">\n<dict>\n\
398 \t<key>Label</key>\n\t<string>io.quvyta.code.reaper</string>\n\
399 \t<key>ProgramArguments</key>\n\t<array>\n\t\t<string>/opt/homebrew/bin/qcode</string>\n\t\t<string>reaper</string>\n\t</array>\n\
400 \t<key>WatchPaths</key>\n\t<array>\n\t\t<string>/Users/ada/Library/Application Support/quvyta/code/containers.toml</string>\n\t</array>\n\
401 </dict>\n</plist>\n"
402 );
403 let mut odd = host;
404 odd.program = PathBuf::from("/Users/a&b/<qcode>");
405 assert!(odd.plist().contains("<string>/Users/a&b/<qcode></string>"), "{}", odd.plist());
406 }
407
408 #[test]
409 fn installing_on_linux_writes_both_units_then_enables_the_watcher() {
410 let scratch = Scratch::new("linux-install");
411 let host = linux(&scratch.0);
412 let steps = host.install();
413 assert_eq!(
414 commands(&steps),
415 ["systemctl --user daemon-reload", "systemctl --user enable --now qcode-reaper.path"]
416 );
417 assert!(!host.is_installed());
418 let mut ran = Vec::new();
419 perform(&steps, &mut |command| {
420 ran.push(command.written());
421 Ok(())
422 })
423 .expect("everything is written");
424 assert_eq!(ran, commands(&steps), "the commands run after the files are there, in order");
425 assert!(host.is_installed());
426 for (path, text) in host.files() {
427 assert_eq!(std::fs::read_to_string(path).expect("written"), text);
428 }
429 }
430
431 #[test]
432 fn removing_on_linux_stops_first_then_takes_the_files_away() {
433 let scratch = Scratch::new("linux-remove");
434 let host = linux(&scratch.0);
435 perform(&host.install(), &mut |_| Ok(())).expect("installed");
436 let steps = host.uninstall();
437 assert_eq!(
438 commands(&steps),
439 [
440 "systemctl --user disable --now qcode-reaper.path",
441 "systemctl --user stop qcode-reaper.service",
442 "systemctl --user daemon-reload",
443 ]
444 );
445 assert!(matches!(steps.last(), Some(Step::Run(_))), "the reload comes after the files are gone");
446 perform(&steps, &mut |_| Ok(())).expect("removed");
447 assert!(!host.is_installed());
448 assert!(host.files().iter().all(|(path, _)| !path.exists()));
449 perform(&steps, &mut |_| Ok(())).expect("removing what is gone already is not a failure");
450 }
451
452 #[test]
453 fn launchd_is_asked_in_the_persons_own_domain() {
454 let scratch = Scratch::new("mac");
455 let host = mac(&scratch.0);
456 let plist = scratch.0.join("Library/LaunchAgents/io.quvyta.code.reaper.plist");
457 assert_eq!(commands(&host.install()), [format!("launchctl bootstrap gui/501 {}", plist.display())]);
458 assert_eq!(commands(&host.uninstall()), ["launchctl bootout gui/501/io.quvyta.code.reaper"]);
459 perform(&host.install(), &mut |_| Ok(())).expect("installed");
460 assert!(host.is_installed());
461 perform(&host.uninstall(), &mut |_| Ok(())).expect("removed");
462 assert!(!plist.exists());
463 }
464
465 #[test]
466 fn a_command_that_fails_stops_the_rest_with_its_own_words() {
467 let scratch = Scratch::new("fail");
468 let host = linux(&scratch.0);
469 let mut ran = 0;
470 let failed = perform(&host.install(), &mut |command| {
471 ran += 1;
472 Err(format!("{}: Failed to connect to bus: No medium found", command.written()))
473 });
474 assert_eq!(failed, Err("systemctl --user daemon-reload: Failed to connect to bus: No medium found".to_owned()));
475 assert_eq!(ran, 1, "nothing is enabled after a reload that failed");
476 }
477
478 #[cfg(unix)]
479 #[test]
480 fn a_host_command_that_fails_answers_with_what_it_printed() {
481 let failed = run_host(&HostCommand::new("/bin/sh", &["-c", "echo nope >&2; exit 1"]));
482 assert_eq!(failed, Err("/bin/sh -c echo nope >&2; exit 1: nope".to_owned()));
483 assert_eq!(run_host(&HostCommand::new("/bin/sh", &["-c", "true"])), Ok(()));
484 assert!(run_host(&HostCommand::new("/nonexistent/qcode-test-ctl", &[])).is_err());
485 }
486}