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}