cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
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));
    }
}