Skip to main content

pitboard_core/
schedule.rs

1//! Keeping parked logins alive without anybody running a command.
2//!
3//! A refresh token has a finite life, and the only two things that renew one are somebody
4//! typing `pitboard` and the menu bar app's poll. So the tool is safe for a macOS user who
5//! installed the app and leaves it running, and quietly unsafe for everyone else: the
6//! whole of Linux, and any macOS user on the command line alone. Go away for the refresh
7//! window, come back, and every parked login is dead and each account needs a browser
8//! sign-in, which is precisely the cost pitboard exists to spare people.
9//!
10//! This is opt-in, and stays opt-in. A background process that talks to Anthropic on a
11//! schedule is the shape most likely to be read as automation, so it is something a person
12//! turns on knowing what it is, and what it does is written where they can read it: it
13//! renews the owner's own parked logins and nothing else. It never switches, never asks for
14//! usage, and makes no request other than the token exchange.
15//!
16//! What it installs is the platform's own thing, not a homemade daemon: a LaunchAgent on
17//! macOS, which runs inside the login session so the keychain is unlocked, and a systemd
18//! user timer on Linux, which is that platform's answer. Where there is neither, it says so
19//! rather than inventing a third.
20
21use crate::context::Context;
22use crate::error::{Error, Result};
23use std::path::PathBuf;
24
25/// What launchd calls the job, so a person can find it without pitboard telling them.
26/// systemd names its own unit, which is why this is macOS only.
27#[cfg(target_os = "macos")]
28const LABEL: &str = "com.datlechin.pitboard.renew";
29
30/// Once a day. A refresh token's life is measured in weeks and pitboard starts renewing
31/// three days out, so a daily check has three chances to catch each one, and a machine
32/// that was asleep for one of them still has two.
33const EVERY_SECONDS: u32 = 86_400;
34
35/// Whether the schedule is installed, and what it is.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Installed {
38    /// Installed, at this path.
39    Yes {
40        path: PathBuf,
41        every_seconds: u32,
42    },
43    No,
44    /// This platform has no scheduler pitboard knows how to write.
45    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
60/// Where the schedule lives on this platform, or `None` where there is none.
61pub 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
88/// Whether the schedule is this context's to look after.
89///
90/// launchd and systemd start `renew` without `PITBOARD_HOME`, so the schedule always renews
91/// the default `~/.pitboard`. A pitboard pointed at another home has none of its own: the
92/// one there is belongs to the default home.
93pub(crate) fn serves(ctx: &Context) -> bool {
94    crate::home::dir(ctx) == ctx.home().join(".pitboard")
95}
96
97/// The pitboard the schedule should run: the one the context names, or this one.
98fn 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/// The path this pitboard was started by, `argv0` found on `search` the way the shell found
116/// it, where that leads to the `running` program; otherwise `running`.
117///
118/// Linux says where the running program is with every link resolved, and the link is what
119/// lasts: Homebrew starts pitboard through one in its `bin` that leads into a directory
120/// named after the version, which the next upgrade deletes. A path that leads to some
121/// other file did not start this one. macOS already says what path a program was started
122/// by.
123#[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
136/// `program`, where it will still be there when the scheduler runs it. launchd and systemd
137/// start a program that is gone without telling anyone, every day, so a path that leads
138/// nowhere is refused rather than written down.
139///
140/// A path inside the copy macOS makes of an app opened where it was downloaded leads
141/// somewhere while that app runs and nowhere once it quits, so it is refused as well.
142fn 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
159/// The pitboard the installed schedule runs, read back from what `install` wrote. `None`
160/// where nothing is installed, and where the file does not name one the way `install`
161/// writes it.
162pub 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        // The timer is what says the schedule is installed; the service is what it runs.
174        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
204/// Install it, and ask the platform to start it. Returns where it went.
205pub 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
211/// Point a schedule that runs an app's own program at the command line the context names.
212/// `true` when it did.
213///
214/// An app up to 0.3.0 scheduled itself, and that app renews nothing when started with
215/// `renew`: launchd started a second menu bar app every day instead. The app calls this
216/// when it starts, so nothing changes unless the schedule is such a one and belongs to this
217/// home, and the context names a command line that will still be there when the scheduler
218/// runs it.
219///
220/// Nothing changes from inside the schedule's own job either: launchd stops a job's process
221/// when it unloads the job, which a repair does before loading it again, so nothing would be
222/// left to load it back.
223pub 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
250/// Whether `program` is the one an app bundle starts, `Contents/MacOS/<name>`, where no
251/// command line is ever kept.
252pub(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
260/// Write the schedule to run `program`, and ask the platform to start it.
261///
262/// Where the platform will not start it, the files go back to what they were and whatever
263/// was running before is started again. The status, doctor and the app all read the files,
264/// so files left naming a schedule nothing is running would say renewal is on while it is
265/// not, which is the failure nobody would notice until the parked logins had run out.
266fn 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        // `bootstrap` is launchd's own word for this, and replaces the deprecated `load`.
276        // SAFETY: `getuid` cannot fail and touches no memory of this process.
277        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/// Put a file back the way it was before this run wrote it: its old contents, or not there.
316/// Best effort, because it runs on the way out of a failure that is already being reported.
317#[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
325/// Take it away. `false` when there was nothing installed.
326pub 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        // SAFETY: `getuid` cannot fail and touches no memory of this process.
337        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/// Ask the platform's scheduler to start or stop the schedule.
374///
375/// Never from a test. A test's home is a scratch directory, but the scheduler it would ask is
376/// the person's own: launchd finds a job by the label inside the file, so booting out a
377/// scratch copy stops their real schedule, and `systemctl --user` reaches the one session
378/// there is. A test writes and reads the files and leaves the scheduler alone.
379#[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    /// The scheduler verb a test has asked to be refused, once.
398    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/// launchd's own format. `RunAtLoad` is off: installing this is not a reason to talk to
426/// Anthropic that second, and the first run comes at the first interval.
427///
428/// `argument_line` is whether the pitboard installing it may write a login on the argument
429/// line. The job runs with launchd's environment, not the person's shell, so a
430/// `PITBOARD_NO_ARGV` they set would not reach it; where it is set, the job is given it.
431#[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/// A path as XML text. An app can be kept in a folder whose name has an ampersand in it,
461/// and launchd refuses a plist that is not well formed.
462#[cfg(target_os = "macos")]
463fn escape(text: &str) -> String {
464    text.replace('&', "&amp;")
465        .replace('<', "&lt;")
466        .replace('>', "&gt;")
467}
468
469/// What [`escape`] wrote, read back.
470#[cfg(target_os = "macos")]
471fn unescape(text: &str) -> String {
472    text.replace("&lt;", "<")
473        .replace("&gt;", ">")
474        .replace("&amp;", "&")
475}
476
477/// systemd's own format. `argument_line` is as for the launchd job: the service runs with
478/// systemd's environment, so a `PITBOARD_NO_ARGV` the installing pitboard has is given to it.
479#[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    /// A home of the test's own, gone when the test is.
520    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    /// Put a program at `path`, for a schedule to name.
542    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    /// The verb that starts a schedule on this platform.
548    #[cfg(target_os = "macos")]
549    const START: &str = "bootstrap";
550    #[cfg(target_os = "linux")]
551    const START: &str = "enable";
552
553    /// Run `change` with the scheduler refusing `verb` the first time it is asked.
554    #[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    /// A schedule the scheduler will not start leaves no file behind saying renewal is on.
563    #[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    /// A repair the scheduler will not start leaves the schedule that was there, which is
579    /// what the app and doctor then go on reporting.
580    #[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    /// Homebrew starts pitboard through a link in its `bin` that leads into a directory
645    /// named after the version, and the next upgrade deletes that directory. Linux says
646    /// where the running program is with every link resolved, so the schedule is given the
647    /// path pitboard was started by instead, wherever that leads to this same program.
648    #[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    /// A pitboard that is named is written down only where it will still be there when the
691    /// scheduler runs it. macOS runs an app opened where it was downloaded from a temporary
692    /// copy, which is there while the app runs and gone once it quits, so being there now
693    /// is not enough.
694    #[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    /// What `pitboard doctor` reads back is what was written, including a path launchd's
730    /// format has to escape. The file itself is looked at too: a path with an ampersand
731    /// reads back the same whether or not it was escaped.
732    #[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&amp;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    /// An app up to 0.3.0 scheduled itself, and launchd has started a second menu bar app
758    /// every day since. An app that names the command line it comes with puts that in its
759    /// place, and every other schedule is left as it is.
760    #[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    /// launchd stops a job's own process when it unloads the job, and a repair unloads the
826    /// schedule before loading it again. Made from inside the schedule's own job, as by an
827    /// app the old schedule started, it would leave nothing loaded, so it is left to the app
828    /// once it is opened any other way.
829    #[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    /// Only the file `install` writes is read, and only the way it writes it.
866    #[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    /// launchd and systemd start `renew` with the default home, so a pitboard pointed
885    /// anywhere else leaves the schedule alone.
886    #[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    /// What it runs is one verb with no arguments, and what it does not do is as much the
900    /// point as what it does.
901    #[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    /// launchd refuses a plist that is not well formed, and a folder's name can hold any of
917    /// the characters XML gives a meaning to.
918    #[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&amp;B &lt;old&gt;/Pitboard.app</string>"),
927            "{body}"
928        );
929    }
930
931    /// The job runs with launchd's environment, not the shell's, so a `PITBOARD_NO_ARGV`
932    /// the installing pitboard has is written into it, and nothing is written otherwise.
933    /// The program is still read back from what was written.
934    #[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}