cueward-adapter-macos 0.2.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
Documentation
use std::thread;
use std::time::{Duration, Instant};

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

use super::super::core::{execute_js_for_profile, focus_tab, open};
use super::super::script::escape_js_string;
use super::SocialFeedPost;

fn x_search_url(query: &str) -> String {
    let encoded = urlencoding::encode(query);
    format!("https://x.com/search?q={encoded}&src=typed_query&f=live")
}

fn x_extract_feed_js() -> String {
    r#"(() => {
        const tweets = document.querySelectorAll('article[data-testid="tweet"]');
        const posts = [];
        const seen = new Set();

        for (const tweet of tweets) {
            const timeEl = tweet.querySelector("time");
            const time = timeEl ? (timeEl.getAttribute("datetime") || "") : "";
            const tweetText = tweet.querySelector('div[data-testid="tweetText"]');
            const content = tweetText ? (tweetText.innerText || "").trim() : "";

            const statusLink = tweet.querySelector("a[href*='/status/']");
            let postUrl = statusLink ? (statusLink.href || statusLink.getAttribute("href") || "") : "";
            if (postUrl.startsWith("/")) {
                postUrl = "https://x.com" + postUrl;
            }

            const key = postUrl || content.substring(0, 50);
            if (seen.has(key) || !content) continue;
            seen.add(key);

            const userCell = tweet.querySelector('div[data-testid="User-Name"]');
            const userText = userCell ? (userCell.innerText || "") : "";
            const author = userText.split("\n")[0] || "";
            const handle = (userText.match(/@\w+/) || [""])[0];

            const groups = tweet.querySelectorAll('div[role="group"] button');
            const metrics = [...groups]
                .map(b => (b.getAttribute("aria-label") || "").trim())
                .filter(Boolean);

            posts.push({
                author,
                handle: handle || null,
                time: time || null,
                content: content.substring(0, 500),
                url: postUrl || null,
                metrics
            });
        }
        return JSON.stringify(posts);
    })()"#
        .to_string()
}

fn poll_x_posts(
    timeout_seconds: u64,
    profile_filter: Option<&str>,
) -> Result<Vec<SocialFeedPost>, MacosError> {
    let deadline = Instant::now() + Duration::from_secs(timeout_seconds);
    let js = x_extract_feed_js();

    loop {
        let raw = execute_js_for_profile(&js, profile_filter, "safari_x_feed")?;
        let posts: Vec<SocialFeedPost> = serde_json::from_str(&raw)
            .map_err(|e| MacosError::Other(format!("failed to parse x feed: {e}")))?;

        if !posts.is_empty() || Instant::now() >= deadline {
            return Ok(posts);
        }
        thread::sleep(Duration::from_millis(500));
    }
}

fn navigate_tab_or_open(
    url: &str,
    tab_hint: &str,
    profile_filter: Option<&str>,
    context: &str,
) -> Result<(), MacosError> {
    let nav_js = format!(
        r#"(function() {{ window.location.href = "{url}"; return "true"; }})()"#,
        url = escape_js_string(url),
    );
    if focus_tab(tab_hint, profile_filter).is_ok() {
        let _ = execute_js_for_profile(&nav_js, profile_filter, context)?;
    } else {
        let _ = open(url, profile_filter)?;
    }
    Ok(())
}

pub fn x_extract_feed(profile_filter: Option<&str>) -> Result<Vec<SocialFeedPost>, MacosError> {
    with_safari_session(|| {
        let _ = focus_tab("x.com", profile_filter);
        poll_x_posts(5, profile_filter)
    })
}

pub fn x_search(
    query: &str,
    profile_filter: Option<&str>,
) -> Result<Vec<SocialFeedPost>, MacosError> {
    with_safari_session(|| {
        let url = x_search_url(query);
        navigate_tab_or_open(&url, "x.com", profile_filter, "safari_x_search_navigate")?;
        poll_x_posts(10, profile_filter)
    })
}

pub fn x_read_post(
    url: &str,
    profile_filter: Option<&str>,
) -> Result<Vec<SocialFeedPost>, MacosError> {
    with_safari_session(|| {
        navigate_tab_or_open(url, "x.com", profile_filter, "safari_x_read_navigate")?;
        poll_x_posts(10, profile_filter)
    })
}

#[cfg(test)]
mod tests {
    use super::{x_extract_feed_js, x_search_url};

    #[test]
    fn x_search_url_encodes_query() {
        let url = x_search_url("台灣 AI 最新討論");

        assert_eq!(
            url,
            "https://x.com/search?q=%E5%8F%B0%E7%81%A3%20AI%20%E6%9C%80%E6%96%B0%E8%A8%8E%E8%AB%96&src=typed_query&f=live"
        );
    }

    #[test]
    fn x_extract_feed_script_includes_status_urls() {
        let script = x_extract_feed_js();

        assert!(script.contains("a[href*='/status/']"));
        assert!(script.contains("url: postUrl || null"));
        assert!(script.contains("https://x.com"));
    }
}