1use allwright_plugin_sdk::{AccessibilitySnapshotInfo, SurfaceFamily, SurfacePluginDescriptor};
2use serde::{Deserialize, Serialize};
3use tokio::time::{Duration, sleep};
4
5pub const SURFACE_ID: &str = "desktop";
6
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9pub enum DesktopPlatform {
10 Mac,
11 Windows,
12}
13
14impl DesktopPlatform {
15 pub fn plugin_id(self) -> &'static str {
16 match self {
17 Self::Mac => "desktop-mac",
18 Self::Windows => "desktop-windows",
19 }
20 }
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct ConnectOptions {
25 pub platform: DesktopPlatform,
26 pub agent_endpoint: Option<String>,
27 pub timeout_ms: Option<u32>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct LaunchOptions {
32 pub app_id: String,
33 pub terminate_running: bool,
34 pub timeout_ms: Option<u32>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct DesktopSessionHandle {
39 pub platform: DesktopPlatform,
40 pub endpoint: String,
41 pub session_id: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct DesktopAppSessionHandle {
46 pub app_session_id: String,
47 pub app_id: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct DesktopAppInfo {
52 pub note: String,
53 pub app_session: DesktopAppSessionHandle,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct DesktopConnectInfo {
58 pub host_name: String,
59 pub note: String,
60 pub desktop_session: DesktopSessionHandle,
61 pub initial_app: DesktopAppInfo,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct DesktopActionInfo {
66 pub selector: String,
67 pub note: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71pub struct DesktopElementCountInfo {
72 pub selector: String,
73 pub count: u32,
74 pub note: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct DesktopTextInfo {
79 pub selector: String,
80 pub text: String,
81 pub note: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85pub struct DesktopWaitInfo {
86 pub selector: String,
87 pub visible: bool,
88 pub note: String,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct DesktopScreenshotInfo {
93 pub png_data: Vec<u8>,
94 pub note: String,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(tag = "command", rename_all = "snake_case")]
99pub enum DesktopCommand {
100 Connect(ConnectOptions),
101 LaunchApp {
102 desktop_session: DesktopSessionHandle,
103 options: LaunchOptions,
104 },
105 CloseApp {
106 desktop_session: DesktopSessionHandle,
107 app_session: DesktopAppSessionHandle,
108 },
109 ClickElement {
110 desktop_session: DesktopSessionHandle,
111 app_session: DesktopAppSessionHandle,
112 selector: String,
113 timeout_ms: Option<u32>,
114 },
115 CountElements {
116 desktop_session: DesktopSessionHandle,
117 app_session: DesktopAppSessionHandle,
118 selector: String,
119 timeout_ms: Option<u32>,
120 },
121 FocusElement {
122 desktop_session: DesktopSessionHandle,
123 app_session: DesktopAppSessionHandle,
124 selector: String,
125 timeout_ms: Option<u32>,
126 },
127 FillElement {
128 desktop_session: DesktopSessionHandle,
129 app_session: DesktopAppSessionHandle,
130 selector: String,
131 value: String,
132 timeout_ms: Option<u32>,
133 },
134 PressKey {
135 desktop_session: DesktopSessionHandle,
136 app_session: DesktopAppSessionHandle,
137 selector: String,
138 key: String,
139 text: Option<String>,
140 timeout_ms: Option<u32>,
141 },
142 GetText {
143 desktop_session: DesktopSessionHandle,
144 app_session: DesktopAppSessionHandle,
145 selector: String,
146 timeout_ms: Option<u32>,
147 },
148 GetInnerText {
149 desktop_session: DesktopSessionHandle,
150 app_session: DesktopAppSessionHandle,
151 selector: String,
152 timeout_ms: Option<u32>,
153 },
154 WaitForSelector {
155 desktop_session: DesktopSessionHandle,
156 app_session: DesktopAppSessionHandle,
157 selector: String,
158 visible: bool,
159 timeout_ms: Option<u32>,
160 },
161 Screenshot {
162 desktop_session: DesktopSessionHandle,
163 app_session: DesktopAppSessionHandle,
164 timeout_ms: Option<u32>,
165 },
166 AccessibilitySnapshot {
167 desktop_session: DesktopSessionHandle,
168 app_session: DesktopAppSessionHandle,
169 format: String,
170 mode: String,
171 },
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(tag = "result", rename_all = "snake_case")]
176pub enum DesktopCommandResult {
177 Connect(DesktopConnectInfo),
178 LaunchApp(DesktopAppInfo),
179 CloseApp,
180 ClickElement(DesktopActionInfo),
181 CountElements(DesktopElementCountInfo),
182 FocusElement(DesktopActionInfo),
183 FillElement(DesktopActionInfo),
184 PressKey(DesktopActionInfo),
185 GetText(DesktopTextInfo),
186 GetInnerText(DesktopTextInfo),
187 WaitForSelector(DesktopWaitInfo),
188 Screenshot(DesktopScreenshotInfo),
189 AccessibilitySnapshot(AccessibilitySnapshotInfo),
190}
191
192pub fn shared_descriptor() -> SurfacePluginDescriptor {
193 SurfacePluginDescriptor {
194 id: SURFACE_ID,
195 family: SurfaceFamily::Desktop,
196 version: env!("CARGO_PKG_VERSION"),
197 description: "Shared desktop surface abstractions for macOS, Windows, and Linux plugins.",
198 }
199}
200
201pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
202 sleep(Duration::from_millis(delay_ms)).await;
203 format!("{label} ready")
204}
205
206pub async fn boot() -> String {
207 boot_surface("desktop", 20).await
208}
209
210pub fn normalize_selector_for_transport(selector: &str) -> String {
211 let trimmed = selector.trim();
212 for prefix in ["css=", "xpath=", "aw=", "uia="] {
213 if let Some(value) = trimmed.strip_prefix(prefix) {
214 if serde_json::from_str::<String>(value).is_ok() {
215 return trimmed.to_string();
216 }
217 return format!("{prefix}{}", serde_json::to_string(value).unwrap());
218 }
219 }
220 if let Some(value) = trimmed.strip_prefix("id=") {
221 return format!(
222 "css={}",
223 serde_json::to_string(&format!("#{value}")).unwrap()
224 );
225 }
226 format!("css={}", serde_json::to_string(trimmed).unwrap())
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[tokio::test]
234 async fn boots_desktop_runtime() {
235 assert_eq!(boot().await, "desktop ready");
236 }
237
238 #[test]
239 fn command_round_trips() {
240 let command = DesktopCommand::Connect(ConnectOptions {
241 platform: DesktopPlatform::Mac,
242 agent_endpoint: None,
243 timeout_ms: Some(1_000),
244 });
245 let json = serde_json::to_string(&command).unwrap();
246 assert_eq!(
247 serde_json::from_str::<DesktopCommand>(&json).unwrap(),
248 command
249 );
250
251 let windows = DesktopCommand::Connect(ConnectOptions {
252 platform: DesktopPlatform::Windows,
253 agent_endpoint: Some("http://127.0.0.1:8300".to_string()),
254 timeout_ms: Some(30_000),
255 });
256 let json = serde_json::to_string(&windows).unwrap();
257 assert_eq!(
258 serde_json::from_str::<DesktopCommand>(&json).unwrap(),
259 windows
260 );
261 }
262}