moadim 1.7.4

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
//! macOS launchd `LaunchAgent`: render the plist, write it, and (un)load it with `launchctl`.

use std::path::{Path, PathBuf};

use super::common::{daemon_log, moadim_exe, run};

/// launchd label, also the plist file stem (`io.moadim.daemon.plist`).
const LAUNCHD_LABEL: &str = "io.moadim.daemon";

/// The `launchctl` executable, overridable via `MOADIM_LAUNCHCTL_BIN` so tests can substitute a
/// no-op shim instead of mutating the real launchd session. Mirrors the `MOADIM_CRONTAB_BIN` seam.
pub(super) fn launchctl_bin() -> String {
    if let Ok(bin) = std::env::var("MOADIM_LAUNCHCTL_BIN") {
        return bin;
    }
    // In test builds, never fall back to the real `launchctl`: a test that forgets to wire up the
    // `MOADIM_LAUNCHCTL_BIN` shim must not mutate the developer's live launchd session. The guard
    // path does not exist, so the eventual spawn fails harmlessly. Mirrors the `crontab_bin()` guard
    // from issue #211.
    #[cfg(test)]
    let fallback = "/nonexistent/moadim-test-launchctl-guard".to_string();
    #[cfg(not(test))]
    let fallback = "launchctl".to_string();
    fallback
}

/// Escape the five XML metacharacters so a filesystem path embeds safely in the plist `<string>`s.
pub(super) fn xml_escape(text: &str) -> String {
    text.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

/// Absolute path to the per-user `LaunchAgents` plist for the moadim service.
///
/// Resolves home through [`crate::paths::home`] so the `MOADIM_HOME_OVERRIDE` test seam redirects
/// the plist under a tempdir instead of the developer's real `~/Library/LaunchAgents`.
pub(super) fn plist_path() -> anyhow::Result<PathBuf> {
    plist_path_from_home(crate::paths::home())
}

/// Resolve the `LaunchAgents` plist path under `home`, erroring when the home directory is unknown.
pub(super) fn plist_path_from_home(home: Option<PathBuf>) -> anyhow::Result<PathBuf> {
    let home = home.ok_or_else(|| anyhow::anyhow!("could not determine the home directory"))?;
    Ok(home
        .join("Library/LaunchAgents")
        .join(format!("{LAUNCHD_LABEL}.plist")))
}

/// Whether the moadim launchd agent is currently installed (its plist file exists).
pub fn is_installed() -> anyhow::Result<bool> {
    Ok(plist_path()?.exists())
}

/// `PATH` to give the launchd agent, in place of launchd's own minimal default
/// (`/usr/bin:/bin:/usr/sbin:/sbin`).
///
/// That default hides Homebrew/cargo-installed tools — notably `tmux`, which the daemon's own
/// background sweep shells out to directly (`routines::cleanup::session`) to probe/kill hung
/// routine sessions. With no `tmux` on `PATH`, every probe silently failed ("session not found"),
/// so a hung run's workbench got TTL-reaped while its tmux session and agent process kept running,
/// untracked, forever. `resolve_tmux_bin` now also searches these locations directly as a second
/// line of defense, but giving the process a real `PATH` fixes this for every other subprocess the
/// daemon shells out to as well.
fn agent_path(home: &Path) -> String {
    format!(
        "/opt/homebrew/bin:/usr/local/bin:{home}/.cargo/bin:{home}/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
        home = home.display(),
    )
}

/// Render the launchd property list for the moadim user agent.
///
/// `exe` is the absolute path to the `moadim` binary; `log` is where launchd writes its stdout and
/// stderr; `home` derives the `PATH` (see [`agent_path`]). The agent runs `moadim --interactive` so
/// launchd supervises it directly.
///
/// `KeepAlive` is a `{ SuccessfulExit = false }` dict (not unconditional `true`): launchd relaunches
/// the agent only when it exits abnormally, so a crash is still auto-restarted but a clean shutdown
/// — `moadim stop`, the UI STOP button, `POST /shutdown`, all of which exit `0` — stays stopped
/// instead of being immediately resurrected by launchd.
pub(super) fn render_plist(exe: &Path, log: &Path, home: &Path) -> String {
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>{label}</string>
  <key>ProgramArguments</key>
  <array>
    <string>{exe}</string>
    <string>--interactive</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <dict>
    <key>SuccessfulExit</key>
    <false/>
  </dict>
  <key>StandardOutPath</key>
  <string>{log}</string>
  <key>StandardErrorPath</key>
  <string>{log}</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>{path}</string>
  </dict>
</dict>
</plist>
"#,
        label = LAUNCHD_LABEL,
        exe = xml_escape(&exe.display().to_string()),
        log = xml_escape(&log.display().to_string()),
        path = xml_escape(&agent_path(home)),
    )
}

