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 serde_json::Value;

use crate::MacosError;
use crate::safari_guard::with_safari_session;

use super::super::core::execute_js_for_profile;
use super::super::script::escape_js_string;
use super::{
    SafariAiResponseResult, SafariConversation, should_skip_grok_response, wait_and_click_send,
};

fn build_grok_go_home_js() -> String {
    r#"(function() {
        window.location.href = "https://grok.com/";
        return "true";
    })()"#
        .to_string()
}

fn build_grok_fill_input_js(prompt: &str) -> String {
    let prompt = escape_js_string(prompt);
    format!(
        r##"(() => {{
            const input = document.querySelector(
              "textarea, textarea[placeholder], div[contenteditable='true'][role='textbox'], div[contenteditable='true']"
            );
            if (!input) throw new Error("grok input not found");
            input.focus();
            if ("value" in input) {{
              input.value = "";
            }} else {{
              input.textContent = "";
            }}
            document.execCommand("insertText", false, "{prompt}");
            return "true";
        }})()"##
    )
}

fn build_grok_click_send_js() -> String {
    r#"(() => {
        const sendLabels = ["send", "ask grok", "提交", "傳送", "送出"];
        const buttons = [...document.querySelectorAll('button,[role="button"]')];
        for (const button of buttons) {
          const label = [
            button.getAttribute("aria-label"),
            button.getAttribute("title"),
            button.getAttribute("data-testid"),
            button.innerText,
            button.textContent
          ].filter(Boolean).join(" ").toLowerCase();
          if (!sendLabels.some((v) => label.includes(v))) continue;
          if (button.disabled || button.getAttribute("aria-disabled") == "true") return "disabled";
          button.click();
          return "true";
        }
        return "false";
    })()"#
        .to_string()
}

fn grok_response_extract_js() -> String {
    r#"(() => {
        const getConversationUrl = () => {
          const url = window.location.href || "";
          if (/grok\.com\/share\//i.test(url)) return url;
          if (/grok\.com\/c\//i.test(url)) return url;
          if (/grok\.com\/history/i.test(url)) return url;
          return null;
        };
        const extractText = (node) => (node?.innerText || node?.textContent || "").trim();
        const selectors = [
          'article',
          '[data-testid*="message"]',
          '[data-testid*="conversation"]',
          'main .prose',
          'main article'
        ];

        let response = "";
        for (const selector of selectors) {
          const nodes = [...document.querySelectorAll(selector)];
          for (let i = nodes.length - 1; i >= 0; i--) {
            const text = extractText(nodes[i]);
            if (text) {
              response = text;
              break;
            }
          }
          if (response) break;
        }

        const controls = [...document.querySelectorAll('button,[role="button"]')]
          .map((button) => [
            button.getAttribute("aria-label"),
            button.getAttribute("title"),
            button.innerText,
            button.textContent
          ].filter(Boolean).join(" "))
          .join(" ");
        const isRunning = /Stop generating|Stop responding|停止生成|停止回應|Thinking\.\.\.|Generating/i.test(controls);

        return JSON.stringify({
          status: response ? (isRunning ? "running" : "complete") : "running",
          response,
          conversation_url: getConversationUrl()
        });
    })()"#
        .to_string()
}

fn grok_list_conversations_js() -> String {
    r#"(() => {
        const items = Array.from(document.querySelectorAll('a[href^="/c/"], a.peer\\/menu-button[href^="/c/"]'));
        const convos = [];
        const seen = new Set();
        for (const a of items) {
          const href = a.getAttribute("href") || "";
          const title = (a.innerText || a.textContent || a.getAttribute("aria-label") || "").trim();
          if (!href || !title) continue;
          const url = href.startsWith("http") ? href : "https://grok.com" + href;
          if (!/grok\.com\/c\//i.test(url)) continue;
          if (seen.has(url)) continue;
          seen.add(url);
          convos.push({ title, url });
        }
        return JSON.stringify(convos);
    })()"#
        .to_string()
}

pub fn ensure_grok_home(profile_filter: Option<&str>) -> Result<(), MacosError> {
    with_safari_session(|| {
        let _ = execute_js_for_profile(
            &build_grok_go_home_js(),
            profile_filter,
            "safari_grok_go_home",
        )?;
        thread::sleep(Duration::from_millis(2500));
        Ok(())
    })
}

pub fn grok_list_conversations(
    profile_filter: Option<&str>,
) -> Result<Vec<SafariConversation>, MacosError> {
    with_safari_session(|| {
        let js = grok_list_conversations_js();
        let deadline = Instant::now() + Duration::from_secs(10);

        while Instant::now() < deadline {
            let raw =
                execute_js_for_profile(&js, profile_filter, "safari_grok_list_conversations")?;
            let items: Vec<SafariConversation> = serde_json::from_str(&raw)
                .map_err(|e| MacosError::Other(format!("failed to parse conversations: {e}")))?;
            if !items.is_empty() {
                return Ok(items);
            }
            thread::sleep(Duration::from_millis(500));
        }

        Ok(Vec::new())
    })
}

