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}