cueward-adapter-macos 0.5.0

macOS adapter for Cueward with Safari, Notes, Messages, Reminders, Calendar, Screenshot, Clipboard, and OCR integrations.
use crate::MacosError;

use super::super::super::eval::exec;
use super::super::super::interaction::RUNTIME;

fn build_effort_js(choice: &str) -> Result<String, MacosError> {
    if choice != "pro" && choice.parse::<u32>().is_err() {
        return Err(MacosError::Other(
            "ChatGPT --effort must be a slider number or 'pro'".to_string(),
        ));
    }
    let choice = serde_json::to_string(choice)
        .map_err(|error| MacosError::Other(format!("invalid effort: {error}")))?;
    Ok(format!(
        r#"
        {RUNTIME}
        const choice = {choice};
        const pill = document.querySelector('button.__composer-pill');
        if (!pill) throw new Error('ChatGPT effort control not found');
        if (pill.getAttribute('aria-expanded') !== 'true') cuewardClick(pill);
        let slider;
        for (let attempt = 0; attempt < 10; attempt++) {{
          slider = document.querySelector('[role="menu"] [role="slider"]');
          if (slider) break;
          await new Promise(resolve => setTimeout(resolve, 100));
        }}
        if (!slider) throw new Error('ChatGPT effort slider not found');
        const min = Number(slider.getAttribute('aria-valuemin'));
        const max = Number(slider.getAttribute('aria-valuemax'));
        let current = Number(slider.getAttribute('aria-valuenow'));
        if (![min, max, current].every(Number.isInteger) || min > max || current < min || current > max)
          throw new Error('ChatGPT effort slider has invalid ARIA values');
        const target = choice === 'pro' ? max : Number(choice);
        if (!Number.isInteger(target) || target < min || target > max)
          throw new Error(`ChatGPT effort ${{choice}} is outside slider range ${{min}}-${{max}}`);
        slider.focus();
        for (let attempt = 0; current !== target && attempt <= max - min; attempt++) {{
          const key = current < target ? 'ArrowRight' : 'ArrowLeft';
          const keyCode = key === 'ArrowRight' ? 39 : 37;
          const event = type => new KeyboardEvent(type, {{
            key, code: key, keyCode, which: keyCode,
            bubbles: true, cancelable: true, composed: true
          }});
          slider.dispatchEvent(event('keydown'));
          slider.dispatchEvent(event('keyup'));
          await new Promise(resolve => setTimeout(resolve, 100));
          const next = Number(slider.getAttribute('aria-valuenow'));
          if (!Number.isInteger(next) || next === current)
            throw new Error('ChatGPT effort slider did not respond to arrow key');
          current = next;
        }}
        if (current !== target) throw new Error('ChatGPT effort slider did not reach requested level');
        return {{level: current, min, max}};
        "#
    ))
}

pub fn set_chatgpt_effort(choice: &str, profile_filter: Option<&str>) -> Result<(), MacosError> {
    let code = build_effort_js(choice)?;
    let value = exec(&code, profile_filter, None, 10, true)?.result;
    if value
        .get("level")
        .and_then(serde_json::Value::as_i64)
        .is_none()
    {
        return Err(MacosError::Other(
            "ChatGPT effort result did not contain a level".to_string(),
        ));
    }
    Ok(())
}

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

    use super::build_effort_js;

    #[test]
    fn validates_choice_before_browser_interaction() {
        assert!(build_effort_js("pro").is_ok());
        assert!(build_effort_js("4").is_ok());
        assert!(build_effort_js("invalid").is_err());
        assert!(build_effort_js("-1").is_err());
    }

    #[test]
    fn moves_slider_to_dynamic_maximum() {
        let code = build_effort_js("pro").expect("effort script");
        let script = format!(
            r#"
            globalThis.window = globalThis;
            globalThis.PointerEvent = class {{ constructor(type, options) {{ this.type = type; Object.assign(this, options); }} }};
            globalThis.MouseEvent = globalThis.PointerEvent;
            globalThis.KeyboardEvent = globalThis.PointerEvent;
            let expanded = false;
            let value = 3;
            const pill = {{
              matches: () => false,
              getAttribute: name => name === 'aria-expanded' ? String(expanded) : null,
              dispatchEvent: event => {{ if (event.type === 'pointerdown') expanded = true; }},
              click: () => {{}}
            }};
            const slider = {{
              getAttribute: name => ({{'aria-valuemin': '0', 'aria-valuemax': '4', 'aria-valuenow': String(value)}})[name],
              focus: () => {{}},
              dispatchEvent: event => {{ if (event.type === 'keydown') value += event.key === 'ArrowRight' ? 1 : -1; }}
            }};
            globalThis.document = {{querySelector: selector =>
              selector === 'button.__composer-pill' ? pill :
              selector === '[role="menu"] [role="slider"]' && expanded ? slider : null}};
            (async () => {{ {code} }})().then(result => process.stdout.write(JSON.stringify(result)));
            "#
        );
        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("effort JSON");
        assert_eq!(result["level"], 4);
        assert_eq!(result["max"], 4);
    }
}