Skip to main content

layover_core/
autostart.rs

1//! Starting Layover when the computer starts.
2//!
3//! A lights-out factory that stops at every reboot is not lights-out. This module generates the
4//! platform's own autostart artefact — a Scheduled Task on Windows, a launchd agent on macOS, a
5//! systemd user unit on Linux — rather than inventing a daemon of its own.
6//!
7//! # What it starts
8//!
9//! [`Autostart::COMMAND`] — today, `serve`. Not `run`: the Tower does not exist, and an autostart
10//! entry that invokes a subcommand the binary does not have fails at every logon, reporting
11//! nothing anybody reads. The generated entry therefore starts the thing that *does* exist and is
12//! worth having at login, which is the dashboard.
13//!
14//! When the Tower lands this becomes `run`, and
15//! `layover-cli`'s `the_autostart_command_is_one_the_cli_accepts` is what stops it from becoming
16//! a command that does not.
17//!
18//! Generating rather than installing is deliberate for the part that can be: the text is
19//! inspectable, diffable and testable on any platform, so what gets written is decided by code
20//! that runs everywhere and only the final file write is platform-specific.
21//!
22//! # Why a *user* service, never a system one
23//!
24//! The Tower spawns agent CLIs that use *your* provider credentials, *your* git identity and
25//! *your* workspace. A machine-wide service would run as another user and have none of them, or
26//! worse, run as root with all of them. Every artefact here installs into the logged-in user's
27//! own session.
28
29use std::fmt;
30use std::path::{Path, PathBuf};
31
32/// Which autostart mechanism to generate for.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Platform {
35    /// A Windows Scheduled Task, triggered at logon.
36    Windows,
37    /// A macOS launchd user agent.
38    MacOs,
39    /// A systemd user unit.
40    Linux,
41}
42
43impl Platform {
44    /// Returns the platform this binary was built for.
45    #[must_use]
46    pub fn current() -> Option<Self> {
47        match std::env::consts::OS {
48            "windows" => Some(Self::Windows),
49            "macos" => Some(Self::MacOs),
50            "linux" => Some(Self::Linux),
51            _ => None,
52        }
53    }
54
55    /// The file name the generated artefact is written as.
56    #[must_use]
57    pub fn artefact_name(&self) -> &'static str {
58        match self {
59            Self::Windows => "layover-autostart.xml",
60            Self::MacOs => "dev.layover.tower.plist",
61            Self::Linux => "layover.service",
62        }
63    }
64
65    /// What to run to register the generated artefact.
66    #[must_use]
67    pub fn install_hint(&self, artefact: &Path) -> String {
68        let path = artefact.display();
69        match self {
70            Self::Windows => format!(
71                "schtasks /Create /TN Layover /XML \"{path}\" /F\n\
72                 Remove it later with: schtasks /Delete /TN Layover /F"
73            ),
74            Self::MacOs => format!(
75                "cp \"{path}\" ~/Library/LaunchAgents/\n\
76                 launchctl load -w ~/Library/LaunchAgents/dev.layover.tower.plist\n\
77                 Remove it later with: launchctl unload -w ~/Library/LaunchAgents/dev.layover.tower.plist"
78            ),
79            Self::Linux => format!(
80                "cp \"{path}\" ~/.config/systemd/user/\n\
81                 systemctl --user enable --now layover.service\n\
82                 Remove it later with: systemctl --user disable --now layover.service"
83            ),
84        }
85    }
86}
87
88impl fmt::Display for Platform {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(match self {
91            Self::Windows => "Windows Scheduled Task",
92            Self::MacOs => "launchd user agent",
93            Self::Linux => "systemd user unit",
94        })
95    }
96}
97
98/// What the generated artefact needs to know.
99#[derive(Debug, Clone)]
100pub struct Autostart {
101    /// Absolute path to the `layover` binary.
102    pub binary: PathBuf,
103    /// Absolute path to the factory definition.
104    pub config: PathBuf,
105}
106
107impl Autostart {
108    /// The subcommand every generated artefact invokes.
109    ///
110    /// Exposed so the CLI can assert its own parser accepts it. See the module documentation for
111    /// why this is `serve` rather than `run`.
112    pub const COMMAND: &'static str = "serve";
113
114    /// Describes an autostart entry.
115    #[must_use]
116    pub fn new(binary: impl Into<PathBuf>, config: impl Into<PathBuf>) -> Self {
117        Self {
118            binary: binary.into(),
119            config: config.into(),
120        }
121    }
122
123    /// Renders the artefact for `platform`.
124    #[must_use]
125    pub fn render(&self, platform: Platform) -> String {
126        match platform {
127            Platform::Windows => self.scheduled_task(),
128            Platform::MacOs => self.launch_agent(),
129            Platform::Linux => self.systemd_unit(),
130        }
131    }
132
133    fn binary(&self) -> String {
134        self.binary.display().to_string()
135    }
136
137    fn config(&self) -> String {
138        self.config.display().to_string()
139    }
140
141    /// A Scheduled Task registered at logon.
142    ///
143    /// `RunLevel` is `LeastPrivilege` on purpose: Layover must run as the logged-in user, with
144    /// that user's credentials and git identity, and nothing it does wants administrator rights.
145    fn scheduled_task(&self) -> String {
146        let binary = xml_escape(&self.binary());
147        let config = xml_escape(&self.config());
148        let command = Self::COMMAND;
149        let work_dir = xml_escape(
150            &self
151                .config
152                .parent()
153                .unwrap_or(Path::new("."))
154                .display()
155                .to_string(),
156        );
157
158        format!(
159            r#"<?xml version="1.0" encoding="UTF-16"?>
160<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
161  <RegistrationInfo>
162    <Description>Layover — serves the dashboard for the factory defined in {config}.</Description>
163    <URI>\Layover</URI>
164  </RegistrationInfo>
165  <Triggers>
166    <LogonTrigger>
167      <Enabled>true</Enabled>
168    </LogonTrigger>
169  </Triggers>
170  <Principals>
171    <Principal id="Author">
172      <LogonType>InteractiveToken</LogonType>
173      <RunLevel>LeastPrivilege</RunLevel>
174    </Principal>
175  </Principals>
176  <Settings>
177    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
178    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
179    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
180    <AllowHardTerminate>true</AllowHardTerminate>
181    <StartWhenAvailable>true</StartWhenAvailable>
182    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
183    <RestartOnFailure>
184      <Interval>PT1M</Interval>
185      <Count>3</Count>
186    </RestartOnFailure>
187  </Settings>
188  <Actions Context="Author">
189    <Exec>
190      <Command>{binary}</Command>
191      <Arguments>{command} --config "{config}"</Arguments>
192      <WorkingDirectory>{work_dir}</WorkingDirectory>
193    </Exec>
194  </Actions>
195</Task>
196"#
197        )
198    }
199
200    /// A launchd agent, loaded at login.
201    fn launch_agent(&self) -> String {
202        let binary = xml_escape(&self.binary());
203        let config = xml_escape(&self.config());
204        let command = Self::COMMAND;
205
206        format!(
207            r#"<?xml version="1.0" encoding="UTF-8"?>
208<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
209<plist version="1.0">
210<dict>
211  <key>Label</key>
212  <string>dev.layover.tower</string>
213  <key>ProgramArguments</key>
214  <array>
215    <string>{binary}</string>
216    <string>{command}</string>
217    <string>--config</string>
218    <string>{config}</string>
219  </array>
220  <key>RunAtLoad</key>
221  <true/>
222  <key>KeepAlive</key>
223  <dict>
224    <key>SuccessfulExit</key>
225    <false/>
226  </dict>
227  <key>ProcessType</key>
228  <string>Background</string>
229</dict>
230</plist>
231"#
232        )
233    }
234
235    /// A systemd user unit.
236    ///
237    /// `default.target` rather than `multi-user.target`, because this is a user service: it starts
238    /// with the session that owns the credentials, not with the machine.
239    fn systemd_unit(&self) -> String {
240        format!(
241            "[Unit]\n\
242             Description=Layover — serves the dashboard for the factory defined in {config}\n\
243             After=network-online.target\n\
244             Wants=network-online.target\n\
245             \n\
246             [Service]\n\
247             Type=simple\n\
248             ExecStart={binary} {command} --config {config}\n\
249             Restart=on-failure\n\
250             RestartSec=60\n\
251             \n\
252             [Install]\n\
253             WantedBy=default.target\n",
254            binary = self.binary(),
255            config = self.config(),
256            command = Self::COMMAND
257        )
258    }
259}
260
261/// Escapes text for an XML text node or attribute.
262fn xml_escape(raw: &str) -> String {
263    let mut out = String::with_capacity(raw.len());
264    for ch in raw.chars() {
265        match ch {
266            '&' => out.push_str("&amp;"),
267            '<' => out.push_str("&lt;"),
268            '>' => out.push_str("&gt;"),
269            '"' => out.push_str("&quot;"),
270            '\'' => out.push_str("&apos;"),
271            other => out.push(other),
272        }
273    }
274    out
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn entry() -> Autostart {
282        Autostart::new(
283            r"C:\Users\ada\.cargo\bin\layover.exe",
284            r"C:\Users\ada\factory\layover.toml",
285        )
286    }
287
288    #[test]
289    fn the_scheduled_task_runs_at_logon_without_elevation() {
290        let xml = entry().render(Platform::Windows);
291
292        assert!(xml.contains("<LogonTrigger>"));
293        assert!(
294            xml.contains("<RunLevel>LeastPrivilege</RunLevel>"),
295            "the Tower runs as the user whose credentials it uses, never elevated"
296        );
297        assert!(
298            xml.contains("<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>"),
299            "a factory is long-running; a time limit would kill it"
300        );
301        assert!(
302            xml.contains("<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>"),
303            "a second Tower would fight the first over the same state directory"
304        );
305    }
306
307    #[test]
308    fn the_launch_agent_restarts_only_on_failure() {
309        let plist = entry().render(Platform::MacOs);
310
311        assert!(plist.contains("<string>dev.layover.tower</string>"));
312        assert!(plist.contains("<key>RunAtLoad</key>"));
313        assert!(
314            plist.contains("<key>SuccessfulExit</key>"),
315            "a clean exit is a Ground Stop or a deliberate shutdown, not something to undo"
316        );
317    }
318
319    #[test]
320    fn the_systemd_unit_is_a_user_service() {
321        let unit = Autostart::new("/usr/local/bin/layover", "/home/ada/factory/layover.toml")
322            .render(Platform::Linux);
323
324        assert!(unit.contains(&format!(
325            "ExecStart=/usr/local/bin/layover {} --config",
326            Autostart::COMMAND
327        )));
328        assert!(
329            unit.contains("WantedBy=default.target"),
330            "a user unit starts with the session that owns the credentials"
331        );
332        assert!(unit.contains("Restart=on-failure"));
333    }
334
335    #[test]
336    fn paths_with_xml_significant_characters_are_escaped() {
337        // A directory called `A & B` would otherwise produce a Scheduled Task XML that
338        // `schtasks` refuses to import, with an error naming neither the file nor the reason.
339        let xml = Autostart::new(r"C:\bin\layover.exe", r"C:\A & B\<odd>\layover.toml")
340            .render(Platform::Windows);
341
342        assert!(xml.contains("A &amp; B"), "{xml}");
343        assert!(xml.contains("&lt;odd&gt;"), "{xml}");
344        assert!(!xml.contains("A & B"));
345    }
346
347    #[test]
348    fn every_platform_names_its_own_artefact_and_command() {
349        for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
350            let name = platform.artefact_name();
351            let hint = platform.install_hint(Path::new("/tmp").join(name).as_path());
352
353            assert!(!name.is_empty());
354            assert!(hint.contains(name), "{platform}: {hint}");
355            assert!(
356                hint.contains("Remove it later"),
357                "{platform} must say how to undo it"
358            );
359        }
360    }
361
362    #[test]
363    fn the_config_path_reaches_the_command_line_on_every_platform() {
364        for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
365            let rendered = entry().render(platform);
366            assert!(
367                rendered.contains("layover.toml"),
368                "{platform} lost the config path"
369            );
370        }
371    }
372}