Skip to main content

allwright_surface_desktop/
lib.rs

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