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 crate::MacosError;
use crate::safari_guard::with_safari_session;

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

const BOOTSTRAP: &str = r#"
  if (!window.__cuewardObserve || window.__cuewardObserve.version !== 1) {
    const state = {
      version: 1, started_at: new Date().toISOString(), sequence: 0,
      console: [], network: [], console_dropped: 0, network_dropped: 0
    };
    window.__cuewardObserve = state;
    const append = (kind, record) => {
      const list = state[kind];
      list.push(record);
      if (list.length > 300) {
        list.shift();
        state[kind + '_dropped']++;
      }
    };
    const preview = value => {
      try {
        if (value instanceof Error) return `${value.name}: ${value.message}\n${value.stack || ''}`.slice(0, 4096);
        if (typeof value === 'string') return value.slice(0, 4096);
        const encoded = JSON.stringify(value);
        return (encoded === undefined ? String(value) : encoded).slice(0, 4096);
      } catch (_) { return String(value).slice(0, 4096); }
    };
    for (const level of ['log', 'info', 'warn', 'error', 'debug']) {
      const original = console[level];
      if (typeof original !== 'function') continue;
      console[level] = function(...args) {
        append('console', {
          id: ++state.sequence, level, timestamp: new Date().toISOString(),
          text: args.map(preview).join(' ')
        });
        return original.apply(this, args);
      };
    }
    if (typeof window.fetch === 'function') {
      const originalFetch = window.fetch;
      window.fetch = function(input, init) {
        const id = ++state.sequence;
        const started = performance.now();
        const record = {
          id, type: 'fetch', timestamp: new Date().toISOString(),
          url: String(input?.url || input),
          method: String(init?.method || input?.method || 'GET').toUpperCase(),
          state: 'pending'
        };
        append('network', record);
        let promise;
        try { promise = originalFetch.apply(this, arguments); }
        catch (error) {
          Object.assign(record, {state: 'failed', duration_ms: Math.round(performance.now() - started), error: String(error)});
          throw error;
        }
        return Promise.resolve(promise).then(response => {
          Object.assign(record, {
            state: 'complete', status: response.status, status_text: response.statusText,
            duration_ms: Math.round(performance.now() - started),
            response_headers: Object.fromEntries(response.headers?.entries?.() || [])
          });
          try {
            const type = response.headers?.get?.('content-type') || '';
            if (/text|json|xml|javascript/i.test(type)) {
              response.clone().text().then(body => { record.response_preview = body.slice(0, 16384); }).catch(() => {});
            }
          } catch (_) { /* response body may be inaccessible */ }
          return response;
        }, error => {
          Object.assign(record, {state: 'failed', duration_ms: Math.round(performance.now() - started), error: String(error)});
          throw error;
        });
      };
    }
    if (typeof XMLHttpRequest !== 'undefined') {
      const metadata = new WeakMap();
      const originalOpen = XMLHttpRequest.prototype.open;
      const originalHeader = XMLHttpRequest.prototype.setRequestHeader;
      const originalSend = XMLHttpRequest.prototype.send;
      XMLHttpRequest.prototype.open = function(method, url) {
        metadata.set(this, {method: String(method).toUpperCase(), url: String(url), request_headers: {}});
        return originalOpen.apply(this, arguments);
      };
      XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
        const meta = metadata.get(this);
        if (meta) meta.request_headers[String(name)] = String(value);
        return originalHeader.apply(this, arguments);
      };
      XMLHttpRequest.prototype.send = function() {
        const meta = metadata.get(this) || {method: 'GET', url: '', request_headers: {}};
        const started = performance.now();
        const record = {
          id: ++state.sequence, type: 'xhr', timestamp: new Date().toISOString(),
          url: meta.url, method: meta.method, request_headers: meta.request_headers,
          state: 'pending'
        };
        append('network', record);
        this.addEventListener('loadend', () => {
          Object.assign(record, {
            state: this.status ? 'complete' : 'failed', status: this.status,
            status_text: this.statusText,
            duration_ms: Math.round(performance.now() - started),
            response_headers: this.getAllResponseHeaders()
          });
          try {
            if (!this.responseType || this.responseType === 'text')
              record.response_preview = this.responseText.slice(0, 16384);
          } catch (_) { /* binary response */ }
        }, {once: true});
        try { return originalSend.apply(this, arguments); }
        catch (error) {
          Object.assign(record, {state: 'failed', duration_ms: Math.round(performance.now() - started), error: String(error)});
          throw error;
        }
      };
    }
  }
