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, Eq)]
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    GetText {
173        desktop_session: DesktopSessionHandle,
174        app_session: DesktopAppSessionHandle,
175        selector: String,
176        timeout_ms: Option<u32>,
177    },
178    GetInnerText {
179        desktop_session: DesktopSessionHandle,
180        app_session: DesktopAppSessionHandle,
181        selector: String,
182        timeout_ms: Option<u32>,
183    },
184    WaitForSelector {
185        desktop_session: DesktopSessionHandle,
186        app_session: DesktopAppSessionHandle,
187        selector: String,
188        visible: bool,
189        timeout_ms: Option<u32>,
190    },
191    Screenshot {
192        desktop_session: DesktopSessionHandle,
193        app_session: DesktopAppSessionHandle,
194        timeout_ms: Option<u32>,
195        full_page: bool,
196    },
197    AccessibilitySnapshot {
198        desktop_session: DesktopSessionHandle,
199        app_session: DesktopAppSessionHandle,
200        format: String,
201        mode: String,
202    },
203    InspectorSnapshot {
204        desktop_session: DesktopSessionHandle,
205        app_session: DesktopAppSessionHandle,
206    },
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
210#[serde(tag = "result", rename_all = "snake_case")]
211pub enum DesktopCommandResult {
212    Connect(DesktopConnectInfo),
213    LaunchApp(DesktopAppInfo),
214    CloseApp,
215    NavigateApp(DesktopNavigationInfo),
216    Capture(CaptureInfo),
217    ClickElement(DesktopActionInfo),
218    CountElements(DesktopElementCountInfo),
219    FocusElement(DesktopActionInfo),
220    FillElement(DesktopActionInfo),
221    PressKey(DesktopActionInfo),
222    ScrollElement(DesktopActionInfo),
223    GetText(DesktopTextInfo),
224    GetInnerText(DesktopTextInfo),
225    WaitForSelector(DesktopWaitInfo),
226    Screenshot(DesktopScreenshotInfo),
227    AccessibilitySnapshot(AccessibilitySnapshotInfo),
228    InspectorSnapshot(InspectorSnapshot),
229}
230
231pub fn shared_descriptor() -> SurfacePluginDescriptor {
232    SurfacePluginDescriptor {
233        id: SURFACE_ID,
234        family: SurfaceFamily::Desktop,
235        version: env!("CARGO_PKG_VERSION"),
236        description: "Shared desktop surface abstractions for macOS, Windows, and Linux plugins.",
237    }
238}
239
240pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
241    sleep(Duration::from_millis(delay_ms)).await;
242    format!("{label} ready")
243}
244
245pub async fn boot() -> String {
246    boot_surface("desktop", 20).await
247}
248
249pub fn normalize_selector_for_transport(selector: &str) -> String {
250    let trimmed = selector.trim();
251    for prefix in ["css=", "xpath=", "aw=", "uia="] {
252        if let Some(value) = trimmed.strip_prefix(prefix) {
253            if serde_json::from_str::<String>(value).is_ok() {
254                return trimmed.to_string();
255            }
256            return format!("{prefix}{}", serde_json::to_string(value).unwrap());
257        }
258    }
259    if let Some(value) = trimmed.strip_prefix("id=") {
260        return format!(
261            "css={}",
262            serde_json::to_string(&format!("#{value}")).unwrap()
263        );
264    }
265    format!("css={}", serde_json::to_string(trimmed).unwrap())
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[tokio::test]
273    async fn boots_desktop_runtime() {
274        assert_eq!(boot().await, "desktop ready");
275    }
276
277    #[test]
278    fn command_round_trips() {
279        let command = DesktopCommand::Connect(ConnectOptions {
280            platform: DesktopPlatform::Mac,
281            agent_endpoint: None,
282            timeout_ms: Some(1_000),
283        });
284        let json = serde_json::to_string(&command).unwrap();
285        assert_eq!(
286            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
287            command
288        );
289
290        let windows = DesktopCommand::Connect(ConnectOptions {
291            platform: DesktopPlatform::Windows,
292            agent_endpoint: Some("http://127.0.0.1:8300".to_string()),
293            timeout_ms: Some(30_000),
294        });
295        let json = serde_json::to_string(&windows).unwrap();
296        assert_eq!(
297            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
298            windows
299        );
300
301        let navigate = DesktopCommand::NavigateApp {
302            desktop_session: DesktopSessionHandle {
303                platform: DesktopPlatform::Windows,
304                endpoint: "http://127.0.0.1:8300".to_string(),
305                session_id: "desktop-1".to_string(),
306            },
307            app_session: DesktopAppSessionHandle {
308                app_session_id: "app-1".to_string(),
309                app_id: Some("notepad.exe".to_string()),
310            },
311            url: "allwright://flights/42".to_string(),
312            timeout_ms: Some(10_000),
313        };
314        let json = serde_json::to_string(&navigate).unwrap();
315        assert_eq!(
316            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
317            navigate
318        );
319
320        let screenshot = DesktopCommand::Screenshot {
321            desktop_session: DesktopSessionHandle {
322                platform: DesktopPlatform::Mac,
323                endpoint: "http://127.0.0.1:8200".to_string(),
324                session_id: "desktop-2".to_string(),
325            },
326            app_session: DesktopAppSessionHandle {
327                app_session_id: "app-2".to_string(),
328                app_id: Some("dev.allwright.fixture".to_string()),
329            },
330            timeout_ms: Some(30_000),
331            full_page: true,
332        };
333        let json = serde_json::to_string(&screenshot).unwrap();
334        assert!(json.contains("\"full_page\":true"));
335        assert_eq!(
336            serde_json::from_str::<DesktopCommand>(&json).unwrap(),
337            screenshot
338        );
339    }
340}