/// Render the plist for `exe`/`log`/`home` and write it (creating parent dirs) to `plist`.
pub(super) fn write_plist(plist: &Path, exe: &Path, log: &Path, home: &Path) -> anyhow::Result<()> {
    if let Some(dir) = plist.parent() {
        std::fs::create_dir_all(dir)?;
    }
    if let Some(dir) = log.parent() {
        std::fs::create_dir_all(dir)?;
    }
    std::fs::write(plist, render_plist(exe, log, home))?;
    Ok(())
}

/// Reload the agent with launchd: unload any earlier copy (ignored when not loaded), then load
/// with `-w` to enable it.
fn reload_agent(plist: &Path) -> anyhow::Result<()> {
    let plist_arg = plist.display().to_string();
    let launchctl = launchctl_bin();
    let _ = run(&launchctl, &["unload", &plist_arg]);
    run(&launchctl, &["load", "-w", &plist_arg])
}

/// Print the post-install summary (paths and a status hint).
fn report_installed(plist: &Path, log: &Path) {
    println!("moadim installed as a launchd agent ({LAUNCHD_LABEL})");
    println!("  plist   {}", plist.display());
    println!("  logs    {}", log.display());
    println!("  status  launchctl list | grep {LAUNCHD_LABEL}");
}

/// Write the `LaunchAgent` plist for the running binary and load it with launchd.
pub fn install() -> anyhow::Result<()> {
    let exe = moadim_exe()?;
    let log = daemon_log();
    // Propagate an undeterminable home directory with `?` instead of `.expect()`-ing it into a
    // panic. Mirrors the Linux backend's `install()`, which propagates `unit_path()` the same way.
    let home = crate::paths::home();
    let plist = plist_path_from_home(home.clone())?;
    // The `?` above already returned if `home` were `None` (see `plist_path_from_home`), so this
    // can never panic.
    #[allow(
        clippy::expect_used,
        reason = "the `?` above already returned if `home` were `None` (see \
                  `plist_path_from_home`), so this is a proven invariant, not a real failure path"
    )]
    let home = home.expect("plist_path_from_home errors before this point when home is None");
    write_plist(&plist, &exe, &log, &home)?;
    reload_agent(&plist)?;
    report_installed(&plist, &log);
    request_automation_permission();
    Ok(())
}

/// Trigger the macOS TCC "administer your computer" prompt now, while the user is present at the
/// terminal, so the background daemon never has to ask for it mid-run.
///
/// Sends a harmless Apple Event to System Events (list running process names). If permission is
/// already granted this is a no-op; if not, the dialog appears once here and is remembered forever.
fn request_automation_permission() {
    println!(
        "  hint    if macOS asks \"moadim would like to administer your computer\", click OK — \
granting it here prevents background interruptions"
    );
    let _ = std::process::Command::new("osascript")
        .args([
            "-e",
            "tell application \"System Events\" to get name of every process",
        ])
        .output();
}

/// Unload the `LaunchAgent` (if loaded) and delete its plist.
pub fn uninstall() -> anyhow::Result<()> {
    let plist = plist_path()?;
    if plist.exists() {
        let plist_arg = plist.display().to_string();
        let _ = run(&launchctl_bin(), &["unload", "-w", &plist_arg]);
        std::fs::remove_file(&plist)?;
        println!("moadim launchd agent removed ({})", plist.display());
    } else {
        println!(
            "moadim launchd agent is not installed (no plist at {})",
            plist.display()
        );
    }
    Ok(())
}