use std::time::Duration;
use serde::Serialize;
use crate::backend::command::stderr_snippet;
use crate::error::ComputerError;
use crate::protocol::{AppleEventLanguage, APPLE_EVENT_TIMEOUT_SECS};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct AppleEventOutcome {
pub(crate) script_bytes: usize,
pub(crate) script_hash: String,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) exit_status: i32,
}
pub(crate) fn run(
script: &str,
language: AppleEventLanguage,
) -> Result<AppleEventOutcome, ComputerError> {
if script.trim().is_empty() {
return Err(ComputerError::InvalidArguments(
"apple-event script is empty".into(),
));
}
let argv: Vec<&str> = osascript_argv(script, language);
let timeout = Duration::from_secs(APPLE_EVENT_TIMEOUT_SECS);
let output = crate::backend::command::run_timeout("osascript", &argv, timeout)
.map_err(ComputerError::ExecutionFailed)?;
let exit_status = output.status.code().unwrap_or(-1);
let stdout = trim_one_line(&output.stdout);
let stderr = stderr_snippet(&output.stderr);
if !output.status.success() {
return Err(ComputerError::ExecutionFailed(format!(
"osascript failed (exit {exit_status}): {stderr}"
)));
}
let script_bytes = script.len();
let script_hash = fnv1a64_hex(script.as_bytes());
Ok(AppleEventOutcome {
script_bytes,
script_hash,
stdout,
stderr,
exit_status,
})
}
pub(crate) fn osascript_argv(script: &str, language: AppleEventLanguage) -> Vec<&str> {
let mut argv: Vec<&str> = Vec::with_capacity(4);
if matches!(language, AppleEventLanguage::JavaScript) {
argv.push("-l");
argv.push("JavaScript");
}
argv.push("-e");
argv.push(script);
argv
}
fn trim_one_line(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
let trimmed = text.trim();
if trimmed.chars().count() > 4096 {
let truncated: String = trimmed.chars().take(4096).collect();
format!("{truncated}...")
} else {
trimmed.to_string()
}
}
fn fnv1a64_hex(bytes: &[u8]) -> String {
const OFFSET: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x100000001b3;
let mut hash = OFFSET;
for byte in bytes {
hash ^= *byte as u64;
hash = hash.wrapping_mul(PRIME);
}
format!("{hash:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_script_is_rejected() {
let error = run("", AppleEventLanguage::AppleScript).unwrap_err();
assert!(matches!(error, ComputerError::InvalidArguments(_)));
}
#[test]
fn whitespace_script_is_rejected() {
let error = run(" \n ", AppleEventLanguage::AppleScript).unwrap_err();
assert!(matches!(error, ComputerError::InvalidArguments(_)));
}
#[test]
fn fnv1a64_hex_is_deterministic_and_collides_loudly() {
let a = fnv1a64_hex(b"hello");
let b = fnv1a64_hex(b"hello");
assert_eq!(a, b);
let c = fnv1a64_hex(b"world");
assert_ne!(a, c);
assert_eq!(a.len(), 16);
}
#[test]
fn osascript_argv_applescript_starts_with_dash_e() {
let argv = osascript_argv("get name", AppleEventLanguage::AppleScript);
assert_eq!(argv, vec!["-e", "get name"]);
assert_ne!(argv.first().copied(), Some("osascript"));
}
#[test]
fn osascript_argv_javascript_includes_the_language_flag() {
let argv = osascript_argv(
"Application('Finder').getName()",
AppleEventLanguage::JavaScript,
);
assert_eq!(
argv,
vec!["-l", "JavaScript", "-e", "Application('Finder').getName()"]
);
assert_ne!(argv.first().copied(), Some("osascript"));
assert_eq!(argv[2], "-e");
}
#[test]
fn osascript_argv_keeps_a_long_script_as_a_single_argv_element() {
let script = "tell application id \"com.apple.TextEdit\" to get name";
let argv = osascript_argv(script, AppleEventLanguage::AppleScript);
assert_eq!(argv.len(), 2);
assert_eq!(argv[1], script);
}
}