1use crate::context::Context;
22use crate::error::{Error, Result};
23use std::path::PathBuf;
24
25#[cfg(target_os = "macos")]
28const LABEL: &str = "com.datlechin.pitboard.renew";
29
30const EVERY_SECONDS: u32 = 86_400;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Installed {
38 Yes {
40 path: PathBuf,
41 every_seconds: u32,
42 },
43 No,
44 Unsupported,
46}
47
48#[cfg(target_os = "macos")]
49fn agent_path(ctx: &Context) -> PathBuf {
50 ctx.home()
51 .join("Library/LaunchAgents")
52 .join(format!("{LABEL}.plist"))
53}
54
55#[cfg(target_os = "linux")]
56fn unit_dir(ctx: &Context) -> PathBuf {
57 ctx.home().join(".config/systemd/user")
58}
59
60pub fn path(ctx: &Context) -> Option<PathBuf> {
62 #[cfg(target_os = "macos")]
63 {
64 Some(agent_path(ctx))
65 }
66 #[cfg(target_os = "linux")]
67 {
68 Some(unit_dir(ctx).join("pitboard-renew.timer"))
69 }
70 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
71 {
72 let _ = ctx;
73 None
74 }
75}
76
77pub fn status(ctx: &Context) -> Installed {
78 match path(ctx) {
79 None => Installed::Unsupported,
80 Some(path) if path.is_file() => Installed::Yes {
81 path,
82 every_seconds: EVERY_SECONDS,
83 },
84 Some(_) => Installed::No,
85 }
86}
87
88pub(crate) fn serves(ctx: &Context) -> bool {
94 crate::home::dir(ctx) == ctx.home().join(".pitboard")
95}
96
97fn program(ctx: &Context) -> Result<PathBuf> {
99 if let Some(program) = ctx.schedule_program() {
100 return lasting(program).map(std::path::Path::to_path_buf);
101 }
102 let running = std::env::current_exe().map_err(|source| Error::HomeUnwritable {
103 path: PathBuf::from("the running pitboard"),
104 source,
105 })?;
106 #[cfg(target_os = "linux")]
107 let running = started_as(
108 std::env::args_os().next().as_deref(),
109 &std::env::var_os("PATH").unwrap_or_default(),
110 running,
111 );
112 Ok(running)
113}
114
115#[cfg(any(target_os = "linux", test))]
124fn started_as(
125 argv0: Option<&std::ffi::OsStr>,
126 search: &std::ffi::OsStr,
127 running: PathBuf,
128) -> PathBuf {
129 let resolved = std::fs::canonicalize(&running).ok();
130 argv0
131 .and_then(|named| crate::provider::find_program(std::path::Path::new(named), search))
132 .filter(|found| resolved.is_some() && std::fs::canonicalize(found).ok() == resolved)
133 .unwrap_or(running)
134}
135
136fn lasting(program: &std::path::Path) -> Result<&std::path::Path> {
143 if program
144 .components()
145 .any(|part| part.as_os_str() == "AppTranslocation")
146 {
147 return Err(Error::ScheduleProgramTemporary {
148 path: program.to_path_buf(),
149 });
150 }
151 if !program.is_file() {
152 return Err(Error::ScheduleProgramMissing {
153 path: program.to_path_buf(),
154 });
155 }
156 Ok(program)
157}
158
159pub fn installed_program(ctx: &Context) -> Option<PathBuf> {
163 #[cfg(target_os = "macos")]
164 {
165 let body = std::fs::read_to_string(agent_path(ctx)).ok()?;
166 let (_, after) = body.split_once("<key>ProgramArguments</key>")?;
167 let (_, after) = after.split_once("<string>")?;
168 let (program, _) = after.split_once("</string>")?;
169 Some(PathBuf::from(unescape(program)))
170 }
171 #[cfg(target_os = "linux")]
172 {
173 if !unit_dir(ctx).join("pitboard-renew.timer").is_file() {
175 return None;
176 }
177 let body = std::fs::read_to_string(unit_dir(ctx).join("pitboard-renew.service")).ok()?;
178 body.lines()
179 .find_map(|line| line.strip_prefix("ExecStart=")?.strip_suffix(" renew"))
180 .map(PathBuf::from)
181 }
182 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
183 {
184 let _ = ctx;
185 None
186 }
187}
188
189fn write(path: &std::path::Path, body: &str) -> Result<()> {
190 if let Some(parent) = path.parent() {
191 std::fs::create_dir_all(parent).map_err(|source| Error::HomeUnwritable {
192 path: parent.to_path_buf(),
193 source,
194 })?;
195 }
196 crate::atomic::write(path, body.as_bytes(), crate::atomic::Perms::MatchExisting).map_err(
197 |source| Error::HomeUnwritable {
198 path: path.to_path_buf(),
199 source,
200 },
201 )
202}
203
204pub fn install(ctx: &Context) -> Result<PathBuf> {
206 let path = put(ctx, &program(ctx)?)?;
207 crate::audit::record(ctx, "schedule", "install", "ok");
208 Ok(path)
209}
210
211pub fn repair(ctx: &Context) -> Result<bool> {
224 #[cfg(target_os = "macos")]
225 if ctx.launchd_job().as_deref() == Some(LABEL) {
226 return Ok(false);
227 }
228 let Some(named) = ctx.schedule_program() else {
229 return Ok(false);
230 };
231 if !serves(ctx)
232 || lasting(named).is_err()
233 || !installed_program(ctx).is_some_and(|program| an_apps_own_program(&program))
234 {
235 return Ok(false);
236 }
237 let repaired = put(ctx, named);
238 crate::audit::record(
239 ctx,
240 "schedule",
241 "repair",
242 match &repaired {
243 Ok(_) => "ok",
244 Err(e) => e.code(),
245 },
246 );
247 repaired.map(|_| true)
248}
249
250pub(crate) fn an_apps_own_program(program: &std::path::Path) -> bool {
253 let mut dirs = program
254 .ancestors()
255 .skip(1)
256 .map(|dir| dir.file_name().and_then(|n| n.to_str()));
257 dirs.next() == Some(Some("MacOS")) && dirs.next() == Some(Some("Contents"))
258}
259
260fn put(ctx: &Context, program: &std::path::Path) -> Result<PathBuf> {
267 let Some(path) = path(ctx) else {
268 return Err(Error::ScheduleUnsupported);
269 };
270
271 #[cfg(target_os = "macos")]
272 {
273 let before = std::fs::read_to_string(&path).ok();
274 write(&path, &plist(program, ctx.argv_fallback()))?;
275 let uid = unsafe { libc::getuid() };
278 let domain = format!("gui/{uid}");
279 let target = path.to_string_lossy();
280 let _ = run("/bin/launchctl", &["bootout", &domain, &target]);
281 if let Err(refused) = run("/bin/launchctl", &["bootstrap", &domain, &target]) {
282 restore(&path, before.as_deref());
283 if before.is_some() {
284 let _ = run("/bin/launchctl", &["bootstrap", &domain, &target]);
285 }
286 return Err(refused);
287 }
288 }
289
290 #[cfg(target_os = "linux")]
291 {
292 let unit = unit_dir(ctx).join("pitboard-renew.service");
293 let before = (
294 std::fs::read_to_string(&unit).ok(),
295 std::fs::read_to_string(&path).ok(),
296 );
297 write(&unit, &service(program, ctx.argv_fallback()))?;
298 write(&path, &timer())?;
299 let _ = run("systemctl", &["--user", "daemon-reload"]);
300 let start = ["--user", "enable", "--now", "pitboard-renew.timer"];
301 if let Err(refused) = run("systemctl", &start) {
302 restore(&unit, before.0.as_deref());
303 restore(&path, before.1.as_deref());
304 let _ = run("systemctl", &["--user", "daemon-reload"]);
305 if before.1.is_some() {
306 let _ = run("systemctl", &start);
307 }
308 return Err(refused);
309 }
310 }
311
312 Ok(path)
313}
314
315#[cfg(any(target_os = "macos", target_os = "linux"))]
318fn restore(path: &std::path::Path, before: Option<&str>) {
319 let _ = match before {
320 Some(body) => write(path, body),
321 None => remove(path),
322 };
323}
324
325pub fn uninstall(ctx: &Context) -> Result<bool> {
327 let Some(path) = path(ctx) else {
328 return Err(Error::ScheduleUnsupported);
329 };
330 if !path.is_file() {
331 return Ok(false);
332 }
333
334 #[cfg(target_os = "macos")]
335 {
336 let uid = unsafe { libc::getuid() };
338 let domain = format!("gui/{uid}");
339 let _ = run(
340 "/bin/launchctl",
341 &["bootout", &domain, &path.to_string_lossy()],
342 );
343 remove(&path)?;
344 }
345
346 #[cfg(target_os = "linux")]
347 {
348 let _ = run(
349 "systemctl",
350 &["--user", "disable", "--now", "pitboard-renew.timer"],
351 );
352 remove(&path)?;
353 remove(&unit_dir(ctx).join("pitboard-renew.service"))?;
354 let _ = run("systemctl", &["--user", "daemon-reload"]);
355 }
356
357 crate::audit::record(ctx, "schedule", "uninstall", "ok");
358 Ok(true)
359}
360
361#[cfg(any(target_os = "macos", target_os = "linux"))]
362fn remove(path: &std::path::Path) -> Result<()> {
363 match std::fs::remove_file(path) {
364 Ok(()) => Ok(()),
365 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
366 Err(source) => Err(Error::HomeUnwritable {
367 path: path.to_path_buf(),
368 source,
369 }),
370 }
371}
372
373#[cfg(all(any(target_os = "macos", target_os = "linux"), test))]
380fn run(program: &str, args: &[&str]) -> Result<()> {
381 let refused = REFUSED.with(|refused| {
382 let mut refused = refused.borrow_mut();
383 refused
384 .filter(|verb| args.contains(verb))
385 .inspect(|_| *refused = None)
386 });
387 match refused {
388 Some(verb) => Err(Error::ScheduleRefused {
389 detail: format!("{program} refused {verb} in a test"),
390 }),
391 None => Ok(()),
392 }
393}
394
395#[cfg(all(any(target_os = "macos", target_os = "linux"), test))]
396thread_local! {
397 static REFUSED: std::cell::RefCell<Option<&'static str>> =
399 const { std::cell::RefCell::new(None) };
400}
401
402#[cfg(all(any(target_os = "macos", target_os = "linux"), not(test)))]
403fn run(program: &str, args: &[&str]) -> Result<()> {
404 let out = std::process::Command::new(program)
405 .args(args)
406 .output()
407 .map_err(|source| Error::HomeUnwritable {
408 path: PathBuf::from(program),
409 source,
410 })?;
411 if out.status.success() {
412 return Ok(());
413 }
414 Err(Error::ScheduleRefused {
415 detail: format!(
416 "{program} exited {}: {}",
417 out.status
418 .code()
419 .map_or_else(|| "on a signal".into(), |c| c.to_string()),
420 String::from_utf8_lossy(&out.stderr).trim()
421 ),
422 })
423}
424
425#[cfg(target_os = "macos")]
432fn plist(program: &std::path::Path, argument_line: bool) -> String {
433 let environment = if argument_line {
434 ""
435 } else {
436 " <key>EnvironmentVariables</key>\n <dict>\n <key>PITBOARD_NO_ARGV</key><string>1</string>\n </dict>\n"
437 };
438 format!(
439 r#"<?xml version="1.0" encoding="UTF-8"?>
440<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
441<plist version="1.0">
442<dict>
443 <key>Label</key><string>{LABEL}</string>
444 <key>ProgramArguments</key>
445 <array>
446 <string>{}</string>
447 <string>renew</string>
448 </array>
449 <key>StartInterval</key><integer>{EVERY_SECONDS}</integer>
450 <key>RunAtLoad</key><false/>
451 <key>LowPriorityIO</key><true/>
452 <key>ProcessType</key><string>Background</string>
453{environment}</dict>
454</plist>
455"#,
456 escape(&program.to_string_lossy())
457 )
458}
459
460#[cfg(target_os = "macos")]
463fn escape(text: &str) -> String {
464 text.replace('&', "&")
465 .replace('<', "<")
466 .replace('>', ">")
467}
468
469#[cfg(target_os = "macos")]
471fn unescape(text: &str) -> String {
472 text.replace("<", "<")
473 .replace(">", ">")
474 .replace("&", "&")
475}
476
477#[cfg(target_os = "linux")]
480fn service(program: &std::path::Path, argument_line: bool) -> String {
481 format!(
482 "[Unit]\n\
483 Description=Renew pitboard's parked logins\n\
484 Documentation=https://docs.usepitboard.com\n\
485 \n\
486 [Service]\n\
487 Type=oneshot\n\
488 {}\
489 ExecStart={} renew\n",
490 if argument_line {
491 ""
492 } else {
493 "Environment=PITBOARD_NO_ARGV=1\n"
494 },
495 program.display()
496 )
497}
498
499#[cfg(target_os = "linux")]
500fn timer() -> String {
501 format!(
502 "[Unit]\n\
503 Description=Renew pitboard's parked logins daily\n\
504 \n\
505 [Timer]\n\
506 OnUnitActiveSec={EVERY_SECONDS}\n\
507 OnStartupSec=900\n\
508 Persistent=true\n\
509 \n\
510 [Install]\n\
511 WantedBy=timers.target\n"
512 )
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 struct Scratch(PathBuf);
521
522 impl Scratch {
523 fn new(name: &str) -> Scratch {
524 let root = std::env::temp_dir().join(format!(
525 "pitboard-schedule-{name}-{}-{:?}",
526 std::process::id(),
527 std::thread::current().id()
528 ));
529 let _ = std::fs::remove_dir_all(&root);
530 std::fs::create_dir_all(&root).expect("a scratch home");
531 Scratch(root)
532 }
533 }
534
535 impl Drop for Scratch {
536 fn drop(&mut self) {
537 let _ = std::fs::remove_dir_all(&self.0);
538 }
539 }
540
541 fn a_program_at(path: &std::path::Path) {
543 std::fs::create_dir_all(path.parent().expect("a directory")).expect("its directory");
544 std::fs::write(path, "").expect("a program");
545 }
546
547 #[cfg(target_os = "macos")]
549 const START: &str = "bootstrap";
550 #[cfg(target_os = "linux")]
551 const START: &str = "enable";
552
553 #[cfg(any(target_os = "macos", target_os = "linux"))]
555 fn refusing<T>(verb: &'static str, change: impl FnOnce() -> T) -> T {
556 REFUSED.with(|refused| *refused.borrow_mut() = Some(verb));
557 let outcome = change();
558 REFUSED.with(|refused| *refused.borrow_mut() = None);
559 outcome
560 }
561
562 #[cfg(any(target_os = "macos", target_os = "linux"))]
564 #[test]
565 fn a_schedule_the_scheduler_will_not_start_is_not_left_on_disk() {
566 let home = Scratch::new("refused");
567 let program = home.0.join("bin/pitboard");
568 a_program_at(&program);
569 let ctx = Context::new(home.0.clone()).with_schedule_program(program);
570
571 let refused = refusing(START, || install(&ctx)).expect_err("refused");
572
573 assert_eq!(refused.code(), "schedule_refused");
574 assert_eq!(status(&ctx), Installed::No);
575 assert_eq!(installed_program(&ctx), None);
576 }
577
578 #[cfg(any(target_os = "macos", target_os = "linux"))]
581 #[test]
582 fn a_repair_the_scheduler_will_not_start_leaves_the_schedule_as_it_was() {
583 let home = Scratch::new("repair-refused");
584 std::fs::create_dir_all(home.0.join(".pitboard")).expect("a pitboard home");
585 let app = home
586 .0
587 .join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
588 let bundled = home
589 .0
590 .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
591 a_program_at(&app);
592 a_program_at(&bundled);
593 let ctx = Context::new(home.0.clone());
594 install(&ctx.clone().with_schedule_program(app.clone())).expect("0.3.0's schedule");
595
596 let refused = refusing(START, || {
597 repair(&ctx.clone().with_schedule_program(bundled))
598 })
599 .expect_err("refused");
600
601 assert_eq!(refused.code(), "schedule_refused");
602 assert!(matches!(status(&ctx), Installed::Yes { .. }));
603 assert_eq!(installed_program(&ctx), Some(app));
604 }
605
606 #[test]
607 fn nothing_is_installed_on_a_machine_where_nothing_was_installed() {
608 let home = Scratch::new("none");
609 let ctx = Context::new(home.0.clone());
610 assert!(matches!(
611 status(&ctx),
612 Installed::No | Installed::Unsupported
613 ));
614 assert_eq!(installed_program(&ctx), None);
615 }
616
617 #[test]
618 fn the_schedule_runs_the_pitboard_the_context_names_and_otherwise_this_one() {
619 let home = Scratch::new("program");
620 let ctx = Context::new(home.0.clone());
621 let scheduled = program(&ctx).expect("this program");
622 let running = std::env::current_exe().expect("this test's own program");
623 assert_eq!(
624 std::fs::canonicalize(&scheduled).expect("there"),
625 std::fs::canonicalize(&running).expect("there"),
626 "the command line schedules itself"
627 );
628 #[cfg(target_os = "macos")]
629 assert_eq!(
630 scheduled, running,
631 "by the path macOS says it was started by"
632 );
633 let bundled = home
634 .0
635 .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
636 a_program_at(&bundled);
637 assert_eq!(
638 program(&ctx.with_schedule_program(bundled.clone())).expect("the named one"),
639 bundled,
640 "an app schedules the command line it comes with"
641 );
642 }
643
644 #[test]
649 fn the_command_line_schedules_itself_by_the_path_it_was_started_by() {
650 use std::os::unix::fs::PermissionsExt;
651 let home = Scratch::new("started");
652 let runnable = |path: &std::path::Path| {
653 a_program_at(path);
654 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
655 .expect("runnable");
656 };
657 let running = home.0.join("Caskroom/pitboard/0.4.0/pitboard");
658 runnable(&running);
659 let bin = home.0.join("bin");
660 std::fs::create_dir_all(&bin).expect("a bin");
661 let link = bin.join("pitboard");
662 std::os::unix::fs::symlink(&running, &link).expect("a link");
663 let another = home.0.join("elsewhere/pitboard");
664 runnable(&another);
665
666 let started = |argv0: Option<&std::path::Path>, search: &std::path::Path| {
667 started_as(
668 argv0.map(std::path::Path::as_os_str),
669 search.as_os_str(),
670 running.clone(),
671 )
672 };
673 let name = std::path::Path::new("pitboard");
674 let nowhere = std::path::Path::new("");
675 assert_eq!(started(Some(name), &bin), link, "a name found on PATH");
676 assert_eq!(started(Some(&link), nowhere), link, "a path");
677 assert_eq!(
678 started(Some(name), another.parent().expect("its directory")),
679 running,
680 "another pitboard on PATH is not the one that is running"
681 );
682 assert_eq!(
683 started(Some(name), nowhere),
684 running,
685 "a name found nowhere"
686 );
687 assert_eq!(started(None, &bin), running, "no name at all");
688 }
689
690 #[test]
695 fn a_named_pitboard_that_will_not_be_there_is_refused() {
696 let home = Scratch::new("refused");
697 let ctx = Context::new(home.0.clone());
698
699 let missing = home.0.join("Pitboard.app/Contents/Helpers/pitboard");
700 let refused = install(&ctx.clone().with_schedule_program(missing.clone()))
701 .expect_err("nothing there to run");
702 assert_eq!(refused.code(), "schedule_program_missing");
703 assert!(
704 refused.to_string().contains(&missing.display().to_string()),
705 "{refused}"
706 );
707
708 let temporary = home
709 .0
710 .join("AppTranslocation/6A1C/d/Pitboard.app/Contents/Helpers/pitboard");
711 a_program_at(&temporary);
712 let refused = install(&ctx.clone().with_schedule_program(temporary.clone()))
713 .expect_err("a copy that goes away");
714 assert_eq!(refused.code(), "schedule_program_temporary");
715 assert!(
716 refused
717 .to_string()
718 .contains(&temporary.display().to_string())
719 && refused.to_string().contains("Applications folder"),
720 "{refused}"
721 );
722
723 assert!(
724 matches!(status(&ctx), Installed::No | Installed::Unsupported),
725 "nothing was written"
726 );
727 }
728
729 #[cfg(any(target_os = "macos", target_os = "linux"))]
733 #[test]
734 fn an_installed_schedule_says_which_pitboard_it_runs() {
735 let home = Scratch::new("installed");
736 let bundled = home
737 .0
738 .join("Tools&Apps/Pitboard.app/Contents/Helpers/pitboard");
739 a_program_at(&bundled);
740 let ctx = Context::new(home.0.clone()).with_schedule_program(bundled.clone());
741
742 install(&ctx).expect("installed");
743 assert!(matches!(status(&ctx), Installed::Yes { .. }));
744 #[cfg(target_os = "macos")]
745 {
746 let written = std::fs::read_to_string(agent_path(&ctx)).expect("the agent");
747 assert!(written.contains("/Tools&Apps/"), "{written}");
748 assert!(!written.contains("/Tools&Apps/"), "{written}");
749 }
750 assert_eq!(installed_program(&ctx), Some(bundled));
751
752 assert!(uninstall(&ctx).expect("taken away"));
753 assert_eq!(status(&ctx), Installed::No);
754 assert_eq!(installed_program(&ctx), None);
755 }
756
757 #[cfg(any(target_os = "macos", target_os = "linux"))]
761 #[test]
762 fn a_schedule_that_runs_an_app_is_pointed_at_its_command_line() {
763 let home = Scratch::new("repair");
764 std::fs::create_dir_all(home.0.join(".pitboard")).expect("a pitboard home");
765 let app = home
766 .0
767 .join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
768 let bundled = home
769 .0
770 .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
771 a_program_at(&app);
772 a_program_at(&bundled);
773 let ctx = Context::new(home.0.clone());
774 let the_app = ctx.clone().with_schedule_program(bundled.clone());
775
776 assert!(
777 !repair(&the_app).expect("nothing to do"),
778 "nothing installed"
779 );
780
781 install(&ctx.clone().with_schedule_program(app.clone())).expect("0.3.0's schedule");
782 assert!(
783 !repair(&ctx).expect("nothing to do"),
784 "no command line named"
785 );
786 let gone = home.0.join("Old.app/Contents/Helpers/pitboard");
787 assert!(
788 !repair(&ctx.clone().with_schedule_program(gone)).expect("nothing to do"),
789 "a command line that is not there"
790 );
791 assert!(
792 !repair(&the_app.clone().with_pitboard_home(home.0.join("elsewhere")))
793 .expect("nothing to do"),
794 "a schedule another home's pitboard looks after"
795 );
796 assert_eq!(installed_program(&ctx), Some(app.clone()));
797
798 assert!(repair(&the_app).expect("repaired"));
799 assert_eq!(installed_program(&ctx), Some(bundled));
800 let logged = crate::audit::read(&ctx, 1);
801 assert_eq!(
802 logged
803 .iter()
804 .map(|e| (e.verb.as_str(), e.subject.as_str(), e.outcome.as_str()))
805 .collect::<Vec<_>>(),
806 [("schedule", "repair", "ok")]
807 );
808
809 assert!(
810 !repair(&the_app).expect("nothing to do"),
811 "a schedule that runs a command line already"
812 );
813 install(&ctx.clone().with_schedule_program(app.clone())).expect("the app again");
814 let temporary = home
815 .0
816 .join("AppTranslocation/6A1C/d/Pitboard.app/Contents/Helpers/pitboard");
817 a_program_at(&temporary);
818 assert!(
819 !repair(&ctx.clone().with_schedule_program(temporary)).expect("nothing to do"),
820 "a command line that is gone once the app quits"
821 );
822 assert_eq!(installed_program(&ctx), Some(app));
823 }
824
825 #[cfg(target_os = "macos")]
830 #[test]
831 fn a_schedule_is_not_repaired_from_inside_its_own_job() {
832 let home = Scratch::new("inside");
833 std::fs::create_dir_all(home.0.join(".pitboard")).expect("a pitboard home");
834 let app = home
835 .0
836 .join("Applications/Pitboard.app/Contents/MacOS/Pitboard");
837 let bundled = home
838 .0
839 .join("Applications/Pitboard.app/Contents/Helpers/pitboard");
840 a_program_at(&app);
841 a_program_at(&bundled);
842 let ctx = Context::new(home.0.clone());
843 install(&ctx.clone().with_schedule_program(app.clone())).expect("0.3.0's schedule");
844 let the_app = ctx.clone().with_schedule_program(bundled.clone());
845
846 assert!(
847 !repair(&the_app.clone().with_launchd_job(LABEL.into())).expect("nothing to do"),
848 "started by the schedule"
849 );
850 assert_eq!(installed_program(&ctx), Some(app));
851 assert_eq!(
852 crate::audit::read(&ctx, 1)
853 .iter()
854 .map(|e| e.subject.as_str())
855 .collect::<Vec<_>>(),
856 ["install"],
857 "and nothing recorded"
858 );
859
860 let opened = the_app.with_launchd_job("application.com.datlechin.pitboard.1.2".into());
861 assert!(repair(&opened).expect("repaired"), "opened from Finder");
862 assert_eq!(installed_program(&ctx), Some(bundled));
863 }
864
865 #[cfg(any(target_os = "macos", target_os = "linux"))]
867 #[test]
868 fn a_schedule_file_pitboard_did_not_write_names_no_program() {
869 let home = Scratch::new("foreign");
870 let ctx = Context::new(home.0.clone());
871 let installed = path(&ctx).expect("a scheduler here");
872 write(&installed, "not what install writes\n").expect("written");
873 #[cfg(target_os = "linux")]
874 write(
875 &unit_dir(&ctx).join("pitboard-renew.service"),
876 "[Service]\nExecStart=/bin/true\n",
877 )
878 .expect("written");
879
880 assert!(matches!(status(&ctx), Installed::Yes { .. }));
881 assert_eq!(installed_program(&ctx), None);
882 }
883
884 #[test]
887 fn the_schedule_belongs_to_the_default_home_alone() {
888 let ctx = Context::new(PathBuf::from("/home/x"));
889 assert!(serves(&ctx));
890 assert!(serves(
891 &ctx.clone()
892 .with_pitboard_home(PathBuf::from("/home/x/.pitboard/"))
893 ));
894 assert!(!serves(
895 &ctx.with_pitboard_home(PathBuf::from("/tmp/elsewhere"))
896 ));
897 }
898
899 #[cfg(target_os = "macos")]
902 #[test]
903 fn the_agent_runs_one_verb_and_does_not_run_at_load() {
904 let body = plist(std::path::Path::new("/usr/local/bin/pitboard"), true);
905 assert!(body.contains("<string>/usr/local/bin/pitboard</string>"));
906 assert!(body.contains("<string>renew</string>"));
907 assert!(!body.contains("status"), "it never asks for usage");
908 assert!(!body.contains("use"), "and it never switches");
909 assert!(
910 body.contains("<key>RunAtLoad</key><false/>"),
911 "installing it is not a reason to talk to Anthropic that second"
912 );
913 assert!(body.contains(&format!("<integer>{EVERY_SECONDS}</integer>")));
914 }
915
916 #[cfg(target_os = "macos")]
919 #[test]
920 fn the_agent_writes_a_path_as_xml_text() {
921 let body = plist(
922 std::path::Path::new("/Users/x/A&B <old>/Pitboard.app"),
923 true,
924 );
925 assert!(
926 body.contains("<string>/Users/x/A&B <old>/Pitboard.app</string>"),
927 "{body}"
928 );
929 }
930
931 #[cfg(target_os = "macos")]
935 #[test]
936 fn the_agent_keeps_a_refusal_of_the_argument_line() {
937 let program = std::path::Path::new("/usr/local/bin/pitboard");
938 let refusing = plist(program, false);
939 assert!(
940 refusing.contains("<key>PITBOARD_NO_ARGV</key><string>1</string>"),
941 "{refusing}"
942 );
943 assert!(!plist(program, true).contains("EnvironmentVariables"));
944 let (_, after) = refusing
945 .split_once("<key>ProgramArguments</key>")
946 .expect("its arguments");
947 assert!(
948 after
949 .trim_start()
950 .starts_with("<array>\n <string>/usr/local/bin/pitboard")
951 );
952 }
953
954 #[cfg(target_os = "linux")]
955 #[test]
956 fn the_unit_keeps_a_refusal_of_the_argument_line() {
957 let program = std::path::Path::new("/usr/local/bin/pitboard");
958 let refusing = service(program, false);
959 assert!(
960 refusing.contains("Environment=PITBOARD_NO_ARGV=1\n"),
961 "{refusing}"
962 );
963 assert!(refusing.contains("ExecStart=/usr/local/bin/pitboard renew"));
964 assert!(!service(program, true).contains("Environment="));
965 }
966
967 #[cfg(target_os = "linux")]
968 #[test]
969 fn the_unit_runs_one_verb_and_the_timer_survives_a_machine_being_off() {
970 let unit = service(std::path::Path::new("/usr/local/bin/pitboard"), true);
971 assert!(unit.contains("ExecStart=/usr/local/bin/pitboard renew"));
972 assert!(!unit.contains("status"));
973 let timer = timer();
974 assert!(timer.contains("Persistent=true"));
975 assert!(timer.contains("WantedBy=timers.target"));
976 }
977}