Skip to main content

qcode/service/
units.rs

1//! The files that install the background service, and the steps that put them in place or take
2//! them away.
3//!
4//! Linux gets two systemd user units: `qcode-reaper.path` watches the list of QCode's containers
5//! and starts `qcode-reaper.service` when QCode writes to it, and the service runs `qcode reaper`
6//! once and exits. macOS gets one launchd job that does the same with `WatchPaths`. Windows gets
7//! nothing, because what the service waits on is a lock Windows does not give QCode.
8//!
9//! Every file is text made by a pure function of a [`ServiceHost`], and installing is a list of
10//! [`Step`]s — files to write, files to remove, commands to run — so the whole of it is read back
11//! in tests against a temporary folder, with the commands collected rather than run. Only
12//! [`perform`] with [`run_host`] touches the machine, and only the settings screen's button calls
13//! it.
14
15use 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
25/// The unit that runs the reaper once.
26pub const SERVICE_UNIT: &str = "qcode-reaper.service";
27
28/// The unit that watches the list and starts [`SERVICE_UNIT`].
29pub const PATH_UNIT: &str = "qcode-reaper.path";
30
31/// The launchd label of the job on macOS.
32pub const LAUNCHD_LABEL: &str = "io.quvyta.code.reaper";
33
34/// The word `qcode` is started with to run the reaper.
35pub const REAPER_ARG: &str = "reaper";
36
37/// Everything about the machine the service files are made for.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ServiceHost {
40    /// Which platform's service manager to write for: Linux or macOS.
41    pub platform: Platform,
42    /// The folder the unit files go into: systemd's user unit folder on Linux, the person's
43    /// `LaunchAgents` on macOS.
44    pub units: PathBuf,
45    /// The list of QCode's containers, which is what the service watches.
46    pub registry: PathBuf,
47    /// The `qcode` binary the service runs.
48    pub program: PathBuf,
49    /// The person's user id, which names launchd's per-user domain on macOS.
50    pub uid: Option<u32>,
51}
52
53/// One thing installing or removing the service does, in order.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Step {
56    /// Write this text to this file, making its folder first.
57    Write(PathBuf, String),
58    /// Remove this file; one that is not there is already what was wanted.
59    Remove(PathBuf),
60    /// Run this command.
61    Run(HostCommand),
62}
63
64/// A program and its arguments, run on the machine itself: `systemctl` or `launchctl`.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct HostCommand {
67    /// The program, looked up on the `PATH`.
68    pub program: String,
69    /// Its arguments.
70    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    /// The command written out the way it would be typed.
79    #[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    /// The machine this program runs on, or `None` where no service is installed: on Windows,
87    /// and on a machine that names no home to keep the files in.
88    #[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    /// The files the service is made of, with their text.
107    #[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    /// Whether the service is installed, which is whether its files are there. Reads the disk.
119    #[must_use]
120    pub fn is_installed(&self) -> bool {
121        self.files().iter().all(|(path, _)| path.is_file())
122    }
123
124    /// The steps that install the service and start watching.
125    #[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    /// The steps that stop the service and take its files away.
143    #[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                // A reaper that is waiting for the last QCode goes with the watcher.
154                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    /// launchd's domain of the person's own graphical session.
165    fn domain(&self) -> String {
166        match self.uid {
167            Some(uid) => format!("gui/{uid}"),
168            // Without an id there is no domain to name; launchctl then says so in its own words.
169            None => "gui".to_owned(),
170        }
171    }
172
173    /// `qcode-reaper.service`: runs the reaper once, and is done when it exits.
174    ///
175    /// `Type=exec` rather than `oneshot`: the reaper may wait hours for the last QCode to close,
176    /// and a oneshot unit would be killed by its start timeout long before that.
177    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    /// `qcode-reaper.path`: starts the service whenever QCode writes to the list.
188    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    /// The launchd job: runs the reaper whenever the list changes.
199    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
224/// Text systemd reads literally where it expands specifiers: `%` introduces one.
225fn systemd_escaped(text: &str) -> String {
226    text.replace('%', "%%")
227}
228
229/// A path as one word of an `ExecStart=` line: quoted, so a folder with a space in it stays one
230/// argument, with the quote and the backslash escaped and `%` and `$` kept from being expanded.
231fn 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
246/// Text inside an XML element.
247fn xml_escaped(text: &str) -> String {
248    text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
249}
250
251/// Carries out `steps` in order, stopping at the first that fails, and answers why it failed in
252/// words the person can read: the command's own when a command failed.
253///
254/// `run` runs one command; [`run_host`] is the real one, and a test passes its own.
255///
256/// # Errors
257///
258/// The words of the first step that failed.
259pub 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
274/// Writes `text` to `path`, making its folder first.
275fn 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
282/// Runs `command` on this machine and waits for it.
283///
284/// This changes the person's service manager, so it runs only when the person presses the
285/// button that asks for it, and on a background thread.
286///
287/// # Errors
288///
289/// What the command printed when it failed, or why it could not be started.
290pub 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    /// A folder of this test's own, removed when the test ends.
328    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&amp;b/&lt;qcode&gt;</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}