cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use serde_json::Value;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

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

use super::interaction::RUNTIME;
use super::target::{execute_js_in_tab, resolve_tab};

static NEXT_SNAPSHOT_ID: AtomicU64 = AtomicU64::new(1);

fn inspect_js(limit: usize, snapshot: &str) -> String {
    let snapshot = serde_json::json!(snapshot);
    format!(
        r#"(() => {{
          {RUNTIME}
          const state = {{snapshot: {snapshot}, nodes: new Map()}};
          window.__cuewardRefState = state;
          const nodes = [];
          let skippedFrames = 0;
          cuewardVisitRoots(document, root => {{
            for (const el of root.querySelectorAll?.('*') || []) {{
              if (el.tagName === 'IFRAME') {{
                try {{ if (!el.contentDocument) skippedFrames += 1; }}
                catch (_) {{ skippedFrames += 1; }}
              }}
              if (nodes.length >= {limit} || el.getClientRects?.().length === 0) continue;
              const tag = (el.tagName || '').toLowerCase();
              const role = el.getAttribute?.('role') || null;
              const interactive = ['a', 'button', 'input', 'textarea', 'select'].includes(tag)
                || ['button', 'link', 'textbox', 'checkbox', 'radio', 'slider', 'menuitem'].includes(role);
              const hasTextChild = [...el.children || []].some(child =>
                (child.innerText || '').trim().length > 0);
              const text = (el.innerText || el.textContent || '').trim().slice(0, 160);
              if (!interactive && (!text || hasTextChild)) continue;
              const labelledBy = (el.getAttribute?.('aria-labelledby') || '').split(/\s+/)
                .map(id => el.ownerDocument.getElementById(id)?.innerText || '').join(' ').trim();
              const labels = Array.from(el.labels || [])
                .map(label => label.innerText || '').join(' ').trim();
              const ref = `${{state.snapshot}}:${{state.nodes.size + 1}}`;
              state.nodes.set(ref, el);
              nodes.push({{
                ref, tag, role,
                name: (el.getAttribute?.('aria-label') || labelledBy || labels ||
                  el.getAttribute?.('title') || el.getAttribute?.('placeholder') || text).slice(0, 160),
                text, href: el.getAttribute?.('href') || null
              }});
            }}
          }});
          return JSON.stringify({{
            url: location.href, title: document.title, snapshot: state.snapshot,
            nodes, skipped_frames: skippedFrames
          }});
        }})()"#
    )
}

/// Read an agent-friendly outline and assign short-lived element references.
pub fn inspect(
    limit: usize,
    profile_filter: Option<&str>,
    tab_selector: Option<&str>,
) -> Result<Value, MacosError> {
    if limit == 0 || limit > 1000 {
        return Err(MacosError::Other(
            "inspect limit must be 1..=1000".to_string(),
        ));
    }
    with_safari_session(|| {
        let tab = resolve_tab(tab_selector, profile_filter)?;
        let clock = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|error| MacosError::Other(format!("system clock before epoch: {error}")))?;
        let snapshot = format!(
            "{:x}-{:x}-{:x}",
            clock.as_nanos(),
            std::process::id(),
            NEXT_SNAPSHOT_ID.fetch_add(1, Ordering::Relaxed)
        );
        let raw = execute_js_in_tab(&inspect_js(limit, &snapshot), &tab, "safari_inspect")?;
        serde_json::from_str(&raw)
            .map_err(|error| MacosError::Other(format!("invalid inspect response: {error}")))
    })
}

fn batch_js(steps: &Value) -> Result<String, MacosError> {
    let mut assertion_cases = String::new();
    if let Some(items) = steps.as_array() {
        for (index, step) in items.iter().enumerate() {
            if step.get("action").and_then(Value::as_str) != Some("assert") {
                continue;
            }
            if let Some(js) = step.get("js") {
                let code = js.as_str().ok_or_else(|| {
                    MacosError::Other(format!("batch assert step {index} js must be a string"))
                })?;
                assertion_cases.push_str(&format!("case {index}: return ({code});\n"));
            }
        }
    }
    let steps = serde_json::to_string(steps)
        .map_err(|error| MacosError::Other(format!("invalid batch steps: {error}")))?;
    Ok(format!(
        r#"(() => {{
          {RUNTIME}
          const steps = {steps};
          const cuewardAssert = (index) => {{
            switch (index) {{
              {assertion_cases}
              default: throw new Error('assertion source missing');
            }}
          }};
          const completed = [];
          for (let index = 0; index < steps.length; index++) {{
            const step = steps[index];
            try {{
              if (step.action === 'assert') {{
                const observed = step.js != null
                  ? cuewardAssert(index) : cuewardResolve(step);
                if (observed?.then) throw new Error('assert requires a synchronous condition');
                const passes = Boolean(observed);
                if (!passes) throw new Error('assertion failed');
              }} else {{
                const el = cuewardResolve(step);
                switch (step.action) {{
                  case 'click': cuewardClick(el); break;
                  case 'fill':
                    if (typeof step.value !== 'string') throw new Error('fill value must be a string');
                    cuewardFill(el, step.value); break;
                  case 'key':
                    if (typeof step.key !== 'string' || !step.key) throw new Error('key must be a string');
                    cuewardKey(el, step.key, step); break;
                  case 'select':
                    if (typeof step.value !== 'string') throw new Error('select value must be a string');
                    cuewardSelect(el, step.value); break;
                  case 'check':
                    if (typeof step.checked !== 'boolean') throw new Error('checked must be a boolean');
                    cuewardCheck(el, step.checked); break;
                  case 'scroll_into_view': cuewardScrollIntoView(el); break;
                  default: throw new Error('unknown action: ' + step.action);
                }}
              }}
              completed.push({{index, action: step.action}});
            }} catch (error) {{
              return JSON.stringify({{ok: false, failed_step: index,
                error: String(error?.message || error), completed}});
            }}
          }}
          return JSON.stringify({{ok: true, completed}});
        }})()"#
    ))
}

/// Execute a sequence of page actions in one JavaScript call.
pub fn batch(
    steps: &Value,
    profile_filter: Option<&str>,
    tab_selector: Option<&str>,
) -> Result<Value, MacosError> {
    let steps_array = steps
        .as_array()
        .ok_or_else(|| MacosError::Other("batch steps must be a JSON array".to_string()))?;
    if steps_array.is_empty() || steps_array.len() > 100 {
        return Err(MacosError::Other(
            "batch requires 1..=100 steps".to_string(),
        ));
    }
    with_safari_session(|| {
        let tab = resolve_tab(tab_selector, profile_filter)?;
        let raw = execute_js_in_tab(&batch_js(steps)?, &tab, "safari_batch")?;
        serde_json::from_str(&raw)
            .map_err(|error| MacosError::Other(format!("invalid batch response: {error}")))
    })
}