"#;

fn read_observation(
    query: &str,
    profile_filter: Option<&str>,
    tab_selector: Option<&str>,
) -> Result<Value, MacosError> {
    with_safari_session(|| {
        let tab = resolve_tab(tab_selector, profile_filter)?;
        let script = format!(
            "(() => {{ {BOOTSTRAP} const state = window.__cuewardObserve; return JSON.stringify({query}); }})()"
        );
        let payload = execute_js_in_tab(&script, &tab, "safari_observe")?;
        serde_json::from_str(&payload)
            .map_err(|error| MacosError::Other(format!("invalid Safari observation: {error}")))
    })
}

pub fn console_messages(
    level: Option<&str>,
    profile_filter: Option<&str>,
    tab_selector: Option<&str>,
) -> Result<Value, MacosError> {
    if let Some(level) = level {
        if !["log", "info", "warn", "error", "debug"].contains(&level) {
            return Err(MacosError::Other(format!(
                "unsupported console level: {level}"
            )));
        }
    }
    let filter = serde_json::to_string(&level)
        .map_err(|error| MacosError::Other(format!("invalid console level: {error}")))?;
    read_observation(
        &format!(
            "{{started_at: state.started_at, dropped: state.console_dropped, messages: state.console.filter(item => {filter} === null || item.level === {filter})}}"
        ),
        profile_filter,
        tab_selector,
    )
}

pub fn network_requests(
    id: Option<u64>,
    profile_filter: Option<&str>,
    tab_selector: Option<&str>,
) -> Result<Value, MacosError> {
    let query = match id {
        Some(id) => format!("{{started_at: state.started_at, request: state.network.find(item => item.id === {id}) || null}}"),
        None => "{started_at: state.started_at, dropped: state.network_dropped, requests: state.network.map(({id, type, timestamp, url, method, state: status, status: http_status, duration_ms, error}) => ({id, type, timestamp, url, method, state: status, status: http_status, duration_ms, error}))}".to_string(),
    };
    let result = read_observation(&query, profile_filter, tab_selector)?;
    if id.is_some() && result.get("request").is_some_and(Value::is_null) {
        return Err(MacosError::Other(format!(
            "network request {} not found in current page capture",
            id.unwrap_or_default()
        )));
    }
    Ok(result)
}

#[cfg(test)]
mod tests {
    use std::process::Command;

    use super::BOOTSTRAP;

    #[test]
    fn capture_installs_once_and_records_console_and_fetch() {
        let script = format!(
            r#"
            globalThis.window = globalThis;
            globalThis.fetch = async () => ({{
              status: 201, statusText: 'Created',
              headers: new Map([['content-type', 'text/plain']]),
              clone: () => ({{text: async () => 'captured'}})
            }});
            (() => {{ {BOOTSTRAP} }})();
            (() => {{ {BOOTSTRAP} }})();
            console.error('observed');
            fetch('/test').then(() => setImmediate(() => process.stdout.write(JSON.stringify({{
              messages: window.__cuewardObserve.console,
              requests: window.__cuewardObserve.network
            }}))));
            "#
        );
        let output = Command::new("node")
            .arg("-e")
            .arg(script)
            .output()
            .expect("Node.js is required for Safari JavaScript behavior tests");
        assert!(
            output.status.success(),
            "{}",
            String::from_utf8_lossy(&output.stderr)
        );
        let result: serde_json::Value =
            serde_json::from_slice(&output.stdout).expect("capture JSON");
        assert_eq!(result["messages"].as_array().unwrap().len(), 1);
        assert_eq!(result["messages"][0]["level"], "error");
        assert_eq!(result["requests"][0]["status"], 201);
        assert_eq!(result["requests"][0]["response_preview"], "captured");
    }
}