@forsion/tangu-computer-use 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/LICENSE +26 -0
  3. package/LICENSE.upstream +21 -0
  4. package/README.md +169 -0
  5. package/UPSTREAM.md +277 -0
  6. package/check.mjs +369 -0
  7. package/icon.png +0 -0
  8. package/install.sh +112 -0
  9. package/main.js +541 -0
  10. package/manifest.json +47 -0
  11. package/native/linux/bridge-rs/Cargo.lock +1204 -0
  12. package/native/linux/bridge-rs/Cargo.toml +18 -0
  13. package/native/linux/bridge-rs/src/atspi.rs +640 -0
  14. package/native/linux/bridge-rs/src/error.rs +65 -0
  15. package/native/linux/bridge-rs/src/lib.rs +11 -0
  16. package/native/linux/bridge-rs/src/main.rs +1074 -0
  17. package/native/linux/bridge-rs/src/protocol.rs +49 -0
  18. package/native/linux/bridge-rs/src/state.rs +293 -0
  19. package/native/linux/bridge-rs/src/wayland.rs +89 -0
  20. package/native/linux/bridge-rs/src/x11.rs +909 -0
  21. package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
  22. package/native/macos/agent_cursor.swift +184 -0
  23. package/native/macos/agent_cursor_motion.swift +252 -0
  24. package/native/macos/agent_cursor_tests.swift +128 -0
  25. package/native/macos/agent_highlight.swift +229 -0
  26. package/native/macos/agent_highlight_tests.swift +99 -0
  27. package/native/macos/bridge.swift +3852 -0
  28. package/native/macos/foreground_activity.swift +57 -0
  29. package/native/macos/foreground_activity_tests.swift +31 -0
  30. package/native/macos/live_stream.swift +289 -0
  31. package/native/windows/bridge-rs/Cargo.lock +396 -0
  32. package/native/windows/bridge-rs/Cargo.toml +30 -0
  33. package/native/windows/bridge-rs/src/capture.rs +518 -0
  34. package/native/windows/bridge-rs/src/error.rs +81 -0
  35. package/native/windows/bridge-rs/src/input.rs +501 -0
  36. package/native/windows/bridge-rs/src/lib.rs +16 -0
  37. package/native/windows/bridge-rs/src/main.rs +1683 -0
  38. package/native/windows/bridge-rs/src/protocol.rs +53 -0
  39. package/native/windows/bridge-rs/src/refs.rs +237 -0
  40. package/native/windows/bridge-rs/src/state.rs +55 -0
  41. package/native/windows/bridge-rs/src/uia.rs +1252 -0
  42. package/native/windows/bridge-rs/src/window.rs +718 -0
  43. package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
  44. package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
  45. package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
  46. package/package.json +74 -0
  47. package/prebuilt/linux/arm64/linux-bridge +0 -0
  48. package/prebuilt/linux/x64/linux-bridge +0 -0
  49. package/prebuilt/macos/arm64/bridge +0 -0
  50. package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
  51. package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
  52. package/prebuilt/macos/x64/bridge +0 -0
  53. package/prebuilt/macos/x64/tangu-computer-use.app.json +10 -0
  54. package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
  55. package/prebuilt/windows/windows-bridge.exe +0 -0
  56. package/scripts/blind-click.check.mjs +128 -0
  57. package/scripts/build-native.mjs +302 -0
  58. package/scripts/build.mjs +84 -0
  59. package/scripts/calc-fixture.mjs +78 -0
  60. package/scripts/cli-exit.check.mjs +49 -0
  61. package/scripts/helper-path.check.mjs +82 -0
  62. package/scripts/helper-refresh.check.mjs +62 -0
  63. package/scripts/helper-signal.check.mjs +59 -0
  64. package/scripts/highlight.check.mjs +32 -0
  65. package/scripts/keychain-free-install.check.mjs +140 -0
  66. package/scripts/live-view.check.mjs +124 -0
  67. package/scripts/macos-bundle.d.mts +1 -0
  68. package/scripts/macos-bundle.mjs +119 -0
  69. package/scripts/make-signing-cert.sh +57 -0
  70. package/scripts/mini-foreground.check.mjs +11 -0
  71. package/scripts/no-foreground.check.mjs +81 -0
  72. package/scripts/overlay-visible.check.mjs +221 -0
  73. package/scripts/package-macos-app.mjs +39 -0
  74. package/scripts/permissions.check.mjs +199 -0
  75. package/scripts/platform-contract.check.mjs +30 -0
  76. package/scripts/setup-helper.mjs +154 -0
  77. package/scripts/tangu-computer-use.entitlements +10 -0
  78. package/scripts/verify-macos-bundles.mjs +25 -0
  79. package/scripts/verify-package.mjs +74 -0
  80. package/skills/computer-use/SKILL.md +110 -0
  81. package/src/foregroundNote.ts +43 -0
  82. package/src/helperState.ts +31 -0
  83. package/src/index.ts +63 -0
  84. package/src/onboarding.ts +198 -0
  85. package/src/pi-compat.ts +49 -0
  86. package/src/settings.ts +11 -0
  87. package/src/setup.ts +97 -0
  88. package/src/tools.ts +289 -0
  89. package/src/vendor/actions.ts +130 -0
  90. package/src/vendor/bridge.ts +2405 -0
  91. package/src/vendor/cdp.ts +658 -0
  92. package/src/vendor/config.ts +113 -0
  93. package/src/vendor/contract.ts +104 -0
  94. package/src/vendor/note.ts +195 -0
  95. package/src/vendor/outline.ts +651 -0
  96. package/src/vendor/output.ts +134 -0
  97. package/src/vendor/permissions.ts +111 -0
  98. package/src/vendor/platform/architecture.ts +23 -0
  99. package/src/vendor/platform/coerce.ts +16 -0
  100. package/src/vendor/platform/index.ts +59 -0
  101. package/src/vendor/platform/linux/backend.ts +186 -0
  102. package/src/vendor/platform/linux/helper.ts +238 -0
  103. package/src/vendor/platform/macos/backend.ts +131 -0
  104. package/src/vendor/platform/macos/browser.ts +110 -0
  105. package/src/vendor/platform/macos/helper-path.d.mts +9 -0
  106. package/src/vendor/platform/macos/helper-path.mjs +34 -0
  107. package/src/vendor/platform/macos/helper.ts +291 -0
  108. package/src/vendor/platform/macos/permissions.ts +146 -0
  109. package/src/vendor/platform/types.ts +222 -0
  110. package/src/vendor/platform/windows/backend.ts +142 -0
  111. package/src/vendor/platform/windows/helper.ts +140 -0
  112. package/src/vendor/root-selection.ts +25 -0
  113. package/src/vendor/runtime.ts +129 -0
  114. package/src/vendor/state.ts +146 -0
  115. package/src/vendor/view.ts +147 -0
  116. package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
  117. package/tangu-plugins/computer-use/dist/index.js +5672 -0
  118. package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
  119. package/tsconfig.json +22 -0
  120. package/types/tangu-agent.d.ts +142 -0