pub fn grok_read_conversation(
    url: &str,
    profile_filter: Option<&str>,
) -> Result<SafariAiResponseResult, MacosError> {
    with_safari_session(|| {
        let nav_js = format!(
            r#"(function() {{ window.location.href = "{url}"; return "true"; }})()"#,
            url = escape_js_string(url),
        );
        let _ = execute_js_for_profile(&nav_js, profile_filter, "safari_grok_read_navigate")?;
        thread::sleep(Duration::from_millis(3000));

        let deadline = Instant::now() + Duration::from_secs(30);
        let response_js = grok_response_extract_js();
        let mut last_response = String::new();
        let mut last_conversation_url: Option<String> = None;

        while Instant::now() < deadline {
            thread::sleep(Duration::from_millis(750));
            let payload =
                execute_js_for_profile(&response_js, profile_filter, "safari_grok_read_response")?;
            let value: Value = serde_json::from_str(&payload).map_err(|e| {
                MacosError::Other(format!("failed to parse grok response payload: {e}"))
            })?;

            let status = value
                .get("status")
                .and_then(Value::as_str)
                .unwrap_or("running");
            let response = value
                .get("response")
                .and_then(Value::as_str)
                .unwrap_or("")
                .trim();
            let conversation_url = value
                .get("conversation_url")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned);

            if conversation_url.is_some() {
                last_conversation_url = conversation_url.clone();
            }
            if !response.is_empty() {
                last_response = response.to_string();
            }

            if status == "complete" && !response.is_empty() {
                return Ok(SafariAiResponseResult {
                    provider: "grok".to_string(),
                    status: "complete".to_string(),
                    response: response.to_string(),
                    conversation_url: conversation_url.or_else(|| last_conversation_url.clone()),
                });
            }
        }

        Ok(SafariAiResponseResult {
            provider: "grok".to_string(),
            status: "timeout".to_string(),
            response: last_response,
            conversation_url: last_conversation_url,
        })
    })
}

pub fn send_grok_prompt(
    prompt: &str,
    profile_filter: Option<&str>,
) -> Result<SafariAiResponseResult, MacosError> {
    with_safari_session(|| {
        let filled = execute_js_for_profile(
            &build_grok_fill_input_js(prompt),
            profile_filter,
            "safari_grok_prompt_fill",
        )?;
        if filled.trim() != "true" {
            return Err(MacosError::Other(format!(
                "failed to fill Grok input: {filled}"
            )));
        }

        wait_and_click_send(
            &build_grok_click_send_js(),
            profile_filter,
            "safari_grok_wait_send",
        )?;

        let deadline = Instant::now() + Duration::from_secs(120);
        let response_js = grok_response_extract_js();
        let mut last_response = String::new();
        let mut last_conversation_url: Option<String> = None;

        while Instant::now() < deadline {
            thread::sleep(Duration::from_millis(750));
            let payload =
                execute_js_for_profile(&response_js, profile_filter, "safari_grok_response")?;
            let value: Value = serde_json::from_str(&payload).map_err(|e| {
                MacosError::Other(format!("failed to parse grok response payload: {e}"))
            })?;

            let status = value
                .get("status")
                .and_then(Value::as_str)
                .unwrap_or("running");
            let response = value
                .get("response")
                .and_then(Value::as_str)
                .unwrap_or("")
                .trim();
            let conversation_url = value
                .get("conversation_url")
                .and_then(Value::as_str)
                .map(ToOwned::to_owned);
            if conversation_url.is_some() {
                last_conversation_url = conversation_url.clone();
            }

            let should_skip = should_skip_grok_response(response, prompt);
            if !should_skip {
                last_response = response.to_string();
            }

            if status == "complete" {
                return Ok(SafariAiResponseResult {
                    provider: "grok".to_string(),
                    status: "complete".to_string(),
                    response: if should_skip {
                        last_response.clone()
                    } else {
                        response.to_string()
                    },
                    conversation_url: conversation_url.or_else(|| last_conversation_url.clone()),
                });
            }
        }

        Ok(SafariAiResponseResult {
            provider: "grok".to_string(),
            status: "timeout".to_string(),
            response: last_response,
            conversation_url: last_conversation_url,
        })
    })
}

#[cfg(test)]
mod tests {
    use super::{
        build_grok_click_send_js, build_grok_fill_input_js, build_grok_go_home_js,
        grok_list_conversations_js, grok_response_extract_js,
    };

    #[test]
    fn grok_go_home_script_targets_root_page() {
        let script = build_grok_go_home_js();

        assert!(script.contains("https://grok.com/"));
        assert!(script.contains("window.location.href"));
    }

    #[test]
    fn grok_fill_input_script_targets_prompt_editor() {
        let script = build_grok_fill_input_js("hello from grok");

        assert!(script.contains("textarea"));
        assert!(script.contains("contenteditable='true'"));
        assert!(script.contains("hello from grok"));
    }

    #[test]
    fn grok_click_send_script_checks_accessible_labels() {
        let script = build_grok_click_send_js();

        assert!(script.contains("ask grok"));
        assert!(script.contains("提交"));
        assert!(script.contains("aria-label"));
        assert!(script.contains("data-testid"));
    }

    #[test]
    fn grok_response_extract_script_returns_response_and_conversation_url() {
        let script = grok_response_extract_js();

        assert!(script.contains("grok\\.com\\/share\\/"));
        assert!(script.contains("conversation_url"));
        assert!(script.contains("window.location.href"));
        assert!(script.contains("complete"));
    }

    #[test]
    fn grok_list_script_targets_share_and_history_links() {
        let script = grok_list_conversations_js();

        assert!(script.contains("a[href^=\"/c/\"]"));
        assert!(script.contains("a.peer\\\\/menu-button[href^=\"/c/\"]"));
        assert!(script.contains("https://grok.com"));
    }
}