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