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}