use std::io::Write;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::IntegrationError;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
const POLL_INTERVAL: Duration = Duration::from_millis(25);
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}"
))
})
}
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}")))?;
{
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::*;
#[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()
);
}
#[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");
}
#[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()
);
}
#[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}");
}
}