1use std::path::{Path, PathBuf};
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum Supervisor {
33 Launchd,
35 Systemd,
37}
38
39pub const LABEL: &str = "com.choir.node";
42
43impl Supervisor {
44 #[must_use]
50 pub fn detect() -> Option<Supervisor> {
51 match std::env::consts::OS {
52 "macos" => Some(Supervisor::Launchd),
53 "linux" => Some(Supervisor::Systemd),
54 _ => None,
55 }
56 }
57
58 #[must_use]
60 pub fn unit_path(self, home: &Path) -> PathBuf {
61 match self {
62 Supervisor::Launchd => home.join(format!("Library/LaunchAgents/{LABEL}.plist")),
63 Supervisor::Systemd => home.join(".config/systemd/user/choir-node.service"),
64 }
65 }
66
67 #[must_use]
73 pub fn render(self, exe: &Path, state: &Path, port: u16, extra: &[String]) -> String {
74 let log = state.join("node.log");
75 match self {
76 Supervisor::Launchd => {
77 let mut argv = String::new();
78 for arg in self.argv(exe, state, port, extra) {
79 argv.push_str(&format!(" <string>{}</string>\n", xml_escape(&arg)));
80 }
81 format!(
82 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
83 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
84 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
85 <plist version=\"1.0\">\n\
86 <dict>\n \
87 <key>Label</key><string>{LABEL}</string>\n \
88 <key>ProgramArguments</key>\n <array>\n{argv} </array>\n \
89 <key>RunAtLoad</key><true/>\n \
90 <key>KeepAlive</key><true/>\n \
91 <key>StandardOutPath</key><string>{log}</string>\n \
92 <key>StandardErrorPath</key><string>{log}</string>\n\
93 </dict>\n\
94 </plist>\n",
95 log = xml_escape(&log.display().to_string()),
96 )
97 }
98 Supervisor::Systemd => {
99 let argv: Vec<String> = self
100 .argv(exe, state, port, extra)
101 .into_iter()
102 .map(|a| shell_quote(&a))
103 .collect();
104 format!(
105 "[Unit]\n\
106 Description=choir node\n\
107 After=network.target\n\
108 \n\
109 [Service]\n\
110 ExecStart={exec}\n\
111 Restart=always\n\
112 RestartSec=2\n\
113 StandardOutput=append:{log}\n\
114 StandardError=append:{log}\n\
115 \n\
116 [Install]\n\
117 WantedBy=default.target\n",
118 exec = argv.join(" "),
119 log = log.display(),
120 )
121 }
122 }
123 }
124
125 #[must_use]
130 pub fn argv(self, exe: &Path, state: &Path, port: u16, extra: &[String]) -> Vec<String> {
131 let mut argv = vec![
132 exe.display().to_string(),
133 "node".to_string(),
134 "serve".to_string(),
135 "--state".to_string(),
136 state.display().to_string(),
137 "--port".to_string(),
138 port.to_string(),
139 ];
140 if !extra.is_empty() {
141 argv.push("--".to_string());
142 argv.extend_from_slice(extra);
143 }
144 argv
145 }
146
147 #[must_use]
153 pub fn commands(self, action: Action, home: &Path) -> Vec<Vec<String>> {
154 let unit = self.unit_path(home).display().to_string();
155 let uid = users_id();
156 match (self, action) {
157 (Supervisor::Launchd, Action::Install) => vec![
162 vec![
163 "launchctl".into(),
164 "bootout".into(),
165 format!("gui/{uid}/{LABEL}"),
166 ],
167 vec![
168 "launchctl".into(),
169 "bootstrap".into(),
170 format!("gui/{uid}"),
171 unit,
172 ],
173 ],
174 (Supervisor::Launchd, Action::Stop) => vec![vec![
175 "launchctl".into(),
176 "bootout".into(),
177 format!("gui/{uid}/{LABEL}"),
178 ]],
179 (Supervisor::Launchd, Action::Uninstall) => vec![vec![
180 "launchctl".into(),
181 "bootout".into(),
182 format!("gui/{uid}/{LABEL}"),
183 ]],
184 (Supervisor::Systemd, Action::Install) => vec![
185 vec!["systemctl".into(), "--user".into(), "daemon-reload".into()],
186 vec![
187 "systemctl".into(),
188 "--user".into(),
189 "enable".into(),
190 "--now".into(),
191 "choir-node.service".into(),
192 ],
193 ],
194 (Supervisor::Systemd, Action::Stop) => vec![vec![
195 "systemctl".into(),
196 "--user".into(),
197 "stop".into(),
198 "choir-node.service".into(),
199 ]],
200 (Supervisor::Systemd, Action::Uninstall) => vec![vec![
201 "systemctl".into(),
202 "--user".into(),
203 "disable".into(),
204 "--now".into(),
205 "choir-node.service".into(),
206 ]],
207 }
208 }
209}
210
211#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub enum Action {
214 Install,
216 Stop,
218 Uninstall,
220}
221
222#[must_use]
231pub fn in_build_directory(exe: &Path) -> bool {
232 let Some(parent) = exe.parent().and_then(Path::file_name) else {
233 return false;
234 };
235 parent == "debug" || parent == "release"
236}
237
238fn users_id() -> String {
240 std::process::Command::new("id")
244 .arg("-u")
245 .output()
246 .ok()
247 .filter(|out| out.status.success())
248 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
249 .unwrap_or_default()
250}
251
252fn xml_escape(text: &str) -> String {
258 let mut out = String::with_capacity(text.len());
259 for c in text.chars() {
260 match c {
261 '&' => out.push_str("&"),
262 '<' => out.push_str("<"),
263 '>' => out.push_str(">"),
264 '"' => out.push_str("""),
265 '\'' => out.push_str("'"),
266 _ => out.push(c),
267 }
268 }
269 out
270}
271
272fn shell_quote(word: &str) -> String {
278 if !word.is_empty()
279 && word
280 .chars()
281 .all(|c| c.is_ascii_alphanumeric() || "-_./:=".contains(c))
282 {
283 return word.to_string();
284 }
285 format!("\"{}\"", word.replace('\\', "\\\\").replace('"', "\\\""))
286}
287
288pub const BACKUP_LABEL: &str = "com.choir.backup";
292
293impl Supervisor {
294 #[must_use]
296 pub fn backup_argv(self, exe: &Path, state: &Path, dest: &Path) -> Vec<String> {
297 vec![
298 exe.display().to_string(),
299 "backup".to_string(),
300 "take".to_string(),
301 dest.display().to_string(),
302 "--state".to_string(),
303 state.display().to_string(),
304 ]
305 }
306
307 #[must_use]
310 pub fn backup_units(self, home: &Path) -> Vec<PathBuf> {
311 match self {
312 Supervisor::Launchd => {
313 vec![home.join(format!("Library/LaunchAgents/{BACKUP_LABEL}.plist"))]
314 }
315 Supervisor::Systemd => vec![
316 home.join(".config/systemd/user/choir-backup.service"),
317 home.join(".config/systemd/user/choir-backup.timer"),
318 ],
319 }
320 }
321
322 #[must_use]
327 pub fn render_backup(
328 self,
329 exe: &Path,
330 state: &Path,
331 dest: &Path,
332 every_secs: u64,
333 ) -> Vec<String> {
334 let log = state.join("backup.log");
335 let argv = self.backup_argv(exe, state, dest);
336 match self {
337 Supervisor::Launchd => {
338 let mut array = String::new();
339 for arg in &argv {
340 array.push_str(&format!(" <string>{}</string>\n", xml_escape(arg)));
341 }
342 vec![format!(
343 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
344 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
345 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
346 <plist version=\"1.0\">\n\
347 <dict>\n \
348 <key>Label</key><string>{BACKUP_LABEL}</string>\n \
349 <key>ProgramArguments</key>\n <array>\n{array} </array>\n \
350 <key>StartInterval</key><integer>{every_secs}</integer>\n \
351 <key>StandardOutPath</key><string>{log}</string>\n \
352 <key>StandardErrorPath</key><string>{log}</string>\n\
353 </dict>\n\
354 </plist>\n",
355 log = xml_escape(&log.display().to_string()),
356 )]
357 }
358 Supervisor::Systemd => {
359 let exec: Vec<String> = argv.iter().map(|a| shell_quote(a)).collect();
360 vec![
361 format!(
362 "[Unit]\n\
363 Description=choir backup\n\
364 \n\
365 [Service]\n\
366 Type=oneshot\n\
367 ExecStart={exec}\n\
368 StandardOutput=append:{log}\n\
369 StandardError=append:{log}\n",
370 exec = exec.join(" "),
371 log = log.display(),
372 ),
373 format!(
374 "[Unit]\n\
375 Description=choir backup every {every_secs}s\n\
376 \n\
377 [Timer]\n\
378 OnBootSec=60\n\
379 OnUnitActiveSec={every_secs}\n\
380 Unit=choir-backup.service\n\
381 \n\
382 [Install]\n\
383 WantedBy=timers.target\n"
384 ),
385 ]
386 }
387 }
388 }
389
390 #[must_use]
394 pub fn backup_commands(self, action: Action, home: &Path) -> Vec<Vec<String>> {
395 let uid = users_id();
396 match (self, action) {
397 (Supervisor::Launchd, Action::Install) => vec![
398 vec![
399 "launchctl".into(),
400 "bootout".into(),
401 format!("gui/{uid}/{BACKUP_LABEL}"),
402 ],
403 vec![
404 "launchctl".into(),
405 "bootstrap".into(),
406 format!("gui/{uid}"),
407 self.backup_units(home)[0].display().to_string(),
408 ],
409 ],
410 (Supervisor::Launchd, Action::Stop | Action::Uninstall) => vec![vec![
411 "launchctl".into(),
412 "bootout".into(),
413 format!("gui/{uid}/{BACKUP_LABEL}"),
414 ]],
415 (Supervisor::Systemd, Action::Install) => vec![
416 vec!["systemctl".into(), "--user".into(), "daemon-reload".into()],
417 vec![
418 "systemctl".into(),
419 "--user".into(),
420 "enable".into(),
421 "--now".into(),
422 "choir-backup.timer".into(),
423 ],
424 ],
425 (Supervisor::Systemd, Action::Stop | Action::Uninstall) => vec![vec![
426 "systemctl".into(),
427 "--user".into(),
428 "disable".into(),
429 "--now".into(),
430 "choir-backup.timer".into(),
431 ]],
432 }
433 }
434}