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, Default, Serialize, Deserialize, PartialEq)]
106#[serde(rename_all = "camelCase")]
107pub struct DesktopElementBounds {
108    pub x: f64,
109    pub y: f64,
110    pub width: f64,
111    pub height: f64,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115#[serde(rename_all = "camelCase")]
116pub struct DesktopInspectorNode {
117    pub role: String,
118    pub name: String,
119    pub automation_id: String,
120    pub class_name: String,
121    pub enabled: bool,
122    pub offscreen: bool,
123    pub bounds: DesktopElementBounds,
124    #[serde(default)]
125    pub properties: serde_json::Value,
126    #[serde(default)]
127    pub states: serde_json::Value,
128    pub children: Vec<DesktopInspectorNode>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
132pub struct DesktopInspectorSnapshot {
133    pub version: u32,
134    pub platform: String,
135    pub backend: String,
136    pub root: DesktopInspectorNode,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140#[serde(tag = "command", rename_all = "snake_case")]
141pub enum DesktopCommand {
142    Connect(ConnectOptions),
143    LaunchApp {
144        desktop_session: DesktopSessionHandle,
145        options: LaunchOptions,
146    },
147    CloseApp {
148        desktop_session: DesktopSessionHandle,
149        app_session: DesktopAppSessionHandle,
150    },
151    NavigateApp {
152        desktop_session: DesktopSessionHandle,
153        app_session: DesktopAppSessionHandle,
154        url: String,
155        timeout_ms: Option<u32>,
156    },
157    Capture {
158        desktop_session: DesktopSessionHandle,
159        app_session: DesktopAppSessionHandle,
160        kind: String,
161        selector: String,
162        attribute_name: String,
163        timeout_ms: Option<u32>,
164    },
165    ClickElement {
166        desktop_session: DesktopSessionHandle,
167        app_session: DesktopAppSessionHandle,
168        selector: String,
169        timeout_ms: Option<u32>,
170    },
171    CountElements {
172        desktop_session: DesktopSessionHandle,
173        app_session: DesktopAppSessionHandle,
174        selector: String,
175        timeout_ms: Option<u32>,
176    },
177    FocusElement {
178        desktop_session: DesktopSessionHandle,
179        app_session: DesktopAppSessionHandle,
180        selector: String,
181        timeout_ms: Option<u32>,
182    },
183    FillElement {
184        desktop_session: DesktopSessionHandle,
185        app_session: DesktopAppSessionHandle,
186        selector: String,
187        value: String,
188        timeout_ms: Option<u32>,
189    },
190    PressKey {
191        desktop_session: DesktopSessionHandle,
192        app_session: DesktopAppSessionHandle,
193        selector: String,
194        key: String,
195        text: Option<String>,
196        timeout_ms: Option<u32>,
197    },
198    GetText {
199        desktop_session: DesktopSessionHandle,
200        app_session: DesktopAppSessionHandle,
201        selector: String,
202        timeout_ms: Option<u32>,
203    },
204    GetInnerText {
205        desktop_session: DesktopSessionHandle,
206        app_session: DesktopAppSessionHandle,
207        selector: String,
208        timeout_ms: Option<u32>,
209    },
210    WaitForSelector {
211        desktop_session: DesktopSessionHandle,
212        app_session: DesktopAppSessionHandle,
213        selector: String,
214        visible: bool,
215        timeout_ms: Option<u32>,
216    },
217    Screenshot {
218        desktop_session: DesktopSessionHandle,
219        app_session: DesktopAppSessionHandle,
220        timeout_ms: Option<u32>,
221        full_page: bool,
222    },
223    AccessibilitySnapshot {
224        desktop_session: DesktopSessionHandle,
225        app_session: DesktopAppSessionHandle,
226        format: String,
227        mode: String,
228    },
229    InspectorSnapshot {
230        desktop_session: DesktopSessionHandle,
231        app_session: DesktopAppSessionHandle,
232    },
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
236#[serde(tag = "result", rename_all = "snake_case")]
237pub enum DesktopCommandResult {
238    Connect(DesktopConnectInfo),
239    LaunchApp(DesktopAppInfo),
240    CloseApp,
241    NavigateApp(DesktopNavigationInfo),
242    Capture(CaptureInfo),
243    ClickElement(DesktopActionInfo),
244    CountElements(DesktopElementCountInfo),
245    FocusElement(DesktopActionInfo),
246    FillElement(DesktopActionInfo),
247    PressKey(DesktopActionInfo),
248    GetText(DesktopTextInfo),
249    GetInnerText(DesktopTextInfo),
250    WaitForSelector(DesktopWaitInfo),
251    Screenshot(DesktopScreenshotInfo),
252    AccessibilitySnapshot(AccessibilitySnapshotInfo),
253    InspectorSnapshot(DesktopInspectorSnapshot),
254}
255
256pub fn shared_descriptor() -> SurfacePluginDescriptor {
257    SurfacePluginDescriptor {
258        id: SURFACE_ID,
259        family: SurfaceFamily::Desktop,
260        version: env!("CARGO_PKG_VERSION"),
261        description: "Shared desktop surface abstractions for macOS, Windows, and Linux plugins.",
262    }
263}
264
265pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
266    sleep(Duration::from_millis(delay_ms)).await;
267    format!("{label} ready")
268}
269
270pub async fn boot() -> String {
271    boot_surface("desktop", 20).await
272}
273
274pub fn normalize_selector_for_transport(selector: &str) -> String {
275    let trimmed = selector.trim();
276    for prefix in ["css=", "xpath=", "aw=", "uia="] {
277        if let Some(value) = trimmed.strip_prefix(prefix) {
278            if serde_json::from_str::<String>(value).is_ok() {
279                return trimmed.to_string();
280            }
281            return format!("{prefix}{}", serde_json::to_string(value).unwrap());
282        }
283    }
284    if let Some(value) = trimmed.strip_prefix("id=") {
285        return format!(
286            "css={}",
287            serde_json::to_string(&format!("#{value}")).unwrap()
288        );
289    }
290    format!("css={}", serde_json::to_string(trimmed).unwrap())
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[tokio::test]
298    async fn boots_desktop_runtime() {
299        assert_eq!(boot().await, "desktop ready");
300    }
301
302    #[test]
303    fn command_round_trips() {
304        let command = DesktopCommand::Connect(ConnectOptions {
305            platform: DesktopPlatform::Mac,
306            agent_endpoint: None,
307            timeout_ms: Some(1_000),
308        });
309        let json = serde_json::to_string(&command).unwrap();
310        assert_eq!(
311            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
312            command
313        );
314
315        let windows = DesktopCommand::Connect(ConnectOptions {
316            platform: DesktopPlatform::Windows,
317            agent_endpoint: Some("http://127.0.0.1:8300".to_string()),
318            timeout_ms: Some(30_000),
319        });
320        let json = serde_json::to_string(&windows).unwrap();
321        assert_eq!(
322            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
323            windows
324        );
325
326        let navigate = DesktopCommand::NavigateApp {
327            desktop_session: DesktopSessionHandle {
328                platform: DesktopPlatform::Windows,
329                endpoint: "http://127.0.0.1:8300".to_string(),
330                session_id: "desktop-1".to_string(),
331            },
332            app_session: DesktopAppSessionHandle {
333                app_session_id: "app-1".to_string(),
334                app_id: Some("notepad.exe".to_string()),
335            },
336            url: "allwright://flights/42".to_string(),
337            timeout_ms: Some(10_000),
338        };
339        let json = serde_json::to_string(&navigate).unwrap();
340        assert_eq!(
341            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
342            navigate
343        );
344
345        let screenshot = DesktopCommand::Screenshot {
346            desktop_session: DesktopSessionHandle {
347                platform: DesktopPlatform::Mac,
348                endpoint: "http://127.0.0.1:8200".to_string(),
349                session_id: "desktop-2".to_string(),
350            },
351            app_session: DesktopAppSessionHandle {
352                app_session_id: "app-2".to_string(),
353                app_id: Some("dev.allwright.fixture".to_string()),
354            },
355            timeout_ms: Some(30_000),
356            full_page: true,
357        };
358        let json = serde_json::to_string(&screenshot).unwrap();
359        assert!(json.contains("\"full_page\":true"));
360        assert_eq!(
361            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
362            screenshot
363        );
364    }
365}