cueward-adapter-macos 0.5.0

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

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};

use super::AccessibilitySurface;
use crate::MacosError;
use crate::screenshot::{CapturableWindow, WindowBounds};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(super) struct WindowIdentity {
    pub window_id: u32,
    pub owner_pid: i32,
    pub title: String,
    pub bounds: WindowBounds,
}

impl From<&CapturableWindow> for WindowIdentity {
    fn from(window: &CapturableWindow) -> Self {
        Self {
            window_id: window.window_id,
            owner_pid: window.owner_pid,
            title: window.title.clone(),
            bounds: window.bounds,
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub(super) struct Target {
    pub version: u8,
    pub issued_at: u64,
    pub window: WindowIdentity,
    #[serde(default)]
    pub surface: AccessibilitySurface,
    pub r#ref: String,
    pub fingerprint: String,
}

impl Target {
    pub fn encode(&self) -> Result<String, MacosError> {
        serde_json::to_vec(self)
            .map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
            .map_err(|error| MacosError::Other(format!("failed to encode window target: {error}")))
    }

    pub fn decode(token: &str, now: u64) -> Result<Self, MacosError> {
        if token.len() > 16_384 {
            return Err(MacosError::Other("window target is too large".into()));
        }
        let bytes = URL_SAFE_NO_PAD
            .decode(token)
            .map_err(|_| MacosError::Other("invalid window target token".into()))?;
        let target: Self = serde_json::from_slice(&bytes)
            .map_err(|_| MacosError::Other("invalid window target token".into()))?;
        let age = now.checked_sub(target.issued_at);
        let supported = matches!(
            (target.version, target.surface),
            (1, AccessibilitySurface::Window) | (2, AccessibilitySurface::Menu)
        );
        if !supported || age.is_none_or(|age| age > 300) {
            return Err(MacosError::Other(
                "window target expired or unsupported; inspect again".into(),
            ));
        }
        let parts: Vec<_> = target.r#ref.split('.').collect();
        if parts.first() != Some(&"0")
            || parts.len() > 13
            || parts.iter().any(|part| part.parse::<usize>().is_err())
            || target.fingerprint.len() != 64
            || !target
                .fingerprint
                .bytes()
                .all(|byte| byte.is_ascii_hexdigit())
        {
            return Err(MacosError::Other("invalid window element identity".into()));
        }
        Ok(target)
    }
}

pub(super) fn now_seconds() -> Result<u64, MacosError> {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs())
        .map_err(|error| MacosError::Other(format!("invalid system clock: {error}")))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn target() -> Target {
        Target {
            version: 1,
            issued_at: 1_000,
            surface: AccessibilitySurface::Window,
            window: WindowIdentity {
                window_id: 42,
                owner_pid: 123,
                title: "Fixture".into(),
                bounds: WindowBounds {
                    x: 0,
                    y: 0,
                    width: 400,
                    height: 300,
                },
            },
            r#ref: "0.1.2".into(),
            fingerprint: "a".repeat(64),
        }
    }

    #[test]
    fn target_expiry_and_clock_changes_require_a_new_inspection() {
        let token = target().encode().expect("token");
        assert!(Target::decode(&token, 1_000).is_ok());
        assert!(Target::decode(&token, 1_300).is_ok());
        assert!(Target::decode(&token, 1_301).is_err());
        assert!(Target::decode(&token, 999).is_err());
    }

    #[test]
    fn malformed_target_cannot_reach_an_action() {
        assert!(Target::decode("invalid!", 1_000).is_err());
        assert!(Target::decode(&"a".repeat(16_385), 1_000).is_err());
        for path in ["", "1", "0..1", "0.-1", "0.text"] {
            let mut target = target();
            target.r#ref = path.into();
            assert!(Target::decode(&target.encode().expect("token"), 1_000).is_err());
        }
        let mut target = target();
        target.version = 2;
        assert!(Target::decode(&target.encode().expect("token"), 1_000).is_err());
    }

    #[test]
    fn menu_targets_require_their_own_version_and_preserve_legacy_window_tokens() {
        let mut legacy = serde_json::to_value(target()).expect("legacy target");
        legacy.as_object_mut().expect("object").remove("surface");
        let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&legacy).expect("legacy JSON"));
        assert_eq!(
            Target::decode(&encoded, 1000).expect("legacy").surface,
            AccessibilitySurface::Window
        );
        let mut menu = target();
        menu.surface = AccessibilitySurface::Menu;
        assert!(Target::decode(&menu.encode().expect("v1 menu"), 1000).is_err());
        menu.version = 2;
        assert_eq!(
            Target::decode(&menu.encode().expect("menu"), 1000)
                .expect("v2")
                .surface,
            AccessibilitySurface::Menu
        );
        menu.surface = AccessibilitySurface::Window;
        assert!(Target::decode(&menu.encode().expect("v2 window"), 1000).is_err());
    }
}