cueward-adapter-macos 0.3.1

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,
};

pub mod ai;
mod core;
#[cfg(test)]
mod core_tests;
mod history;
mod script;
#[cfg(test)]
mod script_tests;
mod social;
mod types;

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,
    start_gemini_deep_research,
};
pub use core::{
    active, click, close, close_tabs, exec, fill, focus_tab, open, read, scroll, scroll_and_read,
    source, tabs, wait,
};
pub(crate) use core::doctor_live_probe;
pub use history::capture;
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,
};

const SAFARI_OPERATION_DELAY: Duration = Duration::from_secs(1);
const SAFARI_429_MAX_RETRIES: usize = 3;
const TAB_SEPARATOR: &str = "---TAB_SEP---";
const FIELD_SEPARATOR: &str = "<<<FIELD_SEP>>>";

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()))?;
        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,
        read_safari_lock, release_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_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_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));
    }
}