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}