car-integrations 0.52.0

OS-native account-bound integrations (Calendar, Contacts, Mail) for CAR
Documentation
//! One place to run a JXA (`osascript -l JavaScript`) script.
//!
//! This module exists because there were three near-identical copies of the
//! same helper — `apple.rs`, `mail/mod.rs`, `messages/mod.rs` — and each copy
//! had exactly one of the two things you need, so each was broken in a
//! different way (Parslee-ai/car#618).
//!
//! ## The two things
//!
//! **1. Close stdin.** `osascript -` reads the script from stdin until EOF. If
//! the parent writes the script but keeps the pipe open, osascript blocks
//! forever waiting for more input and the script never runs at all. The
//! `apple.rs` copy wrote via `child.stdin.as_mut()`, which leaves the handle
//! owned by the `Child`, so the write end stayed open across the wait loop.
//! Measured: a JXA script that returns a constant never exits with the pipe
//! open, and exits in 0.1s when it is closed. That copy therefore could not
//! succeed — every call burned its own 5s watchdog and returned "osascript
//! timed out waiting for Apple Events response", which reads as a permissions
//! problem and is not one. `notes.*`, `reminders.*` and `photos.*` were
//! unavailable on every machine for this reason, regardless of TCC state.
//!
//! **2. Bound the wait.** When Apple Events *are* the problem — the target app
//! is not running, or automation permission has not been granted — osascript
//! sits on Apple's own ~120s default timeout. The `mail`/`messages` copies used
//! a plain blocking `wait_with_output()`, so `mail.accounts` and
//! `messages.services` took a measured **121.0s** to return, where the
//! `apple.rs` copy's watchdog gave up in 5.
//!
//! Getting one right and the other wrong is what made the two failure modes
//! look unrelated. [`run`] does both, so there is a single place to be correct.

use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use crate::IntegrationError;

/// Default ceiling on a single JXA invocation.
///
/// Sized against Apple's own ~120s Apple-Events timeout, which is the thing
/// being cut short: anything approaching it means the target app is not
/// answering, and no amount of further waiting changes the answer. Kept
/// generous enough that a cold app launch or a large mailbox enumeration still
/// completes — the old 5s bound in `apple.rs` was never actually exercised
/// against a working call, since that path could not succeed at all.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);

/// How often the wait loop checks for child exit.
const POLL_INTERVAL: Duration = Duration::from_millis(25);

/// Run `script` under `osascript -l JavaScript -`, passing `args` positionally,
/// and deserialize its stdout as JSON.
///
/// Returns [`IntegrationError::Backend`] on spawn failure, timeout, a non-zero
/// exit, or unparseable output. `timeout` bounds the whole invocation; use
/// [`DEFAULT_TIMEOUT`] unless a caller has a specific reason not to.
pub fn run<T: serde::de::DeserializeOwned>(
    script: &str,
    args: &[&str],
    timeout: Duration,
) -> Result<T, IntegrationError> {
    let raw = run_raw(script, args, timeout)?;
    serde_json::from_slice(&raw).map_err(|e| {
        IntegrationError::Backend(format!(
            "osascript returned output that is not the expected JSON: {e}"
        ))
    })
}

