use std::thread;
use std::time::{Duration, Instant};
use crate::MacosError;
use crate::applescript::run_capture as run_applescript_capture;
use crate::safari_guard::safari_automation_state;
#[cfg(test)]
pub(crate) use crate::safari_guard::{
SAFARI_LOCK_TTL_SECS, SafariAutomationSession, SafariLockFile, acquire_safari_lock,
read_safari_lock, release_safari_lock, renew_safari_lock,
};
pub mod ai;
mod core;
#[cfg(test)]
mod core_tests;
mod eval;
mod history;
mod inspect;
mod interaction;
mod observe;
mod script;
#[cfg(test)]
mod script_tests;
mod scroll;
mod social;
mod target;
mod types;
mod wait;
pub use ai::{
GeminiMode, SafariAiImage, SafariAiImageResult, SafariAiReadyResult, SafariAiResponseResult,
SafariConversation, SafariDeepResearchResult, chatgpt_list_conversations, chatgpt_save_images,
ensure_chatgpt_home, ensure_gemini_home, ensure_grok_home, gemini_list_conversations,
gemini_read_conversation, gemini_save_images, gemini_save_media, grok_list_conversations,
grok_read_conversation, poll_gemini_deep_research, prepare_gemini_mode,
send_chatgpt_image_prompt, send_chatgpt_prompt, send_gemini_prompt, send_grok_prompt,
set_chatgpt_effort, start_gemini_deep_research,
};
pub(crate) use core::doctor_live_probe;
pub use core::{active, click, close, close_tabs, fill, focus_tab, open, read, source, tabs};
pub use eval::exec;
pub use history::capture;
pub use inspect::{batch, inspect};
pub use observe::{console_messages, network_requests};
pub use scroll::{scroll, scroll_and_read};
pub use social::{SocialFeedPost, threads_extract_feed, x_extract_feed, x_read_post, x_search};
pub use types::{
SafariClickResult, SafariCloseResult, SafariEvalResult, SafariFillResult, SafariReadResult,
SafariScrollReadChunk, SafariScrollReadResult, SafariScrollResult, SafariSourceResult,
SafariTab, SafariWaitResult,
};
pub use wait::{WaitCondition, wait_until};
const SAFARI_OPERATION_DELAY: Duration = Duration::from_secs(1);
const SAFARI_429_MAX_RETRIES: usize = 3;
const JS_APPLE_EVENT_TIMEOUT_SECONDS: u64 = 15;
const JS_APPLE_EVENT_TIMEOUT_MARKER: &str = "CUEWARD_JS_APPLE_EVENT_TIMEOUT";
const TAB_SEPARATOR: &str = "---TAB_SEP---";
const FIELD_SEPARATOR: &str = "<<<FIELD_SEP>>>";
fn map_js_timeout(error: MacosError, target: &str) -> MacosError {
let is_js_timeout = matches!(
&error,
MacosError::Other(message)
if message.contains(JS_APPLE_EVENT_TIMEOUT_MARKER) && message.contains("(-1712)")
);
if is_js_timeout {
let target = match &error {
MacosError::Other(message) => {
js_timeout_target(message).unwrap_or_else(|| target.into())
}
_ => target.into(),
};
MacosError::Other(format!(
"Safari JavaScript did not respond within {JS_APPLE_EVENT_TIMEOUT_SECONDS} seconds for {target}; a browser dialog may be open. The action outcome is unknown; inspect the tab before retrying."
))
} else {
error
}
}
fn js_timeout_target(message: &str) -> Option<String> {
let details = message
.split_once(JS_APPLE_EVENT_TIMEOUT_MARKER)?
.1
.strip_prefix('|')?;
let (window_id, tab_index) = details.split_once('|')?;
let window_id = window_id.parse::<i64>().ok()?;
let tab_index = tab_index.split_whitespace().next()?.parse::<usize>().ok()?;
Some(format!("window {window_id} tab index {tab_index}"))
}
fn compute_next_safari_operation(
now: Instant,
last_operation_at: Option<Instant>,
) -> (Option<Duration>, Instant) {
match last_operation_at {
Some(last) if now < last + SAFARI_OPERATION_DELAY => {
let next_allowed = last + SAFARI_OPERATION_DELAY;
(Some(next_allowed - now), next_allowed)
}
_ => (None, now),
}
}
fn throttle_safari_operation() -> Result<(), MacosError> {
let sleep_for = {
let state = safari_automation_state();
let mut guard = state
.lock()
.map_err(|_| MacosError::Other("safari automation state poisoned".to_string()))?;
if guard.depth > 0 {
if let (Some(path), Some(pid)) = (guard.lock_path.as_ref(), guard.lock_owner_pid) {
crate::safari_guard::renew_safari_lock(path, chrono::Utc::now().timestamp(), pid)?;
}
}
let now = Instant::now();
let (delay, next_allowed) = compute_next_safari_operation(now, guard.last_operation_at);
guard.last_operation_at = Some(next_allowed);
delay
};
if let Some(duration) = sleep_for {
thread::sleep(duration);
}
Ok(())
}
fn is_safari_rate_limited(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.len() > 256 {
return false;
}
let normalized = trimmed.to_ascii_lowercase();
normalized.contains("too many requests")
|| normalized.contains("http 429")
|| normalized.contains("429 too many requests")
|| normalized.contains("\"status\":429")
|| normalized == "rate limit exceeded"
|| normalized == "rate-limited"
}
fn safari_rate_limit_backoff(attempt: usize) -> Duration {
Duration::from_secs(30 * (attempt as u64 + 1))
}
fn rate_limit_error(context: &str, detail: &str) -> MacosError {
MacosError::Other(format!(
"{context}: Safari automation hit rate limit: {detail}"
))
}
fn run_capture(script: &str, context: &str) -> Result<String, MacosError> {
let mut last_detail = None;
for attempt in 0..=SAFARI_429_MAX_RETRIES {
throttle_safari_operation()?;
match run_applescript_capture(script, context) {
Ok(stdout) => {
if !is_safari_rate_limited(&stdout) {
return Ok(stdout);
}
last_detail = Some(stdout.trim().to_string());
}
Err(err) => {
let detail = err.to_string();
if !is_safari_rate_limited(&detail) {
return Err(err);
}
last_detail = Some(detail);
}
}
if attempt == SAFARI_429_MAX_RETRIES {
break;
}
thread::sleep(safari_rate_limit_backoff(attempt));
}
Err(rate_limit_error(
context,
last_detail
.as_deref()
.unwrap_or("unknown rate limit response"),
))
}
#[cfg(test)]
mod tests {
use super::{
SAFARI_LOCK_TTL_SECS, SAFARI_OPERATION_DELAY, SafariAutomationSession, SafariLockFile,
acquire_safari_lock, compute_next_safari_operation, is_safari_rate_limited, map_js_timeout,
read_safari_lock, release_safari_lock, renew_safari_lock, safari_automation_state,
safari_rate_limit_backoff,
};
use std::fs;
use std::time::Duration;
use std::time::Instant;
use tempfile::tempdir;
#[test]
fn safari_rate_limit_detection_matches_expected_signals() {
assert!(is_safari_rate_limited("HTTP 429 Too Many Requests"));
assert!(is_safari_rate_limited("rate limit exceeded"));
assert!(is_safari_rate_limited(
r#"{"status":429,"error":"Too Many Requests"}"#
));
assert!(!is_safari_rate_limited(
"this article explains how rate limits work"
));
assert!(!is_safari_rate_limited("all good"));
}
#[test]
fn safari_js_timeout_identifies_tab_and_preserves_unknown_outcome() {
let error = crate::MacosError::Other(
"safari_exec: CUEWARD_JS_APPLE_EVENT_TIMEOUT (-1712)".to_string(),
);
let mapped = map_js_timeout(error, "window 42 tab 1");
let message = mapped.to_string();
assert!(message.contains("window 42 tab 1"));
assert!(message.contains("15 seconds"));
assert!(message.contains("browser dialog"));
assert!(message.contains("outcome is unknown"));
}
#[test]
fn safari_profile_timeout_uses_captured_window_and_tab_index() {
let error = crate::MacosError::Other(
"safari_chatgpt_prompt_fill: CUEWARD_JS_APPLE_EVENT_TIMEOUT|42|1 (-1712)".to_string(),
);
let mapped = map_js_timeout(error, "current Safari tab");
let message = mapped.to_string();
assert!(message.contains("window 42 tab index 1"));
assert!(!message.contains("current Safari tab"));
}
#[test]
fn safari_js_timeout_does_not_relabel_other_errors() {
let error = crate::MacosError::Other("safari_exec: JavaScript syntax error".to_string());
let mapped = map_js_timeout(error, "window 42 tab 1");
assert_eq!(mapped.to_string(), "safari_exec: JavaScript syntax error");
let unrelated_timeout = crate::MacosError::Other("metadata request (-1712)".to_string());
let mapped = map_js_timeout(unrelated_timeout, "window 42 tab 1");
assert_eq!(mapped.to_string(), "metadata request (-1712)");
}
#[test]
fn safari_rate_limit_backoff_is_linear() {
assert_eq!(safari_rate_limit_backoff(0), Duration::from_secs(30));
assert_eq!(safari_rate_limit_backoff(1), Duration::from_secs(60));
assert_eq!(safari_rate_limit_backoff(2), Duration::from_secs(90));
}
#[test]
fn safari_lock_rejects_active_owner() {
let dir = tempdir().expect("tempdir");
let lock_path = dir.path().join("lock.json");
let now = 1_700_000_000;
let payload = SafariLockFile {
pid: 42,
acquired_at: now,
expires_at: now + SAFARI_LOCK_TTL_SECS,
};
fs::write(
&lock_path,
serde_json::to_vec(&payload).expect("encode lock payload"),
)
.expect("write lock");
let err = acquire_safari_lock(&lock_path, now, 77).expect_err("active lock should fail");
assert!(err.to_string().contains("locked by pid 42"));
}
#[test]
fn safari_lock_replaces_stale_owner() {
let dir = tempdir().expect("tempdir");
let lock_path = dir.path().join("lock.json");
let now = 1_700_000_000;
let stale = SafariLockFile {
pid: 42,
acquired_at: now - 600,
expires_at: now - 1,
};
fs::write(
&lock_path,
serde_json::to_vec(&stale).expect("encode lock payload"),
)
.expect("write stale lock");
acquire_safari_lock(&lock_path, now, 77).expect("stale lock should be replaced");
let lock = read_safari_lock(&lock_path).expect("replacement lock");
assert_eq!(lock.pid, 77);
assert_eq!(lock.expires_at, now + SAFARI_LOCK_TTL_SECS);
}
#[test]
fn safari_lock_renewal_protects_a_long_running_session() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("lock.json");
let start = 1_700_000_000;
acquire_safari_lock(&path, start, 77).expect("acquire lock");
renew_safari_lock(&path, start + 100, 77).expect("early renewal is a no-op");
assert_eq!(
read_safari_lock(&path).expect("original lock").expires_at,
start + SAFARI_LOCK_TTL_SECS
);
renew_safari_lock(&path, start + 1000, 77).expect("renew lock");
let renewed = read_safari_lock(&path).expect("renewed lock");
assert_eq!(renewed.acquired_at, start);
assert_eq!(renewed.expires_at, start + 1000 + SAFARI_LOCK_TTL_SECS);
assert!(acquire_safari_lock(&path, start + SAFARI_LOCK_TTL_SECS, 88).is_err());
assert!(renew_safari_lock(&path, start + 1001, 88).is_err());
assert_eq!(read_safari_lock(&path), Some(renewed));
}
#[test]
fn safari_lock_release_removes_owned_lock() {
let dir = tempdir().expect("tempdir");
let lock_path = dir.path().join("lock.json");
let now = 1_700_000_000;
acquire_safari_lock(&lock_path, now, 77).expect("acquire lock");
release_safari_lock(&lock_path, 77).expect("release lock");
assert!(read_safari_lock(&lock_path).is_none());
}
#[test]
fn safari_lock_ttl_covers_long_running_safari_jobs() {
assert!(SAFARI_LOCK_TTL_SECS >= 900);
}
#[test]
fn safari_lock_reports_corrupted_active_file() {
let dir = tempdir().expect("tempdir");
let lock_path = dir.path().join("lock.json");
let now = 1_700_000_000;
fs::write(&lock_path, b"{not-json").expect("write corrupt lock");
let err = acquire_safari_lock(&lock_path, now, 77)
.expect_err("corrupted active lock should fail");
assert!(err.to_string().contains("corrupted or unreadable"));
}
#[test]
fn throttle_schedule_reserves_next_available_slot() {
let now = Instant::now();
let last = now;
let (delay, next_allowed) = compute_next_safari_operation(now, Some(last));
assert_eq!(delay, Some(SAFARI_OPERATION_DELAY));
assert!(next_allowed >= last + SAFARI_OPERATION_DELAY);
}
#[test]
fn dropping_outer_session_keeps_last_operation_timestamp() {
let state = safari_automation_state();
let now = Instant::now();
{
let mut guard = state.lock().expect("state lock");
guard.depth = 1;
guard.last_operation_at = Some(now);
guard.lock_path = None;
guard.lock_owner_pid = None;
}
let session = SafariAutomationSession { outermost: true };
drop(session);
let guard = state.lock().expect("state lock");
assert_eq!(guard.depth, 0);
assert_eq!(guard.last_operation_at, Some(now));
}
}