@@ -0,0 +1,1683 @@
1
+ use std::cell::Cell;
2
+ use std::collections::{HashMap, VecDeque};
3
+ use std::io::{self, BufRead, Write};
4
+ use std::sync::{mpsc, Arc, Mutex, OnceLock};
5
+ use std::thread::{self, sleep};
6
+ use std::time::{Duration, Instant};
7
+
8
+ use serde_json::{json, Value};
9
+ use windows_bridge::{
10
+ capture, input, protocol::PROTOCOL_VERSION, refs::RefStore, window, ErrorCode, ProtocolError,
11
+ Request, Response,
12
+ };
13
+
14
+ #[derive(Clone, Debug)]
15
+ struct ElementRecord {
16
+ hwnd: isize,
17
+ x: f64,
18
+ y: f64,
19
+ w: f64,
20
+ h: f64,
21
+ automation_id: String,
22
+ class_name: String,
23
+ is_secure: bool,
24
+ can_press: bool,
25
+ can_set_value: bool,
26
+ can_scroll: bool,
27
+ runtime_id: Vec<i32>,
28
+ }
29
+
30
+ #[derive(Clone, Debug)]
31
+ struct LookRecord {
32
+ pid: u64,
33
+ hwnd: isize,
34
+ frame_x: f64,
35
+ frame_y: f64,
36
+ frame_w: f64,
37
+ frame_h: f64,
38
+ image_w: f64,
39
+ image_h: f64,
40
+ has_image: bool,
41
+ elements: HashMap<String, ElementRecord>,
42
+ }
43
+
44
+ #[derive(Clone, Debug)]
45
+ struct RootSnapshot {
46
+ roots: HashMap<String, Value>,
47
+ foreground_pid: Option<u64>,
48
+ }
49
+
50
+ struct HelperState {
51
+ store: RefStore,
52
+ roots: HashMap<String, Value>,
53
+ looks: HashMap<String, LookRecord>,
54
+ look_order: VecDeque<String>,
55
+ next_look: u64,
56
+ }
57
+
58
+ const MAX_LOOK_RECORDS: usize = 8;
59
+ const REQUEST_WORKERS: usize = 8;
60
+
61
+ impl Default for HelperState {
62
+ fn default() -> Self {
63
+ Self {
64
+ store: RefStore::new(),
65
+ roots: HashMap::new(),
66
+ looks: HashMap::new(),
67
+ look_order: VecDeque::new(),
68
+ next_look: 1,
69
+ }
70
+ }
71
+ }
72
+
73
+ impl HelperState {
74
+ fn insert_look(&mut self, look_id: String, record: LookRecord) {
75
+ self.looks.insert(look_id.clone(), record);
76
+ self.look_order.push_back(look_id);
77
+ while self.look_order.len() > MAX_LOOK_RECORDS {
78
+ if let Some(expired) = self.look_order.pop_front() {
79
+ self.looks.remove(&expired);
80
+ }
81
+ }
82
+ }
83
+
84
+ fn element_for_look(
85
+ &self,
86
+ look_id: &str,
87
+ reference: &str,
88
+ ) -> Result<ElementRecord, ProtocolError> {
89
+ self.looks
90
+ .get(look_id)
91
+ .ok_or_else(|| {
92
+ ProtocolError::new("Owning look is no longer available", ErrorCode::StaleLook)
93
+ })?
94
+ .elements
95
+ .get(reference)
96
+ .cloned()
97
+ .ok_or_else(|| ProtocolError::new("Element reference is stale", ErrorCode::StaleRef))
98
+ }
99
+ }
100
+
101
+ fn helper_state() -> &'static Mutex<HelperState> {
102
+ static STATE: OnceLock<Mutex<HelperState>> = OnceLock::new();
103
+ STATE.get_or_init(|| Mutex::new(HelperState::default()))
104
+ }
105
+
106
+ fn physical_input_lock() -> &'static Mutex<()> {
107
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
108
+ LOCK.get_or_init(|| Mutex::new(()))
109
+ }
110
+
111
+ thread_local! { static IN_PHYSICAL_TRANSACTION: Cell<bool> = const { Cell::new(false) }; }
112
+
113
+ fn with_physical_input<T>(
114
+ work: impl FnOnce() -> Result<T, ProtocolError>,
115
+ ) -> Result<T, ProtocolError> {
116
+ if IN_PHYSICAL_TRANSACTION.with(Cell::get) {
117
+ return work();
118
+ }
119
+ let _guard = physical_input_lock()
120
+ .lock()
121
+ .map_err(|_| internal("physical input lock poisoned"))?;
122
+ IN_PHYSICAL_TRANSACTION.with(|flag| flag.set(true));
123
+ let result = work();
124
+ IN_PHYSICAL_TRANSACTION.with(|flag| flag.set(false));
125
+ result
126
+ }
127
+
128
+ fn main() {
129
+ #[cfg(windows)]
130
+ set_dpi_awareness();
131
+ window::start_root_event_journal();
132
+
133
+ let stdin = io::stdin();
134
+ let stdout = io::stdout();
135
+ let (sender, receiver) = mpsc::channel::<Request>();
136
+ let receiver = Arc::new(Mutex::new(receiver));
137
+ let workers = (0..REQUEST_WORKERS)
138
+ .map(|_| {
139
+ let receiver = Arc::clone(&receiver);
140
+ thread::spawn(move || loop {
141
+ let request = match receiver.lock() {
142
+ Ok(receiver) => receiver.recv(),
143
+ Err(_) => return,
144
+ };
145
+ match request {
146
+ Ok(request) => emit_response(&handle_request(&request)),
147
+ Err(_) => return,
148
+ }
149
+ })
150
+ })
151
+ .collect::<Vec<_>>();
152
+
153
+ for line in stdin.lock().lines() {
154
+ let line = match line {
155
+ Ok(l) => l,
156
+ Err(e) => {
157
+ let resp = Response::err(
158
+ "unknown",
159
+ ProtocolError::new(
160
+ format!("Failed to read input line: {e}"),
161
+ ErrorCode::InternalError,
162
+ ),
163
+ );
164
+ if let Ok(json) = serde_json::to_string(&resp) {
165
+ let _ = writeln!(stdout.lock(), "{json}");
166
+ }
167
+ return;
168
+ }
169
+ };
170
+ let trimmed = line.trim().to_owned();
171
+ if trimmed.is_empty() {
172
+ continue;
173
+ }
174
+ let id = extract_id(&trimmed).unwrap_or_else(|| "unknown".to_owned());
175
+ let request: Request = match serde_json::from_str(&trimmed) {
176
+ Ok(req) => req,
177
+ Err(e) => {
178
+ emit_response(&Response::err(
179
+ &id,
180
+ ProtocolError::new(format!("Invalid request: {e}"), ErrorCode::InvalidRequest),
181
+ ));
182
+ continue;
183
+ }
184
+ };
185
+ if sender.send(request).is_err() {
186
+ break;
187
+ }
188
+ }
189
+ drop(sender);
190
+ for worker in workers {
191
+ let _ = worker.join();
192
+ }
193
+ }
194
+
195
+ #[cfg(windows)]
196
+ fn set_dpi_awareness() {
197
+ use windows::Win32::UI::HiDpi::{
198
+ SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2,
199
+ };
200
+ let _ = unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) };
201
+ }
202
+
203
+ fn extract_id(line: &str) -> Option<String> {
204
+ serde_json::from_str::<Value>(line)
205
+ .ok()
206
+ .and_then(|v| v.get("id")?.as_str().map(String::from))
207
+ }
208
+
209
+ fn handle_request(request: &Request) -> Response {
210
+ if request.protocol_version != PROTOCOL_VERSION {
211
+ return Response::err(&request.id, ProtocolError::new(format!("Unsupported Windows helper protocol {}; expected {}. Restart Pi to use the installed helper.", request.protocol_version, PROTOCOL_VERSION), ErrorCode::InvalidRequest));
212
+ }
213
+
214
+ let result = match request.cmd.as_str() {
215
+ "diagnostics" => Ok(diagnostics()),
216
+ "listRoots" | "listWindows" => handle_list_roots(&request.args),
217
+ "look" | "screenshot" => handle_look(&request.args),
218
+ "focusWindow" => handle_focus_window(&request.args),
219
+ "act" => handle_act(&request.args),
220
+ "actBatch" => handle_act_batch(&request.args),
221
+ "uiaReadText" | "axReadText" => handle_read_text(&request.args),
222
+ "uiaWaitFor" | "axWaitFor" => handle_wait_for(&request.args),
223
+ "openBrowserLocation" => handle_open_browser_location(&request.args),
224
+ other => Err(ProtocolError::new(
225
+ format!("Unknown command '{other}'"),
226
+ ErrorCode::UnsupportedCommand,
227
+ )),
228
+ };
229
+
230
+ match result {
231
+ Ok(value) => Response::ok(&request.id, value),
232
+ Err(error) => Response::err(&request.id, error),
233
+ }
234
+ }
235
+
236
+ fn diagnostics() -> Value {
237
+ json!({
238
+ "protocolVersion": PROTOCOL_VERSION,
239
+ "architectureVersion": 1,
240
+ "invariants": ["state-scoped-observations", "bounded-observation-history", "multi-root-forest", "progressive-disclosure", "atomic-physical-input", "concurrent-requests", "transactional-batching"],
241
+ "pid": std::process::id(),
242
+ "os": std::env::consts::OS,
243
+ "arch": std::env::consts::ARCH,
244
+ "accessibility": true,
245
+ "screenRecording": true
246
+ })
247
+ }
248
+
249
+ fn handle_list_roots(args: &Value) -> Result<Value, ProtocolError> {
250
+ let filter_pid = args.get("pid").and_then(Value::as_u64);
251
+ let mut state = helper_state()
252
+ .lock()
253
+ .map_err(|_| internal("helper state lock poisoned"))?;
254
+ let result = window::list_windows(&mut state.store, filter_pid)?;
255
+ let discovered = roots_array(&result);
256
+ if filter_pid.is_none() {
257
+ state.store.retain_window_refs(
258
+ discovered
259
+ .iter()
260
+ .filter_map(|root| root.get("rootRef").and_then(Value::as_str)),
261
+ );
262
+ state.roots.clear();
263
+ } else if let Some(pid) = filter_pid {
264
+ state
265
+ .roots
266
+ .retain(|_, root| root.get("pid").and_then(Value::as_u64) != Some(pid));
267
+ }
268
+ state.roots.extend(
269
+ discovered
270
+ .into_iter()
271
+ .map(|root| (root_identity(&root), root)),
272
+ );
273
+ Ok(result)
274
+ }
275
+
276
+ fn handle_focus_window(args: &Value) -> Result<Value, ProtocolError> {
277
+ let root_ref = args
278
+ .get("rootRef")
279
+ .or_else(|| args.get("windowRef"))
280
+ .and_then(Value::as_str)
281
+ .ok_or_else(|| invalid("focusWindow requires rootRef"))?;
282
+ let wref = windows_bridge::refs::WindowRef::parse(root_ref)
283
+ .ok_or_else(|| invalid(format!("Invalid root ref '{root_ref}'")))?;
284
+ let state = helper_state()
285
+ .lock()
286
+ .map_err(|_| internal("helper state lock poisoned"))?;
287
+ window::focus_window(&state.store, &wref)
288
+ }
289
+
290
+ fn handle_look(args: &Value) -> Result<Value, ProtocolError> {
291
+ let started_at = Instant::now();
292
+ let root_ref = args
293
+ .get("rootRef")
294
+ .or_else(|| args.get("windowRef"))
295
+ .and_then(Value::as_str)
296
+ .map(str::to_owned);
297
+ let window_id = args.get("windowId").and_then(Value::as_i64);
298
+ let include_image = args
299
+ .get("includeImage")
300
+ .and_then(Value::as_bool)
301
+ .unwrap_or(true);
302
+ let max_dimension = args
303
+ .get("maxDimension")
304
+ .and_then(Value::as_u64)
305
+ .map(|value| value.clamp(1, 16_384) as u32);
306
+ let read_text = args
307
+ .get("readText")
308
+ .and_then(Value::as_str)
309
+ .unwrap_or("auto");
310
+ if !matches!(read_text, "auto" | "always" | "never") {
311
+ return Err(invalid("readText must be auto, always, or never"));
312
+ }
313
+ let scope_ref = args
314
+ .get("scopeRef")
315
+ .and_then(Value::as_str)
316
+ .map(str::to_owned);
317
+ let base_look_id = args.get("baseLookId").and_then(Value::as_str);
318
+
319
+ if root_ref.is_none() && window_id.is_none() {
320
+ return Err(invalid("look requires rootRef or windowId"));
321
+ }
322
+
323
+ let roots_missing = helper_state()
324
+ .lock()
325
+ .map_err(|_| internal("helper state lock poisoned"))?
326
+ .roots
327
+ .is_empty();
328
+ if roots_missing {
329
+ let _ = handle_list_roots(&json!({}));
330
+ }
331
+ let (mut store, roots) = {
332
+ let state = helper_state()
333
+ .lock()
334
+ .map_err(|_| internal("helper state lock poisoned"))?;
335
+ (state.store.clone(), state.roots.clone())
336
+ };
337
+
338
+ let (identity, root) = roots
339
+ .iter()
340
+ .find(|(_, root)| {
341
+ root_ref.as_deref().is_some_and(|r| {
342
+ root.get("rootRef").and_then(Value::as_str) == Some(r)
343
+ || root.get("windowRef").and_then(Value::as_str) == Some(r)
344
+ }) || window_id
345
+ .is_some_and(|id| root.get("windowId").and_then(Value::as_i64) == Some(id))
346
+ })
347
+ .map(|(id, root)| (id.clone(), root.clone()))
348
+ .ok_or_else(|| ProtocolError::new("Root not found", ErrorCode::TargetNotFound))?;
349
+
350
+ let root_ref = root
351
+ .get("rootRef")
352
+ .and_then(Value::as_str)
353
+ .unwrap_or(&identity)
354
+ .to_owned();
355
+ let kind = root.get("kind").and_then(Value::as_str).unwrap_or("window");
356
+ let is_outline_only = kind == "menu" || !include_image;
357
+ let frame = root
358
+ .get("framePoints")
359
+ .cloned()
360
+ .unwrap_or_else(|| json!({"x":0,"y":0,"w":1,"h":1}));
361
+ let fx = number_at(&frame, "x", 0.0);
362
+ let fy = number_at(&frame, "y", 0.0);
363
+ let fw = number_at(&frame, "w", number_at(&frame, "width", 1.0)).max(1.0);
364
+ let fh = number_at(&frame, "h", number_at(&frame, "height", 1.0)).max(1.0);
365
+
366
+ let mut image_payload = None;
367
+ let mut elements = Vec::new();
368
+ let mut image_w = fw;
369
+ let mut image_h = fh;
370
+
371
+ let capture_started = Instant::now();
372
+ if !is_outline_only {
373
+ let wref = windows_bridge::refs::WindowRef::parse(&root_ref)
374
+ .ok_or_else(|| invalid(format!("Invalid root ref '{root_ref}'")))?;
375
+ let shot = capture::screenshot(&mut store, &wref, scope_ref.is_none(), max_dimension)?;
376
+ if let Some(capture) = shot.get("capture") {
377
+ image_w = number_at(capture, "width", fw).max(1.0);
378
+ image_h = number_at(capture, "height", fh).max(1.0);
379
+ if let Some(encoded) = capture.get("imageBase64").and_then(Value::as_str) {
380
+ image_payload = Some(
381
+ json!({ "jpegBase64": encoded, "mimeType": "image/png", "width": image_w, "height": image_h }),
382
+ );
383
+ }
384
+ }
385
+ elements = shot
386
+ .get("axTargets")
387
+ .and_then(Value::as_array)
388
+ .cloned()
389
+ .unwrap_or_default();
390
+ } else {
391
+ #[cfg(windows)]
392
+ {
393
+ if let Some(wref) = windows_bridge::refs::WindowRef::parse(&root_ref) {
394
+ if let Some(native) = store.get_window(&wref) {
395
+ elements = windows_bridge::uia::extract_elements(&mut store, native.raw())
396
+ .into_iter()
397
+ .collect();
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ let capture_ms = capture_started.elapsed().as_millis() as u64;
404
+ let root_hwnd = root.get("windowId").and_then(Value::as_i64).unwrap_or(0) as isize;
405
+ let mut base_look = None;
406
+ if let Some(scope_ref) = scope_ref.as_deref() {
407
+ let (owning_look, scope) =
408
+ look_record_for_element(scope_ref, base_look_id).ok_or_else(|| {
409
+ ProtocolError::new(
410
+ "Scope ref is stale or outside the target root",
411
+ ErrorCode::StaleRef,
412
+ )
413
+ })?;
414
+ if scope.hwnd != root_hwnd {
415
+ return Err(ProtocolError::new(
416
+ "Scope ref is stale or outside the target root",
417
+ ErrorCode::StaleRef,
418
+ ));
419
+ }
420
+ elements = windows_bridge::uia::extract_elements_from(
421
+ &mut store,
422
+ scope.hwnd,
423
+ &scope.runtime_id,
424
+ &scope.automation_id,
425
+ )
426
+ .map_err(stale_ref_from_uia)?;
427
+ base_look = Some(owning_look);
428
+ }
429
+
430
+ let look_id = fresh_look_id()?;
431
+ let describe_started = Instant::now();
432
+ let (mut outline, mut element_records) = outline_from_elements(
433
+ scope_ref.as_deref().unwrap_or(&root_ref),
434
+ &look_id,
435
+ kind,
436
+ &root,
437
+ &elements,
438
+ fx,
439
+ fy,
440
+ image_w,
441
+ image_h,
442
+ fw,
443
+ fh,
444
+ );
445
+ if let Some(scope_ref) = scope_ref.as_deref() {
446
+ outline = scoped_outline_root(outline, scope_ref);
447
+ }
448
+ if let Some(base) = base_look.as_ref() {
449
+ element_records.extend(base.elements.clone());
450
+ }
451
+ let describe_ms = describe_started.elapsed().as_millis() as u64;
452
+ let record = LookRecord {
453
+ pid: root.get("pid").and_then(Value::as_u64).unwrap_or(0),
454
+ hwnd: root_hwnd,
455
+ frame_x: base_look.as_ref().map(|look| look.frame_x).unwrap_or(fx),
456
+ frame_y: base_look.as_ref().map(|look| look.frame_y).unwrap_or(fy),
457
+ frame_w: base_look.as_ref().map(|look| look.frame_w).unwrap_or(fw),
458
+ frame_h: base_look.as_ref().map(|look| look.frame_h).unwrap_or(fh),
459
+ image_w: base_look
460
+ .as_ref()
461
+ .map(|look| look.image_w)
462
+ .unwrap_or(image_w),
463
+ image_h: base_look
464
+ .as_ref()
465
+ .map(|look| look.image_h)
466
+ .unwrap_or(image_h),
467
+ has_image: base_look
468
+ .as_ref()
469
+ .map(|look| look.has_image)
470
+ .unwrap_or_else(|| image_payload.is_some()),
471
+ elements: element_records,
472
+ };
473
+ store_look_record(look_id.clone(), record)?;
474
+
475
+ let mut response = json!({
476
+ "lookId": look_id,
477
+ "capturedAt": now_seconds(),
478
+ "window": {
479
+ "windowId": root.get("windowId").and_then(Value::as_i64).unwrap_or(0),
480
+ "rootRef": root_ref,
481
+ "kind": kind,
482
+ "framePoints": { "x": fx, "y": fy, "w": fw, "h": fh },
483
+ "scaleFactor": root.get("scaleFactor").and_then(Value::as_f64).unwrap_or(1.0),
484
+ "isModal": root.get("isModal").and_then(Value::as_bool).unwrap_or(false),
485
+ "role": root.get("role").and_then(Value::as_str).unwrap_or("Window"),
486
+ "subrole": root.get("subrole").and_then(Value::as_str).unwrap_or("")
487
+ },
488
+ "outline": outline,
489
+ "timings": { "captureMs": capture_ms, "describeMs": describe_ms, "readTextMs": 0, "totalMs": started_at.elapsed().as_millis() as u64 },
490
+ "readText": { "requested": read_text, "executed": false }
491
+ });
492
+ if let Some(metadata) = root.get("metadata") {
493
+ response["window"]["metadata"] = metadata.clone();
494
+ }
495
+ if let Some(image) = image_payload {
496
+ response["image"] = image;
497
+ }
498
+ Ok(response)
499
+ }
500
+
501
+ #[allow(clippy::too_many_arguments)]
502
+ fn outline_from_elements(
503
+ root_ref: &str,
504
+ look_id: &str,
505
+ kind: &str,
506
+ root: &Value,
507
+ elements: &[Value],
508
+ fx: f64,
509
+ fy: f64,
510
+ image_w: f64,
511
+ image_h: f64,
512
+ fw: f64,
513
+ fh: f64,
514
+ ) -> (Value, HashMap<String, ElementRecord>) {
515
+ let mut records = HashMap::new();
516
+ let sx = image_w / fw.max(1.0);
517
+ let sy = image_h / fh.max(1.0);
518
+ let nodes = elements.iter().map(|raw| {
519
+ let bounds = raw.get("bounds").unwrap_or(&Value::Null);
520
+ let screen_x = number_at(bounds, "x", 0.0);
521
+ let screen_y = number_at(bounds, "y", 0.0);
522
+ let screen_w = number_at(bounds, "width", number_at(bounds, "w", 1.0)).max(1.0);
523
+ let screen_h = number_at(bounds, "height", number_at(bounds, "h", 1.0)).max(1.0);
524
+ // Windows UIA and HWND geometry are in DPI-aware screen points. Look images
525
+ // are pixels; every element rect below is converted by the window-image scale
526
+ // so coordinate acts can invert through the stored LookRecord without TS state.
527
+ let rect = json!({ "x": (screen_x - fx) * sx, "y": (screen_y - fy) * sy, "w": screen_w * sx, "h": screen_h * sy });
528
+ let native_reference = raw.get("ref").and_then(Value::as_str).unwrap_or("");
529
+ let reference = if native_reference.is_empty() { String::new() } else { format!("win:{look_id}:{native_reference}") };
530
+ let role = raw.get("role").and_then(Value::as_str).unwrap_or("unknown").to_owned();
531
+ let automation_id = raw.get("automationId").and_then(Value::as_str).unwrap_or("").to_owned();
532
+ let class_name = format!(
533
+ "{} {}",
534
+ raw.get("className").and_then(Value::as_str).unwrap_or(""),
535
+ root.get("subrole").and_then(Value::as_str).unwrap_or("")
536
+ );
537
+ let runtime_id: Vec<i32> = raw.get("runtimeId").and_then(Value::as_array).map(|items| items.iter().filter_map(|item| item.as_i64().map(|n| n as i32)).collect()).unwrap_or_default();
538
+ let parent_runtime_id: Vec<i32> = raw.get("parentRuntimeId").and_then(Value::as_array).map(|items| items.iter().filter_map(|item| item.as_i64().map(|n| n as i32)).collect()).unwrap_or_default();
539
+ let is_secure = raw.get("isPassword").and_then(Value::as_bool).unwrap_or(false);
540
+ let displayed_value = if is_secure { "" } else { raw.get("value").and_then(Value::as_str).unwrap_or("") };
541
+ let text = if displayed_value.is_empty() { raw.get("label").and_then(Value::as_str).unwrap_or("") } else { displayed_value }.to_owned();
542
+ let hwnd = root.get("windowId").and_then(Value::as_i64).unwrap_or(0) as isize;
543
+ let caps = raw.get("capabilities").unwrap_or(&Value::Null);
544
+ let can_press = caps.get("canPress").or_else(|| caps.get("canInvoke")).and_then(Value::as_bool).unwrap_or(false);
545
+ let can_set_value = caps.get("canSetValue").or_else(|| caps.get("canEditText")).and_then(Value::as_bool).unwrap_or(false);
546
+ let can_scroll = caps.get("canScroll").and_then(Value::as_bool).unwrap_or(false);
547
+ if !reference.is_empty() { records.insert(reference.clone(), ElementRecord { hwnd, x: screen_x, y: screen_y, w: screen_w, h: screen_h, automation_id: automation_id.clone(), class_name, is_secure, can_press, can_set_value, can_scroll, runtime_id: runtime_id.clone() }); }
548
+ let node = json!({
549
+ "ref": reference,
550
+ "role": role,
551
+ "subrole": raw.get("className").and_then(Value::as_str).unwrap_or(""),
552
+ "identifier": raw.get("automationId").and_then(Value::as_str).unwrap_or(""),
553
+ "title": raw.get("label").and_then(Value::as_str).unwrap_or(""),
554
+ "description": raw.get("className").and_then(Value::as_str).unwrap_or(""),
555
+ "value": displayed_value,
556
+ "actions": [],
557
+ "canPress": can_press,
558
+ "canFocus": caps.get("isKeyboardFocusable").and_then(Value::as_bool).unwrap_or(false),
559
+ "canSetValue": can_set_value,
560
+ "canScroll": can_scroll,
561
+ "canIncrement": false,
562
+ "canDecrement": false,
563
+ "isTextInput": matches!(raw.get("role").and_then(Value::as_str), Some("edit" | "document")),
564
+ "rect": rect,
565
+ "focused": false,
566
+ "offscreen": caps.get("isOffscreen").and_then(Value::as_bool).unwrap_or(false),
567
+ "pictureOnly": false,
568
+ "truncated": raw.get("truncated").and_then(Value::as_bool).unwrap_or(false),
569
+ "text": if text.is_empty() { json!([]) } else { json!([{ "string": text, "confidence": 1, "rect": rect }]) },
570
+ "children": []
571
+ });
572
+ (runtime_key(&runtime_id), runtime_key(&parent_runtime_id), node)
573
+ }).collect::<Vec<_>>();
574
+ let children = nest_outline_nodes(&nodes);
575
+ (
576
+ json!({
577
+ "ref": root_ref,
578
+ "role": root.get("role").and_then(Value::as_str).unwrap_or("Window"),
579
+ "subrole": root.get("subrole").and_then(Value::as_str).unwrap_or(""),
580
+ "identifier": "",
581
+ "title": root.get("title").and_then(Value::as_str).unwrap_or(if kind == "menu" { "Menu" } else { "Window" }),
582
+ "description": "",
583
+ "value": "",
584
+ "actions": [],
585
+ "canPress": false,
586
+ "canFocus": false,
587
+ "canSetValue": false,
588
+ "canScroll": false,
589
+ "canIncrement": false,
590
+ "canDecrement": false,
591
+ "isTextInput": false,
592
+ "rect": { "x": 0, "y": 0, "w": image_w, "h": image_h },
593
+ "focused": root.get("isFocused").and_then(Value::as_bool).unwrap_or(false),
594
+ "offscreen": false,
595
+ "pictureOnly": false,
596
+ "truncated": false,
597
+ "text": [],
598
+ "children": children
599
+ }),
600
+ records,
601
+ )
602
+ }
603
+
604
+ fn runtime_key(runtime_id: &[i32]) -> String {
605
+ runtime_id
606
+ .iter()
607
+ .map(i32::to_string)
608
+ .collect::<Vec<_>>()
609
+ .join(".")
610
+ }
611
+
612
+ fn nest_outline_nodes(nodes: &[(String, String, Value)]) -> Vec<Value> {
613
+ let index = nodes
614
+ .iter()
615
+ .enumerate()
616
+ .filter(|(_, (key, _, _))| !key.is_empty())
617
+ .map(|(index, (key, _, _))| (key.as_str(), index))
618
+ .collect::<HashMap<_, _>>();
619
+ let mut children = vec![Vec::<usize>::new(); nodes.len()];
620
+ let mut roots = Vec::new();
621
+ for (node_index, (_, parent, _)) in nodes.iter().enumerate() {
622
+ match index.get(parent.as_str()).copied() {
623
+ Some(parent_index) if parent_index != node_index => {
624
+ children[parent_index].push(node_index)
625
+ }
626
+ _ => roots.push(node_index),
627
+ }
628
+ }
629
+ fn materialize(
630
+ node_index: usize,
631
+ nodes: &[(String, String, Value)],
632
+ children: &[Vec<usize>],
633
+ path: &mut Vec<usize>,
634
+ ) -> Value {
635
+ let mut node = nodes[node_index].2.clone();
636
+ if path.len() >= 64 || path.contains(&node_index) {
637
+ node["truncated"] = json!(true);
638
+ return node;
639
+ }
640
+ path.push(node_index);
641
+ node["children"] = Value::Array(
642
+ children[node_index]
643
+ .iter()
644
+ .map(|child| materialize(*child, nodes, children, path))
645
+ .collect(),
646
+ );
647
+ path.pop();
648
+ node
649
+ }
650
+ roots
651
+ .into_iter()
652
+ .map(|root| materialize(root, nodes, &children, &mut Vec::new()))
653
+ .collect()
654
+ }
655
+
656
+ fn fresh_look_id() -> Result<String, ProtocolError> {
657
+ let mut state = helper_state()
658
+ .lock()
659
+ .map_err(|_| internal("helper state lock poisoned"))?;
660
+ let look_id = format!("look_{}", state.next_look);
661
+ state.next_look += 1;
662
+ Ok(look_id)
663
+ }
664
+
665
+ fn store_look_record(look_id: String, record: LookRecord) -> Result<(), ProtocolError> {
666
+ let mut state = helper_state()
667
+ .lock()
668
+ .map_err(|_| internal("helper state lock poisoned"))?;
669
+ state.insert_look(look_id, record);
670
+ Ok(())
671
+ }
672
+
673
+ fn look_record_for_element(
674
+ reference: &str,
675
+ base_look_id: Option<&str>,
676
+ ) -> Option<(LookRecord, ElementRecord)> {
677
+ let state = helper_state().lock().ok()?;
678
+ if let Some(look_id) = base_look_id {
679
+ let look = state.looks.get(look_id)?;
680
+ return look
681
+ .elements
682
+ .get(reference)
683
+ .cloned()
684
+ .map(|element| (look.clone(), element));
685
+ }
686
+ state
687
+ .look_order
688
+ .iter()
689
+ .rev()
690
+ .filter_map(|look_id| state.looks.get(look_id))
691
+ .find_map(|look| {
692
+ look.elements
693
+ .get(reference)
694
+ .cloned()
695
+ .map(|element| (look.clone(), element))
696
+ })
697
+ }
698
+
699
+ fn scoped_outline_root(mut outline: Value, scope_ref: &str) -> Value {
700
+ let mut roots = outline["children"]
701
+ .as_array_mut()
702
+ .map(std::mem::take)
703
+ .unwrap_or_default();
704
+ if roots.is_empty() {
705
+ outline["ref"] = json!(scope_ref);
706
+ outline["truncated"] = json!(false);
707
+ return outline;
708
+ }
709
+ let mut scoped = roots.remove(0);
710
+ scoped["ref"] = json!(scope_ref);
711
+ if !roots.is_empty() {
712
+ if let Some(children) = scoped["children"].as_array_mut() {
713
+ children.extend(roots);
714
+ }
715
+ }
716
+ scoped
717
+ }
718
+
719
+ fn handle_act(args: &Value) -> Result<Value, ProtocolError> {
720
+ let parsed = input::parse_act_request(args)?;
721
+ let record = {
722
+ let state = helper_state()
723
+ .lock()
724
+ .map_err(|_| internal("helper state lock poisoned"))?;
725
+ state.looks.get(&parsed.look_id).cloned().ok_or_else(|| {
726
+ ProtocolError::new(
727
+ format!("Look id '{}' is no longer available", parsed.look_id),
728
+ ErrorCode::StaleLook,
729
+ )
730
+ })?
731
+ };
732
+ let target_pid = parsed
733
+ .pid
734
+ .or((record.pid != 0).then_some(record.pid))
735
+ .ok_or_else(|| invalid("act requires pid or observed root pid for root deltas"))?;
736
+ let defer_root_delta = args
737
+ .get("deferRootDelta")
738
+ .and_then(Value::as_bool)
739
+ .unwrap_or(false);
740
+ let event_cursor = window::root_event_cursor();
741
+ let before = if defer_root_delta {
742
+ None
743
+ } else {
744
+ Some(snapshot_roots(target_pid)?)
745
+ };
746
+
747
+ let mut response = match &parsed.target {
748
+ input::ActTarget::Ref(reference) => act_on_ref(args, &parsed, &record, reference)?,
749
+ input::ActTarget::Point { x, y } => {
750
+ if !record.has_image {
751
+ return Err(ProtocolError::new(
752
+ "Coordinate targeting is unavailable for this outline-only root",
753
+ ErrorCode::CoordinateUnavailableForRoot,
754
+ ));
755
+ }
756
+ let mut executable = args.clone();
757
+ executable["resolvedPoint"] = json!(screen_point(&record, *x, *y));
758
+ if parsed.action == "drag" {
759
+ if let Some(path) = parsed.params.get("path").and_then(Value::as_array) {
760
+ executable["resolvedPath"] = Value::Array(
761
+ path.iter()
762
+ .filter_map(|point| {
763
+ Some(json!(screen_point(
764
+ &record,
765
+ point.get("x")?.as_f64()?,
766
+ point.get("y")?.as_f64()?
767
+ )))
768
+ })
769
+ .collect(),
770
+ );
771
+ }
772
+ }
773
+ with_physical_input(|| {
774
+ let preserve_focus = parsed
775
+ .params
776
+ .get("preserveFocus")
777
+ .and_then(Value::as_bool)
778
+ .unwrap_or(false);
779
+ if !preserve_focus {
780
+ window::ensure_foreground(record.hwnd)?;
781
+ }
782
+ input::act(&executable)
783
+ })?
784
+ }
785
+ };
786
+
787
+ if let Some(before) = before {
788
+ let (after, source, events) = await_delta_snapshot(target_pid, &before, event_cursor)?;
789
+ response = input::response_with_delta(
790
+ response,
791
+ source,
792
+ combined_root_delta(&before, &after, target_pid, events),
793
+ );
794
+ }
795
+ Ok(response)
796
+ }
797
+
798
+ fn handle_act_batch(args: &Value) -> Result<Value, ProtocolError> {
799
+ let actions = args
800
+ .get("actions")
801
+ .and_then(Value::as_array)
802
+ .ok_or_else(|| invalid("actBatch requires actions"))?;
803
+ if actions.is_empty() || actions.len() > 20 {
804
+ return Err(invalid("actBatch requires 1...20 actions"));
805
+ }
806
+ let first = input::parse_act_request(&actions[0])?;
807
+ let target_pid = first
808
+ .pid
809
+ .ok_or_else(|| invalid("actBatch actions require pid"))?;
810
+ for action in actions {
811
+ let parsed = input::parse_act_request(action)?;
812
+ if parsed.pid != Some(target_pid) {
813
+ return Err(invalid("actBatch actions must target one pid"));
814
+ }
815
+ if parsed.look_id != first.look_id {
816
+ return Err(invalid("actBatch actions must belong to one look"));
817
+ }
818
+ }
819
+ let before = snapshot_roots(target_pid)?;
820
+ let event_cursor = window::root_event_cursor();
821
+ let record = {
822
+ let state = helper_state()
823
+ .lock()
824
+ .map_err(|_| internal("helper state lock poisoned"))?;
825
+ state.looks.get(&first.look_id).cloned().ok_or_else(|| {
826
+ ProtocolError::new("Owning look is no longer available", ErrorCode::StaleLook)
827
+ })?
828
+ };
829
+ let requires_physical = actions
830
+ .iter()
831
+ .any(|action| action_may_use_physical_input(action, &record));
832
+ let execute = || -> Result<(Vec<Value>, Option<usize>), ProtocolError> {
833
+ let mut steps = Vec::new();
834
+ let mut stopped_at = None;
835
+ for (index, action) in actions.iter().enumerate() {
836
+ let mut deferred = action.clone();
837
+ deferred["deferRootDelta"] = json!(true);
838
+ let step = match handle_act(&deferred) {
839
+ Ok(step) => step,
840
+ Err(error) => {
841
+ steps.push(json!({ "outcome": "didnt", "error": { "code": error.code.to_string(), "message": error.message } }));
842
+ stopped_at = Some(index);
843
+ break;
844
+ }
845
+ };
846
+ let didnt = step.get("outcome").and_then(Value::as_str) == Some("didnt");
847
+ steps.push(step);
848
+ if didnt {
849
+ stopped_at = Some(index);
850
+ break;
851
+ }
852
+ }
853
+ Ok((steps, stopped_at))
854
+ };
855
+ let executed = if requires_physical {
856
+ let _physical_guard = physical_input_lock()
857
+ .lock()
858
+ .map_err(|_| internal("physical input lock poisoned"))?;
859
+ IN_PHYSICAL_TRANSACTION.with(|flag| flag.set(true));
860
+ let result = execute();
861
+ IN_PHYSICAL_TRANSACTION.with(|flag| flag.set(false));
862
+ result
863
+ } else {
864
+ execute()
865
+ };
866
+ let (steps, stopped_at) = executed?;
867
+ let outcome = if steps
868
+ .iter()
869
+ .any(|step| step.get("outcome").and_then(Value::as_str) == Some("didnt"))
870
+ {
871
+ "didnt"
872
+ } else if steps
873
+ .iter()
874
+ .any(|step| step.get("outcome").and_then(Value::as_str) == Some("unknown"))
875
+ {
876
+ "unknown"
877
+ } else {
878
+ "worked"
879
+ };
880
+ let mut response = json!({ "outcome": outcome, "performed": { "transaction": true, "actionCount": steps.len() }, "steps": steps });
881
+ if let Some(index) = stopped_at {
882
+ response["stoppedAt"] = json!(index);
883
+ }
884
+ let (after, source, events) = await_delta_snapshot(target_pid, &before, event_cursor)?;
885
+ response = input::response_with_delta(
886
+ response,
887
+ source,
888
+ combined_root_delta(&before, &after, target_pid, events),
889
+ );
890
+ Ok(response)
891
+ }
892
+
893
+ fn action_may_use_physical_input(action: &Value, record: &LookRecord) -> bool {
894
+ let Ok(parsed) = input::parse_act_request(action) else {
895
+ return false;
896
+ };
897
+ if parsed.policy != "foreground" {
898
+ return false;
899
+ }
900
+ let input::ActTarget::Ref(reference) = &parsed.target else {
901
+ return true;
902
+ };
903
+ let Some(element) = record.elements.get(reference) else {
904
+ return false;
905
+ };
906
+ match parsed.action.as_str() {
907
+ "press" | "click" => is_web_backed(element) || !element.can_press,
908
+ "setText" => is_web_backed(element) || !element.can_set_value,
909
+ "scroll" => !element.can_scroll,
910
+ _ => true,
911
+ }
912
+ }
913
+
914
+ fn act_on_ref(
915
+ args: &Value,
916
+ parsed: &input::ParsedActRequest,
917
+ record: &LookRecord,
918
+ reference: &str,
919
+ ) -> Result<Value, ProtocolError> {
920
+ let element = record
921
+ .elements
922
+ .get(reference)
923
+ .cloned()
924
+ .ok_or_else(|| ProtocolError::new("Element reference is stale", ErrorCode::StaleRef))?;
925
+ if parsed.policy != "ax_only"
926
+ && is_web_backed(&element)
927
+ && matches!(parsed.action.as_str(), "press" | "click" | "setText")
928
+ {
929
+ return coordinate_fallback(args, parsed, &element);
930
+ }
931
+ match parsed.action.as_str() {
932
+ "press" | "click" => match windows_bridge::uia::press(
933
+ element.hwnd,
934
+ &element.runtime_id,
935
+ &element.automation_id,
936
+ )
937
+ .map_err(stale_ref_from_uia)?
938
+ {
939
+ windows_bridge::uia::PressResult::Invoked => Ok(
940
+ json!({ "outcome": "worked", "performed": { "grounding": "description", "delivery": "ax" } }),
941
+ ),
942
+ windows_bridge::uia::PressResult::Toggled(state) => Ok(
943
+ json!({ "outcome": "worked", "performed": { "grounding": "description", "delivery": "ax" }, "evidence": { "toggleState": state } }),
944
+ ),
945
+ windows_bridge::uia::PressResult::Selected(selected) => Ok(
946
+ json!({ "outcome": if selected { "worked" } else { "didnt" }, "performed": { "grounding": "description", "delivery": "ax" }, "evidence": { "selected": selected } }),
947
+ ),
948
+ windows_bridge::uia::PressResult::Expanded => Ok(
949
+ json!({ "outcome": "worked", "performed": { "grounding": "description", "delivery": "ax" } }),
950
+ ),
951
+ windows_bridge::uia::PressResult::LegacyDefaultAction => Ok(
952
+ json!({ "outcome": "worked", "performed": { "grounding": "description", "delivery": "ax" } }),
953
+ ),
954
+ windows_bridge::uia::PressResult::NoPattern => {
955
+ coordinate_fallback(args, parsed, &element)
956
+ }
957
+ },
958
+ "setText" => {
959
+ let text = parsed
960
+ .params
961
+ .get("text")
962
+ .and_then(Value::as_str)
963
+ .unwrap_or("");
964
+ match windows_bridge::uia::set_text(
965
+ element.hwnd,
966
+ &element.runtime_id,
967
+ &element.automation_id,
968
+ text,
969
+ )
970
+ .map_err(stale_ref_from_uia)?
971
+ {
972
+ windows_bridge::uia::SetTextResult::Set { value } => {
973
+ if value != text && parsed.policy != "foreground" {
974
+ return Err(ProtocolError::new(
975
+ "The background UIA value write was accepted but did not take effect",
976
+ ErrorCode::ForegroundRequired,
977
+ ));
978
+ }
979
+ Ok(
980
+ json!({ "outcome": if value == text { "worked" } else { "didnt" }, "performed": { "grounding": "description", "delivery": "ax" }, "evidence": { "value": value } }),
981
+ )
982
+ }
983
+ windows_bridge::uia::SetTextResult::NoPattern => {
984
+ input::policy_allows_raw_input(parsed)?;
985
+ let _ = windows_bridge::uia::focus(
986
+ element.hwnd,
987
+ &element.runtime_id,
988
+ &element.automation_id,
989
+ );
990
+ coordinate_fallback(args, parsed, &element)
991
+ }
992
+ }
993
+ }
994
+ "scroll" => {
995
+ let sx = parsed
996
+ .params
997
+ .get("scrollX")
998
+ .and_then(Value::as_f64)
999
+ .unwrap_or(0.0);
1000
+ let sy = parsed
1001
+ .params
1002
+ .get("scrollY")
1003
+ .and_then(Value::as_f64)
1004
+ .unwrap_or(0.0);
1005
+ match windows_bridge::uia::scroll(
1006
+ element.hwnd,
1007
+ &element.runtime_id,
1008
+ &element.automation_id,
1009
+ sx,
1010
+ sy,
1011
+ )
1012
+ .map_err(stale_ref_from_uia)?
1013
+ {
1014
+ windows_bridge::uia::ScrollResult::Scrolled => Ok(
1015
+ json!({ "outcome": "unknown", "performed": { "grounding": "description", "delivery": "ax" } }),
1016
+ ),
1017
+ windows_bridge::uia::ScrollResult::NoPattern => {
1018
+ coordinate_fallback(args, parsed, &element)
1019
+ }
1020
+ }
1021
+ }
1022
+ _ => coordinate_fallback(args, parsed, &element),
1023
+ }
1024
+ }
1025
+
1026
+ fn coordinate_fallback(
1027
+ args: &Value,
1028
+ parsed: &input::ParsedActRequest,
1029
+ element: &ElementRecord,
1030
+ ) -> Result<Value, ProtocolError> {
1031
+ input::policy_allows_raw_input(parsed)?;
1032
+ with_physical_input(|| {
1033
+ window::ensure_foreground(element.hwnd)?;
1034
+ let snapshot = windows_bridge::uia::snapshot(
1035
+ element.hwnd,
1036
+ &element.runtime_id,
1037
+ &element.automation_id,
1038
+ )
1039
+ .map_err(stale_ref_from_uia)
1040
+ .unwrap_or(windows_bridge::uia::ElementSnapshot {
1041
+ rect: (element.x, element.y, element.w, element.h),
1042
+ runtime_id: element.runtime_id.clone(),
1043
+ });
1044
+ let x = snapshot.rect.0 + snapshot.rect.2 / 2.0;
1045
+ let y = snapshot.rect.1 + snapshot.rect.3 / 2.0;
1046
+ let mut occlusion_unknown = false;
1047
+ let mut clear = false;
1048
+ for attempt in 0..3 {
1049
+ match windows_bridge::uia::occlusion_ok(
1050
+ element.hwnd,
1051
+ &snapshot.runtime_id,
1052
+ &element.automation_id,
1053
+ x,
1054
+ y,
1055
+ ) {
1056
+ Ok(true) => {
1057
+ clear = true;
1058
+ break;
1059
+ }
1060
+ Ok(false) if attempt < 2 => sleep(Duration::from_millis(20)),
1061
+ Ok(false) => break,
1062
+ Err(_) => {
1063
+ occlusion_unknown = true;
1064
+ clear = true;
1065
+ break;
1066
+ }
1067
+ }
1068
+ }
1069
+ if !clear {
1070
+ return Err(ProtocolError::new(
1071
+ "Target is occluded",
1072
+ ErrorCode::OccludedTarget,
1073
+ ));
1074
+ }
1075
+ let mut executable = args.clone();
1076
+ executable["resolvedPoint"] = json!({ "x": x, "y": y });
1077
+ let mut response = input::act(&executable)?;
1078
+ if occlusion_unknown {
1079
+ response["outcome"] = json!("unknown");
1080
+ response["evidence"]["preflight"] = json!("unknown");
1081
+ }
1082
+ Ok(response)
1083
+ })
1084
+ }
1085
+
1086
+ fn is_web_backed(element: &ElementRecord) -> bool {
1087
+ let class = element.class_name.to_ascii_lowercase();
1088
+ class.contains("chrome")
1089
+ || class.contains("chromium")
1090
+ || class.contains("webview")
1091
+ || class.contains("electron")
1092
+ }
1093
+
1094
+ fn stale_ref_from_uia(message: String) -> ProtocolError {
1095
+ if message.contains("stale") || message.contains("Element reference") {
1096
+ ProtocolError::new("Element reference is stale", ErrorCode::StaleRef)
1097
+ } else {
1098
+ ProtocolError::new(message, ErrorCode::InternalError)
1099
+ }
1100
+ }
1101
+
1102
+ fn screen_point(record: &LookRecord, x: f64, y: f64) -> Value {
1103
+ json!({
1104
+ "x": record.frame_x + record.frame_w * (x / record.image_w.max(1.0)).clamp(0.0, 1.0),
1105
+ "y": record.frame_y + record.frame_h * (y / record.image_h.max(1.0)).clamp(0.0, 1.0)
1106
+ })
1107
+ }
1108
+
1109
+ fn snapshot_roots(pid: u64) -> Result<RootSnapshot, ProtocolError> {
1110
+ let mut store = RefStore::new();
1111
+ let value = window::list_windows(&mut store, Some(pid))?;
1112
+ Ok(RootSnapshot {
1113
+ roots: roots_array(&value)
1114
+ .into_iter()
1115
+ .map(|root| (root_identity(&root), root))
1116
+ .collect(),
1117
+ foreground_pid: window::foreground_pid(),
1118
+ })
1119
+ }
1120
+
1121
+ fn root_signature(snapshot: &RootSnapshot) -> (Vec<String>, Option<u64>) {
1122
+ let mut keys = snapshot.roots.keys().cloned().collect::<Vec<_>>();
1123
+ keys.sort();
1124
+ (keys, snapshot.foreground_pid)
1125
+ }
1126
+
1127
+ fn await_delta_snapshot(
1128
+ pid: u64,
1129
+ before: &RootSnapshot,
1130
+ event_cursor: u64,
1131
+ ) -> Result<(RootSnapshot, &'static str, Vec<Value>), ProtocolError> {
1132
+ let before_sig = root_signature(before);
1133
+ let deadline = Instant::now() + Duration::from_millis(300);
1134
+ let mut signaled = false;
1135
+ let mut after = snapshot_roots(pid)?;
1136
+ while Instant::now() < deadline {
1137
+ after = snapshot_roots(pid)?;
1138
+ if root_signature(&after) != before_sig
1139
+ || !window::root_events_since(event_cursor, pid).is_empty()
1140
+ {
1141
+ signaled = true;
1142
+ break;
1143
+ }
1144
+ sleep(Duration::from_millis(30));
1145
+ }
1146
+ if signaled {
1147
+ // Signals accelerate the settle window, but the reported delta is always
1148
+ // a full before/after snapshot diff. Keep re-diffing briefly so a
1149
+ // close-then-open transition (menus/popups) does not report only the
1150
+ // first observed close.
1151
+ for _ in 0..3 {
1152
+ sleep(Duration::from_millis(60));
1153
+ after = snapshot_roots(pid)?;
1154
+ }
1155
+ let events = window::root_events_since(event_cursor, pid);
1156
+ let source = if events.is_empty() {
1157
+ "win-poll"
1158
+ } else {
1159
+ "win-event+snapshot"
1160
+ };
1161
+ Ok((after, source, events))
1162
+ } else {
1163
+ Ok((after, "snapshot", Vec::new()))
1164
+ }
1165
+ }
1166
+
1167
+ fn combined_root_delta(
1168
+ before: &RootSnapshot,
1169
+ after: &RootSnapshot,
1170
+ target_pid: u64,
1171
+ events: Vec<Value>,
1172
+ ) -> Vec<Value> {
1173
+ let mut delta = root_delta(before, after, target_pid);
1174
+ for event in events {
1175
+ let duplicate = delta.iter().any(|item| {
1176
+ item.get("change") == event.get("change")
1177
+ && item.get("kind") == event.get("kind")
1178
+ && item.get("title") == event.get("title")
1179
+ && item.get("pid") == event.get("pid")
1180
+ });
1181
+ if !duplicate {
1182
+ delta.push(event);
1183
+ }
1184
+ }
1185
+ delta
1186
+ }
1187
+
1188
+ fn root_delta(before: &RootSnapshot, after: &RootSnapshot, target_pid: u64) -> Vec<Value> {
1189
+ let mut delta = Vec::new();
1190
+ for (key, root) in &after.roots {
1191
+ if !before.roots.contains_key(key) {
1192
+ delta.push(delta_item("appeared", root));
1193
+ }
1194
+ }
1195
+ for (key, root) in &before.roots {
1196
+ if !after.roots.contains_key(key) {
1197
+ delta.push(delta_item("closed", root));
1198
+ }
1199
+ }
1200
+ for (key, root) in &after.roots {
1201
+ if root.get("isFocused").and_then(Value::as_bool) == Some(true)
1202
+ && before
1203
+ .roots
1204
+ .get(key)
1205
+ .and_then(|r| r.get("isFocused"))
1206
+ .and_then(Value::as_bool)
1207
+ != Some(true)
1208
+ {
1209
+ delta.push(delta_item("focused", root));
1210
+ }
1211
+ }
1212
+ if before.foreground_pid != after.foreground_pid && after.foreground_pid != Some(target_pid) {
1213
+ delta.push(json!({ "change": "focused", "kind": "app", "title": "Foreground app", "pid": after.foreground_pid.unwrap_or(0) }));
1214
+ }
1215
+ delta
1216
+ }
1217
+
1218
+ fn delta_item(change: &str, root: &Value) -> Value {
1219
+ let mut item = json!({
1220
+ "change": change,
1221
+ "kind": root.get("kind").and_then(Value::as_str).unwrap_or("window"),
1222
+ "ref": root.get("rootRef").and_then(Value::as_str).unwrap_or(""),
1223
+ "title": root.get("title").and_then(Value::as_str).unwrap_or(""),
1224
+ "pid": root.get("pid").and_then(Value::as_u64).unwrap_or(0)
1225
+ });
1226
+ if let Some(is_modal) = root.get("isModal").and_then(Value::as_bool) {
1227
+ item["isModal"] = json!(is_modal);
1228
+ }
1229
+ if let Some(metadata) = root.get("metadata") {
1230
+ item["metadata"] = metadata.clone();
1231
+ }
1232
+ item
1233
+ }
1234
+
1235
+ fn handle_read_text(args: &Value) -> Result<Value, ProtocolError> {
1236
+ let look_id = args
1237
+ .get("lookId")
1238
+ .and_then(Value::as_str)
1239
+ .ok_or_else(|| invalid("uiaReadText requires lookId"))?;
1240
+ let element_ref = args
1241
+ .get("elementRef")
1242
+ .and_then(Value::as_str)
1243
+ .ok_or_else(|| invalid("uiaReadText requires elementRef"))?;
1244
+ let offset = args.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
1245
+ let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(4096) as usize;
1246
+ let element = {
1247
+ let state = helper_state()
1248
+ .lock()
1249
+ .map_err(|_| internal("helper state lock poisoned"))?;
1250
+ state.element_for_look(look_id, element_ref)?
1251
+ };
1252
+ if element.is_secure {
1253
+ return Err(ProtocolError::new(
1254
+ "Refers to a secure text field; refusing to read its value",
1255
+ ErrorCode::SecureTextUnreadable,
1256
+ ));
1257
+ }
1258
+ let text = windows_bridge::uia::read_live_text(
1259
+ element.hwnd,
1260
+ &element.runtime_id,
1261
+ &element.automation_id,
1262
+ )
1263
+ .map_err(stale_ref_from_uia)?;
1264
+ let characters = text.chars().collect::<Vec<_>>();
1265
+ let end = offset.saturating_add(limit).min(characters.len());
1266
+ let slice = if offset >= characters.len() {
1267
+ String::new()
1268
+ } else {
1269
+ characters[offset..end].iter().collect()
1270
+ };
1271
+ Ok(
1272
+ json!({ "text": slice, "offset": offset, "limit": limit, "totalChars": characters.len(), "hasMore": end < characters.len() }),
1273
+ )
1274
+ }
1275
+
1276
+ fn handle_wait_for(args: &Value) -> Result<Value, ProtocolError> {
1277
+ let timeout_ms = args
1278
+ .get("timeoutMs")
1279
+ .and_then(Value::as_u64)
1280
+ .unwrap_or(10_000)
1281
+ .clamp(100, 60_000);
1282
+ let text = args
1283
+ .get("text")
1284
+ .and_then(Value::as_str)
1285
+ .map(|s| s.to_lowercase());
1286
+ let role = args.get("role").and_then(Value::as_str).map(str::to_owned);
1287
+ let expected_value = args
1288
+ .get("value")
1289
+ .and_then(Value::as_str)
1290
+ .map(|s| s.trim().to_lowercase());
1291
+ let gone = args.get("gone").and_then(Value::as_bool).unwrap_or(false);
1292
+ let scope_exact = args.get("scopeExact").and_then(Value::as_bool).unwrap_or(false);
1293
+ let scope_runtime_id = match (
1294
+ args.get("lookId").and_then(Value::as_str),
1295
+ args.get("scopeRef").and_then(Value::as_str),
1296
+ ) {
1297
+ (Some(look_id), Some(scope_ref)) => Some(
1298
+ helper_state()
1299
+ .lock()
1300
+ .map_err(|_| internal("helper state lock poisoned"))?
1301
+ .element_for_look(look_id, scope_ref)?
1302
+ .runtime_id,
1303
+ ),
1304
+ (None, Some(_)) => return Err(invalid("scopeRef requires lookId")),
1305
+ _ => None,
1306
+ };
1307
+ if text.is_none() && role.is_none() && expected_value.is_none() {
1308
+ return Err(invalid("uiaWaitFor requires text, role, or value"));
1309
+ }
1310
+ let hwnd = wait_target_hwnd(args)?;
1311
+ let deadline = Instant::now() + Duration::from_millis(timeout_ms);
1312
+ let mut node_count = 0;
1313
+ while Instant::now() < deadline {
1314
+ let elements = windows_bridge::uia::live_elements(hwnd).map_err(internal)?;
1315
+ node_count = elements.len();
1316
+ let parents: HashMap<String, String> = elements
1317
+ .iter()
1318
+ .filter_map(|element| {
1319
+ let runtime = element.get("runtimeId")?.as_array()?;
1320
+ let parent = element.get("parentRuntimeId")?.as_array()?;
1321
+ Some((serde_json::to_string(runtime).ok()?, serde_json::to_string(parent).ok()?))
1322
+ })
1323
+ .collect();
1324
+ let scope_key = scope_runtime_id.as_ref().and_then(|id| serde_json::to_string(id).ok());
1325
+ let found = elements.iter().any(|element| {
1326
+ if let Some(scope_key) = &scope_key {
1327
+ let Some(mut current) = element.get("runtimeId").and_then(Value::as_array).and_then(|id| serde_json::to_string(id).ok()) else { return false; };
1328
+ let mut within_scope = false;
1329
+ for _ in 0..64 {
1330
+ if &current == scope_key { within_scope = true; break; }
1331
+ if scope_exact { break; }
1332
+ let Some(parent) = parents.get(&current) else { break; };
1333
+ if parent == &current { break; }
1334
+ current = parent.clone();
1335
+ }
1336
+ if !within_scope { return false; }
1337
+ }
1338
+ let candidate_text = ["value", "label", "name", "title"]
1339
+ .iter()
1340
+ .filter_map(|key| element.get(*key).and_then(Value::as_str))
1341
+ .collect::<Vec<_>>()
1342
+ .join(" ")
1343
+ .to_lowercase();
1344
+ let candidate_role = element.get("role").and_then(Value::as_str).unwrap_or("");
1345
+ let candidate_value = element
1346
+ .get("value")
1347
+ .and_then(Value::as_str)
1348
+ .unwrap_or("")
1349
+ .trim()
1350
+ .to_lowercase();
1351
+ text.as_ref()
1352
+ .map(|needle| candidate_text.contains(needle))
1353
+ .unwrap_or(true)
1354
+ && role.as_ref().map(|r| candidate_role == r).unwrap_or(true)
1355
+ && expected_value
1356
+ .as_ref()
1357
+ .map(|value| candidate_value == *value)
1358
+ .unwrap_or(true)
1359
+ });
1360
+ if found != gone {
1361
+ return Ok(
1362
+ json!({ "found": true, "gone": if gone { Some(true) } else { None::<bool> }, "nodeCount": node_count }),
1363
+ );
1364
+ }
1365
+ sleep(Duration::from_millis(150));
1366
+ }
1367
+ Ok(json!({ "found": false, "timedOut": true, "nodeCount": node_count }))
1368
+ }
1369
+
1370
+ fn wait_target_hwnd(args: &Value) -> Result<isize, ProtocolError> {
1371
+ if let Some(window_id) = args.get("windowId").and_then(Value::as_i64) {
1372
+ return Ok(window_id as isize);
1373
+ }
1374
+ if let Some(root_ref) = args
1375
+ .get("rootRef")
1376
+ .or_else(|| args.get("windowRef"))
1377
+ .and_then(Value::as_str)
1378
+ {
1379
+ let state = helper_state()
1380
+ .lock()
1381
+ .map_err(|_| internal("helper state lock poisoned"))?;
1382
+ for root in state.roots.values() {
1383
+ if root.get("rootRef").and_then(Value::as_str) == Some(root_ref)
1384
+ || root.get("windowRef").and_then(Value::as_str) == Some(root_ref)
1385
+ {
1386
+ return Ok(root.get("windowId").and_then(Value::as_i64).unwrap_or(0) as isize);
1387
+ }
1388
+ }
1389
+ }
1390
+ if let Some(pid) = args.get("pid").and_then(Value::as_u64) {
1391
+ let roots = snapshot_roots(pid)?;
1392
+ if let Some(root) = roots
1393
+ .roots
1394
+ .values()
1395
+ .find(|root| root.get("pid").and_then(Value::as_u64) == Some(pid))
1396
+ {
1397
+ return Ok(root.get("windowId").and_then(Value::as_i64).unwrap_or(0) as isize);
1398
+ }
1399
+ }
1400
+ Err(ProtocolError::new(
1401
+ "waitFor target root was not found",
1402
+ ErrorCode::TargetNotFound,
1403
+ ))
1404
+ }
1405
+
1406
+ fn handle_open_browser_location(args: &Value) -> Result<Value, ProtocolError> {
1407
+ let app_name = args
1408
+ .get("appName")
1409
+ .and_then(Value::as_str)
1410
+ .unwrap_or("")
1411
+ .to_lowercase();
1412
+ let roots = handle_list_roots(&json!({}))?;
1413
+ let root = roots_array(&roots).into_iter().find(|root| {
1414
+ root.get("appName")
1415
+ .and_then(Value::as_str)
1416
+ .unwrap_or("")
1417
+ .to_lowercase()
1418
+ .contains(&app_name)
1419
+ });
1420
+ if let Some(root) = root {
1421
+ let hwnd = root.get("windowId").and_then(Value::as_i64).unwrap_or(0) as isize;
1422
+ let url = args
1423
+ .get("url")
1424
+ .and_then(Value::as_str)
1425
+ .ok_or_else(|| invalid("openBrowserLocation requires url"))?;
1426
+ with_physical_input(|| {
1427
+ window::ensure_foreground(hwnd)?;
1428
+ input::open_browser_location(url)
1429
+ })?;
1430
+ Ok(json!({ "opened": true }))
1431
+ } else {
1432
+ Err(ProtocolError::new("Target browser window was not found; refusing to type into the currently focused window", ErrorCode::TargetNotFound))
1433
+ }
1434
+ }
1435
+
1436
+ fn roots_array(value: &Value) -> Vec<Value> {
1437
+ value
1438
+ .get("roots")
1439
+ .or_else(|| value.get("windows"))
1440
+ .and_then(Value::as_array)
1441
+ .cloned()
1442
+ .unwrap_or_default()
1443
+ }
1444
+
1445
+ fn root_identity(root: &Value) -> String {
1446
+ if let Some(id) = root.get("windowId").and_then(Value::as_i64) {
1447
+ return format!("window:{id}");
1448
+ }
1449
+ format!(
1450
+ "meta:{}:{}:{}",
1451
+ root.get("kind").and_then(Value::as_str).unwrap_or("window"),
1452
+ root.get("title").and_then(Value::as_str).unwrap_or(""),
1453
+ root.get("rootRef").and_then(Value::as_str).unwrap_or("")
1454
+ )
1455
+ }
1456
+
1457
+ fn number_at(value: &Value, key: &str, fallback: f64) -> f64 {
1458
+ value.get(key).and_then(Value::as_f64).unwrap_or(fallback)
1459
+ }
1460
+ fn invalid(message: impl Into<String>) -> ProtocolError {
1461
+ ProtocolError::new(message.into(), ErrorCode::InvalidRequest)
1462
+ }
1463
+ fn internal(message: impl Into<String>) -> ProtocolError {
1464
+ ProtocolError::new(message.into(), ErrorCode::InternalError)
1465
+ }
1466
+ fn now_seconds() -> f64 {
1467
+ std::time::SystemTime::now()
1468
+ .duration_since(std::time::UNIX_EPOCH)
1469
+ .map(|d| d.as_secs_f64())
1470
+ .unwrap_or(0.0)
1471
+ }
1472
+
1473
+ fn emit_response(response: &Response) {
1474
+ let json = serde_json::to_string(response).expect("Response serialization should not fail");
1475
+ let mut out = io::stdout().lock();
1476
+ let _ = writeln!(out, "{json}");
1477
+ }
1478
+
1479
+ #[cfg(test)]
1480
+ mod tests {
1481
+ use super::*;
1482
+
1483
+ fn root(id: i64, pid: u64, title: &str, focused: bool) -> Value {
1484
+ json!({ "windowId": id, "kind": "window", "title": title, "pid": pid, "rootRef": format!("@w{id}"), "isFocused": focused })
1485
+ }
1486
+
1487
+ fn snap(roots: Vec<Value>, foreground_pid: Option<u64>) -> RootSnapshot {
1488
+ RootSnapshot {
1489
+ roots: roots
1490
+ .into_iter()
1491
+ .map(|root| (root_identity(&root), root))
1492
+ .collect(),
1493
+ foreground_pid,
1494
+ }
1495
+ }
1496
+
1497
+ #[test]
1498
+ fn root_delta_uses_act_time_baseline_for_two_acts_one_look() {
1499
+ let look_time = snap(vec![root(1, 1, "A", true)], Some(1));
1500
+ let after_first = snap(vec![root(1, 1, "A", false), root(2, 1, "B", true)], Some(1));
1501
+ let after_second = snap(
1502
+ vec![
1503
+ root(1, 1, "A", false),
1504
+ root(2, 1, "B", false),
1505
+ root(3, 1, "C", true),
1506
+ ],
1507
+ Some(1),
1508
+ );
1509
+ let first = root_delta(&look_time, &after_first, 1);
1510
+ assert!(first
1511
+ .iter()
1512
+ .any(|item| item["title"] == "B" && item["change"] == "appeared"));
1513
+ let second = root_delta(&after_first, &after_second, 1);
1514
+ assert!(
1515
+ !second
1516
+ .iter()
1517
+ .any(|item| item["title"] == "B" && item["change"] == "appeared"),
1518
+ "second act must not re-report first act delta"
1519
+ );
1520
+ assert!(second
1521
+ .iter()
1522
+ .any(|item| item["title"] == "C" && item["change"] == "appeared"));
1523
+ }
1524
+
1525
+ #[test]
1526
+ fn root_delta_scopes_to_target_pid_and_foreground_flip() {
1527
+ let before = snap(vec![root(1, 1, "Target", true)], Some(1));
1528
+ let after_same_pid = snap(
1529
+ vec![root(1, 1, "Target", false), root(2, 1, "Dialog", true)],
1530
+ Some(1),
1531
+ );
1532
+ assert!(root_delta(&before, &after_same_pid, 1)
1533
+ .iter()
1534
+ .any(|item| item["title"] == "Dialog" && item["change"] == "appeared"));
1535
+
1536
+ let after_other_pid_filtered = snap(vec![root(1, 1, "Target", true)], Some(1));
1537
+ assert!(
1538
+ root_delta(&before, &after_other_pid_filtered, 1).is_empty(),
1539
+ "other-pid window churn is excluded before diffing"
1540
+ );
1541
+
1542
+ let foreground_flip = snap(vec![root(1, 1, "Target", true)], Some(2));
1543
+ let delta = root_delta(&before, &foreground_flip, 1);
1544
+ assert!(delta
1545
+ .iter()
1546
+ .any(|item| item["change"] == "focused" && item["kind"] == "app" && item["pid"] == 2));
1547
+ }
1548
+
1549
+ #[test]
1550
+ fn root_delta_coalesces_close_then_open_from_final_snapshot() {
1551
+ let before = snap(
1552
+ vec![root(1, 1, "Editor", false), root(2, 1, "Old menu", true)],
1553
+ Some(1),
1554
+ );
1555
+ let after = snap(
1556
+ vec![root(1, 1, "Editor", false), root(3, 1, "File menu", true)],
1557
+ Some(1),
1558
+ );
1559
+ let delta = root_delta(&before, &after, 1);
1560
+ assert!(delta
1561
+ .iter()
1562
+ .any(|item| item["title"] == "Old menu" && item["change"] == "closed"));
1563
+ assert!(delta
1564
+ .iter()
1565
+ .any(|item| item["title"] == "File menu" && item["change"] == "appeared"));
1566
+ }
1567
+
1568
+ #[test]
1569
+ fn action_batch_rejects_empty_transactions() {
1570
+ let error = handle_act_batch(&json!({ "actions": [] })).expect_err("empty batch must fail");
1571
+ assert_eq!(error.code, ErrorCode::InvalidRequest);
1572
+ }
1573
+
1574
+ fn look_with_element(x: f64) -> LookRecord {
1575
+ LookRecord {
1576
+ pid: 1,
1577
+ hwnd: 1,
1578
+ frame_x: 0.0,
1579
+ frame_y: 0.0,
1580
+ frame_w: 100.0,
1581
+ frame_h: 100.0,
1582
+ image_w: 100.0,
1583
+ image_h: 100.0,
1584
+ has_image: false,
1585
+ elements: HashMap::from([(
1586
+ "same-ref".to_owned(),
1587
+ ElementRecord {
1588
+ hwnd: 1,
1589
+ x,
1590
+ y: 0.0,
1591
+ w: 1.0,
1592
+ h: 1.0,
1593
+ automation_id: String::new(),
1594
+ class_name: String::new(),
1595
+ is_secure: false,
1596
+ can_press: true,
1597
+ can_set_value: true,
1598
+ can_scroll: true,
1599
+ runtime_id: vec![],
1600
+ },
1601
+ )]),
1602
+ }
1603
+ }
1604
+
1605
+ #[test]
1606
+ fn observation_ownership_is_explicit_and_bounded() {
1607
+ let mut state = HelperState::default();
1608
+ state.insert_look("look-a".to_owned(), look_with_element(1.0));
1609
+ state.insert_look("look-b".to_owned(), look_with_element(2.0));
1610
+ assert_eq!(state.element_for_look("look-a", "same-ref").unwrap().x, 1.0);
1611
+ assert_eq!(state.element_for_look("look-b", "same-ref").unwrap().x, 2.0);
1612
+ for index in 0..MAX_LOOK_RECORDS {
1613
+ state.insert_look(format!("new-{index}"), look_with_element(index as f64));
1614
+ }
1615
+ assert_eq!(state.looks.len(), MAX_LOOK_RECORDS);
1616
+ assert_eq!(
1617
+ state
1618
+ .element_for_look("look-a", "same-ref")
1619
+ .unwrap_err()
1620
+ .code,
1621
+ ErrorCode::StaleLook
1622
+ );
1623
+ }
1624
+
1625
+ #[test]
1626
+ fn outline_nesting_uses_native_parentage() {
1627
+ let nodes = vec![
1628
+ (
1629
+ "1".to_owned(),
1630
+ String::new(),
1631
+ json!({"title":"parent","children":[]}),
1632
+ ),
1633
+ (
1634
+ "2".to_owned(),
1635
+ "1".to_owned(),
1636
+ json!({"title":"child","children":[]}),
1637
+ ),
1638
+ ];
1639
+ let nested = nest_outline_nodes(&nodes);
1640
+ assert_eq!(nested.len(), 1);
1641
+ assert_eq!(nested[0]["children"][0]["title"], "child");
1642
+ }
1643
+
1644
+ #[test]
1645
+ fn scoped_outline_preserves_the_parent_wire_ref() {
1646
+ let outline = json!({
1647
+ "ref": "@w1",
1648
+ "children": [{ "ref": "win:look-2:@e1", "title": "scope", "children": [{ "ref": "win:look-2:@e2" }] }]
1649
+ });
1650
+ let scoped = scoped_outline_root(outline, "win:look-1:@e9");
1651
+ assert_eq!(scoped["ref"], "win:look-1:@e9");
1652
+ assert_eq!(scoped["children"][0]["ref"], "win:look-2:@e2");
1653
+ }
1654
+
1655
+ #[test]
1656
+ fn batch_locking_is_capability_driven() {
1657
+ let mut record = look_with_element(1.0);
1658
+ let native_press = json!({"lookId":"look","action":"press","policy":"default","target":{"ref":"same-ref"},"params":{}});
1659
+ assert!(!action_may_use_physical_input(&native_press, &record));
1660
+ record.elements.get_mut("same-ref").unwrap().class_name = "Chrome_WidgetWin_1".to_owned();
1661
+ assert!(!action_may_use_physical_input(&native_press, &record));
1662
+ let foreground_press = json!({"lookId":"look","action":"press","policy":"foreground","target":{"ref":"same-ref"},"params":{}});
1663
+ assert!(action_may_use_physical_input(&foreground_press, &record));
1664
+ let ax_only = json!({"lookId":"look","action":"press","policy":"ax_only","target":{"ref":"same-ref"},"params":{}});
1665
+ assert!(!action_may_use_physical_input(&ax_only, &record));
1666
+ let coordinates = json!({"lookId":"look","action":"click","policy":"foreground","target":{"x":1,"y":2},"params":{}});
1667
+ assert!(action_may_use_physical_input(&coordinates, &record));
1668
+ }
1669
+
1670
+ #[test]
1671
+ fn event_journal_preserves_transient_roots_absent_from_final_snapshot() {
1672
+ let before = snap(vec![root(1, 1, "Editor", true)], Some(1));
1673
+ let after = before.clone();
1674
+ let events = vec![
1675
+ json!({"change":"appeared","kind":"menu","title":"File","pid":1}),
1676
+ json!({"change":"closed","kind":"menu","title":"File","pid":1}),
1677
+ ];
1678
+ let delta = combined_root_delta(&before, &after, 1, events);
1679
+ assert_eq!(delta.len(), 2);
1680
+ assert_eq!(delta[0]["change"], "appeared");
1681
+ assert_eq!(delta[1]["change"], "closed");
1682
+ }
1683
+ }