/// [`run`] without the JSON step — returns raw stdout bytes.
pub fn run_raw(
    script: &str,
    args: &[&str],
    timeout: Duration,
) -> Result<Vec<u8>, IntegrationError> {
    let mut child = Command::new("/usr/bin/osascript")
        .arg("-l")
        .arg("JavaScript")
        .arg("-")
        .args(args)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .map_err(|e| IntegrationError::Backend(format!("osascript: {e}")))?;

    // Write the script, then DROP the handle. `take()` moves stdin out of the
    // Child so it closes at the end of this scope — that close is what gives
    // osascript its EOF. Writing through `child.stdin.as_mut()` instead leaves
    // the pipe open and deadlocks (see the module docs).
    {
        let mut stdin = child
            .stdin
            .take()
            .ok_or_else(|| IntegrationError::Backend("osascript: stdin unavailable".to_string()))?;
        stdin
            .write_all(script.as_bytes())
            .map_err(|e| IntegrationError::Backend(format!("osascript stdin: {e}")))?;
    }

    let start = Instant::now();
    loop {
        match child.try_wait() {
            Ok(Some(_)) => break,
            Ok(None) => {}
            Err(e) => {
                let _ = child.kill();
                let _ = child.wait();
                return Err(IntegrationError::Backend(format!("osascript wait: {e}")));
            }
        }
        if start.elapsed() > timeout {
            let _ = child.kill();
            let _ = child.wait();
            return Err(IntegrationError::Backend(format!(
                "osascript did not respond within {}s — the target app may not be running, \
                 or automation permission may not be granted (check `car permissions status automation`)",
                timeout.as_secs()
            )));
        }
        std::thread::sleep(POLL_INTERVAL);
    }

    let output = child
        .wait_with_output()
        .map_err(|e| IntegrationError::Backend(format!("osascript output: {e}")))?;
    if !output.status.success() {
        return Err(IntegrationError::Backend(format!(
            "osascript failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        )));
    }
    Ok(output.stdout)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The regression that motivated this module: a JXA script returning a
    /// constant must complete promptly. With stdin left open it never exits and
    /// this times out instead — which is what every `apple.rs` JXA call did.
    #[test]
    #[cfg_attr(not(target_os = "macos"), ignore)]
    fn trivial_script_completes_and_deserializes() {
        let started = Instant::now();
        let v: serde_json::Value = run(
            "function run(){ return JSON.stringify({ok:true, n:41+1}); }",
            &[],
            DEFAULT_TIMEOUT,
        )
        .expect("a constant-returning JXA script must succeed");
        assert_eq!(v["ok"], true);
        assert_eq!(v["n"], 42);
        assert!(
            started.elapsed() < Duration::from_secs(5),
            "took {:?} — that is the stdin-EOF deadlock, not real work",
            started.elapsed()
        );
    }

    /// Positional args reach the script through `run()`'s parameter list.
    #[test]
    #[cfg_attr(not(target_os = "macos"), ignore)]
    fn args_are_passed_through() {
        let v: serde_json::Value = run(
            "function run(argv){ return JSON.stringify({got: argv}); }",
            &["alpha", "beta"],
            DEFAULT_TIMEOUT,
        )
        .expect("script with args must succeed");
        assert_eq!(v["got"][0], "alpha");
        assert_eq!(v["got"][1], "beta");
    }

    /// A script that never returns is cut off at the bound rather than running
    /// to Apple's ~120s default — the `mail`/`messages` failure mode.
    #[test]
    #[cfg_attr(not(target_os = "macos"), ignore)]
    fn a_hanging_script_is_killed_at_the_timeout() {
        let started = Instant::now();
        let r: Result<serde_json::Value, _> = run(
            "function run(){ while(true){} }",
            &[],
            Duration::from_secs(2),
        );
        let err = r.expect_err("an infinite script must not succeed");
        assert!(
            err.to_string().contains("did not respond within"),
            "unexpected error: {err}"
        );
        assert!(
            started.elapsed() < Duration::from_secs(10),
            "watchdog did not fire promptly: {:?}",
            started.elapsed()
        );
    }

    /// A failing script surfaces osascript's own stderr, not a generic message.
    #[test]
    #[cfg_attr(not(target_os = "macos"), ignore)]
    fn script_error_surfaces_stderr() {
        let r: Result<serde_json::Value, _> = run(
            "function run(){ throw new Error('boom'); }",
            &[],
            DEFAULT_TIMEOUT,
        );
        let err = r.expect_err("a throwing script must be an error");
        assert!(err.to_string().contains("boom"), "unexpected error: {err}");
    }
}