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 },
188 AccessibilitySnapshot {
189 desktop_session: DesktopSessionHandle,
190 app_session: DesktopAppSessionHandle,
191 format: String,
192 mode: String,
193 },
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
197#[serde(tag = "result", rename_all = "snake_case")]
198pub enum DesktopCommandResult {
199 Connect(DesktopConnectInfo),
200 LaunchApp(DesktopAppInfo),
201 CloseApp,
202 NavigateApp(DesktopNavigationInfo),
203 Capture(CaptureInfo),
204 ClickElement(DesktopActionInfo),
205 CountElements(DesktopElementCountInfo),
206 FocusElement(DesktopActionInfo),
207 FillElement(DesktopActionInfo),
208 PressKey(DesktopActionInfo),
209 GetText(DesktopTextInfo),
210 GetInnerText(DesktopTextInfo),
211 WaitForSelector(DesktopWaitInfo),
212 Screenshot(DesktopScreenshotInfo),
213 AccessibilitySnapshot(AccessibilitySnapshotInfo),
214}
215
216pub fn shared_descriptor() -> SurfacePluginDescriptor {
217 SurfacePluginDescriptor {
218 id: SURFACE_ID,
219 family: SurfaceFamily::Desktop,
220 version: env!("CARGO_PKG_VERSION"),
221 description: "Shared desktop surface abstractions for macOS, Windows, and Linux plugins.",
222 }
223}
224
225pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
226 sleep(Duration::from_millis(delay_ms)).await;
227 format!("{label} ready")
228}
229
230pub async fn boot() -> String {
231 boot_surface("desktop", 20).await
232}
233
234pub fn normalize_selector_for_transport(selector: &str) -> String {
235 let trimmed = selector.trim();
236 for prefix in ["css=", "xpath=", "aw=", "uia="] {
237 if let Some(value) = trimmed.strip_prefix(prefix) {
238 if serde_json::from_str::<String>(value).is_ok() {
239 return trimmed.to_string();
240 }
241 return format!("{prefix}{}", serde_json::to_string(value).unwrap());
242 }
243 }
244 if let Some(value) = trimmed.strip_prefix("id=") {
245 return format!(
246 "css={}",
247 serde_json::to_string(&format!("#{value}")).unwrap()
248 );
249 }
250 format!("css={}", serde_json::to_string(trimmed).unwrap())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[tokio::test]
258 async fn boots_desktop_runtime() {
259 assert_eq!(boot().await, "desktop ready");
260 }
261
262 #[test]
263 fn command_round_trips() {
264 let command = DesktopCommand::Connect(ConnectOptions {
265 platform: DesktopPlatform::Mac,
266 agent_endpoint: None,
267 timeout_ms: Some(1_000),
268 });
269 let json = serde_json::to_string(&command).unwrap();
270 assert_eq!(
271 serde_json::from_str::<DesktopCommand>(&json).unwrap(),
272 command
273 );
274
275 let windows = DesktopCommand::Connect(ConnectOptions {
276 platform: DesktopPlatform::Windows,
277 agent_endpoint: Some("http://127.0.0.1:8300".to_string()),
278 timeout_ms: Some(30_000),
279 });
280 let json = serde_json::to_string(&windows).unwrap();
281 assert_eq!(
282 serde_json::from_str::<DesktopCommand>(&json).unwrap(),
283 windows
284 );
285
286 let navigate = DesktopCommand::NavigateApp {
287 desktop_session: DesktopSessionHandle {
288 platform: DesktopPlatform::Windows,
289 endpoint: "http://127.0.0.1:8300".to_string(),
290 session_id: "desktop-1".to_string(),
291 },
292 app_session: DesktopAppSessionHandle {
293 app_session_id: "app-1".to_string(),
294 app_id: Some("notepad.exe".to_string()),
295 },
296 url: "allwright://flights/42".to_string(),
297 timeout_ms: Some(10_000),
298 };
299 let json = serde_json::to_string(&navigate).unwrap();
300 assert_eq!(
301 serde_json::from_str::<DesktopCommand>(&json).unwrap(),
302 navigate
303 );
304 }
305}