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