layover_core/
autostart.rs1use std::fmt;
30use std::path::{Path, PathBuf};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Platform {
35 Windows,
37 MacOs,
39 Linux,
41}
42
43impl Platform {
44 #[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 #[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 #[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#[derive(Debug, Clone)]
100pub struct Autostart {
101 pub binary: PathBuf,
103 pub config: PathBuf,
105}
106
107impl Autostart {
108 pub const COMMAND: &'static str = "serve";
113
114 #[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 #[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 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 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 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
261fn 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("&"),
267 '<' => out.push_str("<"),
268 '>' => out.push_str(">"),
269 '"' => out.push_str("""),
270 '\'' => out.push_str("'"),
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 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 & B"), "{xml}");
343 assert!(xml.contains("<odd>"), "{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}