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}