Skip to main content

allwright_surface_desktop/
lib.rs

1use allwright_plugin_sdk::{
2    AccessibilitySnapshotInfo, CaptureInfo, SurfaceFamily, SurfacePluginDescriptor,
3};
4use serde::{Deserialize, Serialize};
5use tokio::time::{Duration, sleep};
6
7pub const SURFACE_ID: &str = "desktop";
8
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum DesktopPlatform {
12    Mac,
13    Windows,
14}
15
16impl DesktopPlatform {
17    pub fn plugin_id(self) -> &'static str {
18        match self {
19            Self::Mac => "desktop-mac",
20            Self::Windows => "desktop-windows",
21        }
22    }
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct ConnectOptions {
27    pub platform: DesktopPlatform,
28    pub agent_endpoint: Option<String>,
29    pub timeout_ms: Option<u32>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct LaunchOptions {
34    pub app_id: String,
35    pub terminate_running: bool,
36    pub timeout_ms: Option<u32>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct DesktopSessionHandle {
41    pub platform: DesktopPlatform,
42    pub endpoint: String,
43    pub session_id: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct DesktopAppSessionHandle {
48    pub app_session_id: String,
49    pub app_id: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct DesktopAppInfo {
54    pub note: String,
55    pub app_session: DesktopAppSessionHandle,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct DesktopConnectInfo {
60    pub host_name: String,
61    pub note: String,
62    pub desktop_session: DesktopSessionHandle,
63    pub initial_app: DesktopAppInfo,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct DesktopActionInfo {
68    pub selector: String,
69    pub note: String,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct DesktopNavigationInfo {
74    pub url: String,
75    pub note: String,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct DesktopElementCountInfo {
80    pub selector: String,
81    pub count: u32,
82    pub note: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
86pub struct DesktopTextInfo {
87    pub selector: String,
88    pub text: String,
89    pub note: String,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct DesktopWaitInfo {
94    pub selector: String,
95    pub visible: bool,
96    pub note: String,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100pub struct DesktopScreenshotInfo {
101    pub png_data: Vec<u8>,
102    pub note: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[serde(tag = "command", rename_all = "snake_case")]
107pub enum DesktopCommand {
108    Connect(ConnectOptions),
109    LaunchApp {
110        desktop_session: DesktopSessionHandle,
111        options: LaunchOptions,
112    },
113    CloseApp {
114        desktop_session: DesktopSessionHandle,
115        app_session: DesktopAppSessionHandle,
116    },
117    NavigateApp {
118        desktop_session: DesktopSessionHandle,
119        app_session: DesktopAppSessionHandle,
120        url: String,
121        timeout_ms: Option<u32>,
122    },
123    Capture {
124        desktop_session: DesktopSessionHandle,
125        app_session: DesktopAppSessionHandle,
126        kind: String,
127        selector: String,
128        attribute_name: String,
129        timeout_ms: Option<u32>,
130    },
131    ClickElement {
132        desktop_session: DesktopSessionHandle,
133        app_session: DesktopAppSessionHandle,
134        selector: String,
135        timeout_ms: Option<u32>,
136    },
137    CountElements {
138        desktop_session: DesktopSessionHandle,
139        app_session: DesktopAppSessionHandle,
140        selector: String,
141        timeout_ms: Option<u32>,
142    },
143    FocusElement {
144        desktop_session: DesktopSessionHandle,
145        app_session: DesktopAppSessionHandle,
146        selector: String,
147        timeout_ms: Option<u32>,
148    },
149    FillElement {
150        desktop_session: DesktopSessionHandle,
151        app_session: DesktopAppSessionHandle,
152        selector: String,
153        value: String,
154        timeout_ms: Option<u32>,
155    },
156    PressKey {
157        desktop_session: DesktopSessionHandle,
158        app_session: DesktopAppSessionHandle,
159        selector: String,
160        key: String,
161        text: Option<String>,
162        timeout_ms: Option<u32>,
163    },
164    GetText {
165        desktop_session: DesktopSessionHandle,
166        app_session: DesktopAppSessionHandle,
167        selector: String,
168        timeout_ms: Option<u32>,
169    },
170    GetInnerText {
171        desktop_session: DesktopSessionHandle,
172        app_session: DesktopAppSessionHandle,
173        selector: String,
174        timeout_ms: Option<u32>,
175    },
176    WaitForSelector {
177        desktop_session: DesktopSessionHandle,
178        app_session: DesktopAppSessionHandle,
179        selector: String,
180        visible: bool,
181        timeout_ms: Option<u32>,
182    },
183    Screenshot {
184        desktop_session: DesktopSessionHandle,
185        app_session: DesktopAppSessionHandle,
186        timeout_ms: Option<u32>,
187    },
188    AccessibilitySnapshot {
189        desktop_session: DesktopSessionHandle,
190        app_session: DesktopAppSessionHandle,
191        format: String,
192        mode: String,
193    },
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
197#[serde(tag = "result", rename_all = "snake_case")]
198pub enum DesktopCommandResult {
199    Connect(DesktopConnectInfo),
200    LaunchApp(DesktopAppInfo),
201    CloseApp,
202    NavigateApp(DesktopNavigationInfo),
203    Capture(CaptureInfo),
204    ClickElement(DesktopActionInfo),
205    CountElements(DesktopElementCountInfo),
206    FocusElement(DesktopActionInfo),
207    FillElement(DesktopActionInfo),
208    PressKey(DesktopActionInfo),
209    GetText(DesktopTextInfo),
210    GetInnerText(DesktopTextInfo),
211    WaitForSelector(DesktopWaitInfo),
212    Screenshot(DesktopScreenshotInfo),
213    AccessibilitySnapshot(AccessibilitySnapshotInfo),
214}
215
216pub fn shared_descriptor() -> SurfacePluginDescriptor {
217    SurfacePluginDescriptor {
218        id: SURFACE_ID,
219        family: SurfaceFamily::Desktop,
220        version: env!("CARGO_PKG_VERSION"),
221        description: "Shared desktop surface abstractions for macOS, Windows, and Linux plugins.",
222    }
223}
224
225pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
226    sleep(Duration::from_millis(delay_ms)).await;
227    format!("{label} ready")
228}
229
230pub async fn boot() -> String {
231    boot_surface("desktop", 20).await
232}
233
234pub fn normalize_selector_for_transport(selector: &str) -> String {
235    let trimmed = selector.trim();
236    for prefix in ["css=", "xpath=", "aw=", "uia="] {
237        if let Some(value) = trimmed.strip_prefix(prefix) {
238            if serde_json::from_str::<String>(value).is_ok() {
239                return trimmed.to_string();
240            }
241            return format!("{prefix}{}", serde_json::to_string(value).unwrap());
242        }
243    }
244    if let Some(value) = trimmed.strip_prefix("id=") {
245        return format!(
246            "css={}",
247            serde_json::to_string(&format!("#{value}")).unwrap()
248        );
249    }
250    format!("css={}", serde_json::to_string(trimmed).unwrap())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[tokio::test]
258    async fn boots_desktop_runtime() {
259        assert_eq!(boot().await, "desktop ready");
260    }
261
262    #[test]
263    fn command_round_trips() {
264        let command = DesktopCommand::Connect(ConnectOptions {
265            platform: DesktopPlatform::Mac,
266            agent_endpoint: None,
267            timeout_ms: Some(1_000),
268        });
269        let json = serde_json::to_string(&command).unwrap();
270        assert_eq!(
271            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
272            command
273        );
274
275        let windows = DesktopCommand::Connect(ConnectOptions {
276            platform: DesktopPlatform::Windows,
277            agent_endpoint: Some("http://127.0.0.1:8300".to_string()),
278            timeout_ms: Some(30_000),
279        });
280        let json = serde_json::to_string(&windows).unwrap();
281        assert_eq!(
282            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
283            windows
284        );
285
286        let navigate = DesktopCommand::NavigateApp {
287            desktop_session: DesktopSessionHandle {
288                platform: DesktopPlatform::Windows,
289                endpoint: "http://127.0.0.1:8300".to_string(),
290                session_id: "desktop-1".to_string(),
291            },
292            app_session: DesktopAppSessionHandle {
293                app_session_id: "app-1".to_string(),
294                app_id: Some("notepad.exe".to_string()),
295            },
296            url: "allwright://flights/42".to_string(),
297            timeout_ms: Some(10_000),
298        };
299        let json = serde_json::to_string(&navigate).unwrap();
300        assert_eq!(
301            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
302            navigate
303        );
304    }
305}