allwright-surface-desktop 0.1.30

Shared desktop surface abstractions for allwright plugins.
Documentation
use allwright_plugin_sdk::{
    AccessibilitySnapshotInfo, CaptureInfo, SurfaceFamily, SurfacePluginDescriptor,
};
use serde::{Deserialize, Serialize};
use tokio::time::{Duration, sleep};

pub const SURFACE_ID: &str = "desktop";

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DesktopPlatform {
    Mac,
    Windows,
}

impl DesktopPlatform {
    pub fn plugin_id(self) -> &'static str {
        match self {
            Self::Mac => "desktop-mac",
            Self::Windows => "desktop-windows",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ConnectOptions {
    pub platform: DesktopPlatform,
    pub agent_endpoint: Option<String>,
    pub timeout_ms: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LaunchOptions {
    pub app_id: String,
    pub terminate_running: bool,
    pub timeout_ms: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopSessionHandle {
    pub platform: DesktopPlatform,
    pub endpoint: String,
    pub session_id: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopAppSessionHandle {
    pub app_session_id: String,
    pub app_id: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopAppInfo {
    pub note: String,
    pub app_session: DesktopAppSessionHandle,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopConnectInfo {
    pub host_name: String,
    pub note: String,
    pub desktop_session: DesktopSessionHandle,
    pub initial_app: DesktopAppInfo,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopActionInfo {
    pub selector: String,
    pub note: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopNavigationInfo {
    pub url: String,
    pub note: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopElementCountInfo {
    pub selector: String,
    pub count: u32,
    pub note: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopTextInfo {
    pub selector: String,
    pub text: String,
    pub note: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopWaitInfo {
    pub selector: String,
    pub visible: bool,
    pub note: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DesktopScreenshotInfo {
    pub png_data: Vec<u8>,
    pub note: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DesktopElementBounds {
    pub x: f64,
    pub y: f64,
    pub width: f64,
    pub height: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DesktopInspectorNode {
    pub role: String,
    pub name: String,
    pub automation_id: String,
    pub class_name: String,
    pub enabled: bool,
    pub offscreen: bool,
    pub bounds: DesktopElementBounds,
    #[serde(default)]
    pub properties: serde_json::Value,
    #[serde(default)]
    pub states: serde_json::Value,
    pub children: Vec<DesktopInspectorNode>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DesktopInspectorSnapshot {
    pub version: u32,
    pub platform: String,
    pub backend: String,
    pub root: DesktopInspectorNode,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "command", rename_all = "snake_case")]
pub enum DesktopCommand {
    Connect(ConnectOptions),
    LaunchApp {
        desktop_session: DesktopSessionHandle,
        options: LaunchOptions,
    },
    CloseApp {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
    },
    NavigateApp {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        url: String,
        timeout_ms: Option<u32>,
    },
    Capture {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        kind: String,
        selector: String,
        attribute_name: String,
        timeout_ms: Option<u32>,
    },
    ClickElement {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        selector: String,
        timeout_ms: Option<u32>,
    },
    CountElements {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        selector: String,
        timeout_ms: Option<u32>,
    },
    FocusElement {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        selector: String,
        timeout_ms: Option<u32>,
    },
    FillElement {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        selector: String,
        value: String,
        timeout_ms: Option<u32>,
    },
    PressKey {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        selector: String,
        key: String,
        text: Option<String>,
        timeout_ms: Option<u32>,
    },
    GetText {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        selector: String,
        timeout_ms: Option<u32>,
    },
    GetInnerText {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        selector: String,
        timeout_ms: Option<u32>,
    },
    WaitForSelector {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        selector: String,
        visible: bool,
        timeout_ms: Option<u32>,
    },
    Screenshot {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        timeout_ms: Option<u32>,
        full_page: bool,
    },
    AccessibilitySnapshot {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
        format: String,
        mode: String,
    },
    InspectorSnapshot {
        desktop_session: DesktopSessionHandle,
        app_session: DesktopAppSessionHandle,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "result", rename_all = "snake_case")]
pub enum DesktopCommandResult {
    Connect(DesktopConnectInfo),
    LaunchApp(DesktopAppInfo),
    CloseApp,
    NavigateApp(DesktopNavigationInfo),
    Capture(CaptureInfo),
    ClickElement(DesktopActionInfo),
    CountElements(DesktopElementCountInfo),
    FocusElement(DesktopActionInfo),
    FillElement(DesktopActionInfo),
    PressKey(DesktopActionInfo),
    GetText(DesktopTextInfo),
    GetInnerText(DesktopTextInfo),
    WaitForSelector(DesktopWaitInfo),
    Screenshot(DesktopScreenshotInfo),
    AccessibilitySnapshot(AccessibilitySnapshotInfo),
    InspectorSnapshot(DesktopInspectorSnapshot),
}

pub fn shared_descriptor() -> SurfacePluginDescriptor {
    SurfacePluginDescriptor {
        id: SURFACE_ID,
        family: SurfaceFamily::Desktop,
        version: env!("CARGO_PKG_VERSION"),
        description: "Shared desktop surface abstractions for macOS, Windows, and Linux plugins.",
    }
}

pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
    sleep(Duration::from_millis(delay_ms)).await;
    format!("{label} ready")
}

pub async fn boot() -> String {
    boot_surface("desktop", 20).await
}

pub fn normalize_selector_for_transport(selector: &str) -> String {
    let trimmed = selector.trim();
    for prefix in ["css=", "xpath=", "aw=", "uia="] {
        if let Some(value) = trimmed.strip_prefix(prefix) {
            if serde_json::from_str::<String>(value).is_ok() {
                return trimmed.to_string();
            }
            return format!("{prefix}{}", serde_json::to_string(value).unwrap());
        }
    }
    if let Some(value) = trimmed.strip_prefix("id=") {
        return format!(
            "css={}",
            serde_json::to_string(&format!("#{value}")).unwrap()
        );
    }
    format!("css={}", serde_json::to_string(trimmed).unwrap())
}

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

    #[tokio::test]
    async fn boots_desktop_runtime() {
        assert_eq!(boot().await, "desktop ready");
    }

    #[test]
    fn command_round_trips() {
        let command = DesktopCommand::Connect(ConnectOptions {
            platform: DesktopPlatform::Mac,
            agent_endpoint: None,
            timeout_ms: Some(1_000),
        });
        let json = serde_json::to_string(&command).unwrap();
        assert_eq!(
            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
            command
        );

        let windows = DesktopCommand::Connect(ConnectOptions {
            platform: DesktopPlatform::Windows,
            agent_endpoint: Some("http://127.0.0.1:8300".to_string()),
            timeout_ms: Some(30_000),
        });
        let json = serde_json::to_string(&windows).unwrap();
        assert_eq!(
            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
            windows
        );

        let navigate = DesktopCommand::NavigateApp {
            desktop_session: DesktopSessionHandle {
                platform: DesktopPlatform::Windows,
                endpoint: "http://127.0.0.1:8300".to_string(),
                session_id: "desktop-1".to_string(),
            },
            app_session: DesktopAppSessionHandle {
                app_session_id: "app-1".to_string(),
                app_id: Some("notepad.exe".to_string()),
            },
            url: "allwright://flights/42".to_string(),
            timeout_ms: Some(10_000),
        };
        let json = serde_json::to_string(&navigate).unwrap();
        assert_eq!(
            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
            navigate
        );

        let screenshot = DesktopCommand::Screenshot {
            desktop_session: DesktopSessionHandle {
                platform: DesktopPlatform::Mac,
                endpoint: "http://127.0.0.1:8200".to_string(),
                session_id: "desktop-2".to_string(),
            },
            app_session: DesktopAppSessionHandle {
                app_session_id: "app-2".to_string(),
                app_id: Some("dev.allwright.fixture".to_string()),
            },
            timeout_ms: Some(30_000),
            full_page: true,
        };
        let json = serde_json::to_string(&screenshot).unwrap();
        assert!(json.contains("\"full_page\":true"));
        assert_eq!(
            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
            screenshot
        );
    }
}