@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,1074 @@
1
+ use std::sync::{Arc, Mutex, OnceLock};
2
+ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
+
4
+ use linux_bridge::atspi::{outline_json, root_json, AtspiClient, NodeSnapshot, Rect, RootSnapshot};
5
+ use linux_bridge::protocol::PROTOCOL_VERSION;
6
+ use linux_bridge::state::{fresh_state_id, HelperState};
7
+ use linux_bridge::wayland::{PortalCapabilities, PortalClient, PortalError};
8
+ use linux_bridge::x11::{self, PhysicalPolicy, SessionKind};
9
+ use linux_bridge::{ErrorCode, ProtocolError, Request, Response};
10
+ use serde_json::{json, Value};
11
+ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
12
+ use tokio::sync::mpsc;
13
+
14
+ const REQUEST_WORKERS: usize = 8;
15
+ const DEFAULT_MAX_NODES: usize = 512;
16
+
17
+ #[tokio::main(flavor = "multi_thread", worker_threads = 8)]
18
+ async fn main() {
19
+ let state = Arc::new(Mutex::new(HelperState::default()));
20
+ let (output, mut responses) = mpsc::unbounded_channel::<Response>();
21
+ let writer = tokio::spawn(async move {
22
+ let mut stdout = tokio::io::stdout();
23
+ while let Some(response) = responses.recv().await {
24
+ if let Ok(mut line) = serde_json::to_vec(&response) {
25
+ line.push(b'\n');
26
+ if stdout.write_all(&line).await.is_err() {
27
+ return;
28
+ }
29
+ let _ = stdout.flush().await;
30
+ }
31
+ }
32
+ });
33
+
34
+ let mut lines = BufReader::new(tokio::io::stdin()).lines();
35
+ let semaphore = Arc::new(tokio::sync::Semaphore::new(REQUEST_WORKERS));
36
+ let mut requests = tokio::task::JoinSet::new();
37
+ while let Ok(Some(line)) = lines.next_line().await {
38
+ if line.trim().is_empty() {
39
+ continue;
40
+ }
41
+ let id = extract_id(&line).unwrap_or_else(|| "unknown".to_owned());
42
+ let request = match serde_json::from_str::<Request>(&line) {
43
+ Ok(request) => request,
44
+ Err(error) => {
45
+ let _ = output.send(Response::err(
46
+ &id,
47
+ ProtocolError::new(
48
+ format!("Invalid request: {error}"),
49
+ ErrorCode::InvalidRequest,
50
+ ),
51
+ ));
52
+ continue;
53
+ }
54
+ };
55
+ let Ok(permit) = semaphore.clone().acquire_owned().await else {
56
+ break;
57
+ };
58
+ let state = Arc::clone(&state);
59
+ let output = output.clone();
60
+ requests.spawn(async move {
61
+ let _permit = permit;
62
+ let response = handle_request(&state, &request).await;
63
+ let _ = output.send(response);
64
+ });
65
+ }
66
+ while requests.join_next().await.is_some() {}
67
+ drop(output);
68
+ let _ = writer.await;
69
+ }
70
+
71
+ fn action_lock() -> &'static tokio::sync::Mutex<()> {
72
+ static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
73
+ LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
74
+ }
75
+
76
+ fn extract_id(line: &str) -> Option<String> {
77
+ serde_json::from_str::<Value>(line)
78
+ .ok()?
79
+ .get("id")?
80
+ .as_str()
81
+ .map(str::to_owned)
82
+ }
83
+
84
+ async fn handle_request(state: &Arc<Mutex<HelperState>>, request: &Request) -> Response {
85
+ if request.protocol_version != PROTOCOL_VERSION {
86
+ return Response::err(
87
+ &request.id,
88
+ invalid(format!(
89
+ "Unsupported Linux helper protocol {}; expected {}. Restart Pi to use the installed helper.",
90
+ request.protocol_version, PROTOCOL_VERSION
91
+ )),
92
+ );
93
+ }
94
+ let result = match request.cmd.as_str() {
95
+ "diagnostics" => Ok(diagnostics().await),
96
+ "listRoots" | "listWindows" => list_roots(state, &request.args).await,
97
+ "look" | "screenshot" => look(state, &request.args).await,
98
+ "act" => {
99
+ let _transaction = action_lock().lock().await;
100
+ act(state, &request.args).await
101
+ }
102
+ "actBatch" => {
103
+ let _transaction = action_lock().lock().await;
104
+ act_batch(state, &request.args).await
105
+ }
106
+ "atspiReadText" | "uiaReadText" | "axReadText" => read_text(state, &request.args).await,
107
+ "atspiWaitFor" | "uiaWaitFor" | "axWaitFor" => wait_for(state, &request.args).await,
108
+ "focusWindow" => focus_window(state, &request.args),
109
+ "openBrowserLocation" => open_browser_location(&request.args),
110
+ other => Err(ProtocolError::new(
111
+ format!("Unknown command '{other}'"),
112
+ ErrorCode::UnsupportedCommand,
113
+ )),
114
+ };
115
+ match result {
116
+ Ok(value) => Response::ok(&request.id, value),
117
+ Err(error) => Response::err(&request.id, error),
118
+ }
119
+ }
120
+
121
+ async fn diagnostics() -> Value {
122
+ let accessibility = AtspiClient::available().await;
123
+ let session = SessionKind::detect();
124
+ let x11_available = x11::available();
125
+ let wayland_portal = if session == SessionKind::Wayland {
126
+ let probe = match PortalClient::connect().await {
127
+ Ok(client) => client.probe().await,
128
+ Err(error) => Err(error),
129
+ };
130
+ portal_diagnostics(Some(probe))
131
+ } else {
132
+ portal_diagnostics(None)
133
+ };
134
+ json!({
135
+ "protocolVersion": PROTOCOL_VERSION,
136
+ "architectureVersion": 1,
137
+ "invariants": [
138
+ "state-scoped-observations",
139
+ "bounded-observation-history",
140
+ "multi-root-forest",
141
+ "progressive-disclosure",
142
+ "atomic-physical-input",
143
+ "concurrent-requests",
144
+ "transactional-batching"
145
+ ],
146
+ "pid": std::process::id(),
147
+ "os": std::env::consts::OS,
148
+ "arch": std::env::consts::ARCH,
149
+ "backend": if x11_available { "at-spi2+x11" } else { "at-spi2" },
150
+ "sessionType": session.as_str(),
151
+ "x11": x11_available,
152
+ "accessibility": accessibility,
153
+ "screenRecording": x11_available,
154
+ "coordinateInput": x11_available,
155
+ "waylandPortal": wayland_portal,
156
+ "capabilities": {
157
+ "roots": accessibility,
158
+ "outline": accessibility,
159
+ "readText": accessibility,
160
+ "waitFor": accessibility,
161
+ "semanticPress": accessibility,
162
+ "semanticSetText": accessibility,
163
+ "screenshots": x11_available,
164
+ "coordinates": x11_available,
165
+ "forceFocus": x11_available
166
+ }
167
+ })
168
+ }
169
+
170
+ fn portal_diagnostics(probe: Option<Result<PortalCapabilities, PortalError>>) -> Value {
171
+ match probe {
172
+ Some(Ok(capabilities)) => json!({
173
+ "probed": true,
174
+ "available": true,
175
+ "interactiveUseEnabled": false,
176
+ "readOnlyProbe": true,
177
+ "remoteDesktopVersion": capabilities.remote_desktop_version,
178
+ "screenCastVersion": capabilities.screen_cast_version,
179
+ "availableDeviceTypes": capabilities.available_device_types,
180
+ "availableSourceTypes": capabilities.available_source_types,
181
+ "availableCursorModes": capabilities.available_cursor_modes,
182
+ }),
183
+ Some(Err(error)) => json!({
184
+ "probed": true,
185
+ "available": false,
186
+ "interactiveUseEnabled": false,
187
+ "readOnlyProbe": true,
188
+ "error": {
189
+ "kind": "portal_unavailable",
190
+ "message": error.to_string(),
191
+ },
192
+ }),
193
+ None => json!({
194
+ "probed": false,
195
+ "available": false,
196
+ "interactiveUseEnabled": false,
197
+ "readOnlyProbe": true,
198
+ "reason": "not_wayland",
199
+ }),
200
+ }
201
+ }
202
+
203
+ async fn list_roots(state: &Arc<Mutex<HelperState>>, args: &Value) -> Result<Value, ProtocolError> {
204
+ let client = AtspiClient::connect().await?;
205
+ let mut roots = client
206
+ .list_roots(args.get("pid").and_then(Value::as_u64))
207
+ .await?;
208
+ if let Ok(windows) = x11::list_windows() {
209
+ x11::enrich_roots(&mut roots, &windows);
210
+ }
211
+ let stored = lock(state)?.replace_roots(roots);
212
+ let windows = stored
213
+ .iter()
214
+ .enumerate()
215
+ .map(|(index, (reference, root))| root_json(reference, root, index))
216
+ .collect::<Vec<_>>();
217
+ Ok(json!({
218
+ "stateId": fresh_state_id(),
219
+ "windows": windows,
220
+ "roots": windows
221
+ }))
222
+ }
223
+
224
+ async fn resolve_root(
225
+ state: &Arc<Mutex<HelperState>>,
226
+ args: &Value,
227
+ ) -> Result<(String, RootSnapshot), ProtocolError> {
228
+ let requested = args
229
+ .get("rootRef")
230
+ .or_else(|| args.get("windowRef"))
231
+ .and_then(Value::as_str);
232
+ if let Some(reference) = requested {
233
+ if let Some(root) = lock(state)?.root(reference) {
234
+ return Ok((reference.to_owned(), root));
235
+ }
236
+ }
237
+ if lock(state)?.roots().is_empty() {
238
+ list_roots(state, &json!({})).await?;
239
+ }
240
+ let roots = lock(state)?.roots();
241
+ let requested_pid = args.get("pid").and_then(Value::as_u64);
242
+ roots
243
+ .into_iter()
244
+ .find(|(_, root)| {
245
+ requested.is_none() && requested_pid.map(|pid| root.pid == pid).unwrap_or(true)
246
+ })
247
+ .ok_or_else(|| ProtocolError::new("Root not found", ErrorCode::TargetNotFound))
248
+ }
249
+
250
+ fn initial_image_size(root: &RootSnapshot, base_size: Option<(u16, u16)>) -> (u16, u16) {
251
+ base_size.unwrap_or_else(|| {
252
+ root.frame
253
+ .as_ref()
254
+ .map(|frame| {
255
+ (
256
+ frame.width.max(1).min(i32::from(u16::MAX)) as u16,
257
+ frame.height.max(1).min(i32::from(u16::MAX)) as u16,
258
+ )
259
+ })
260
+ .unwrap_or((1, 1))
261
+ })
262
+ }
263
+
264
+ async fn look(state: &Arc<Mutex<HelperState>>, args: &Value) -> Result<Value, ProtocolError> {
265
+ let started = Instant::now();
266
+ let (root_ref, root) = resolve_root(state, args).await?;
267
+ let read_text = args
268
+ .get("readText")
269
+ .and_then(Value::as_str)
270
+ .unwrap_or("auto");
271
+ if !matches!(read_text, "auto" | "always" | "never") {
272
+ return Err(invalid("readText must be auto, always, or never"));
273
+ }
274
+ let max_nodes = args
275
+ .get("maxNodes")
276
+ .and_then(Value::as_u64)
277
+ .unwrap_or(DEFAULT_MAX_NODES as u64)
278
+ .clamp(1, 4096) as usize;
279
+ let max_depth = args
280
+ .get("maxDepth")
281
+ .and_then(Value::as_u64)
282
+ .unwrap_or(20)
283
+ .clamp(1, 64) as usize;
284
+ let max_dimension = args
285
+ .get("maxDimension")
286
+ .and_then(Value::as_u64)
287
+ .map(|value| value.clamp(1, 16_384) as u32);
288
+ let client = AtspiClient::connect().await?;
289
+ let scope_ref = args.get("scopeRef").and_then(Value::as_str);
290
+ let base_look_id = args.get("baseLookId").and_then(Value::as_str);
291
+ let (nodes, base) = if let Some(scope_ref) = scope_ref {
292
+ let (base, scope) = lock(state)?.scope(scope_ref, base_look_id)?;
293
+ if base.root.accessible != root.accessible {
294
+ return Err(ProtocolError::new(
295
+ "Scope ref is outside the target root",
296
+ ErrorCode::StaleRef,
297
+ ));
298
+ }
299
+ (
300
+ client
301
+ .snapshot_from(&scope.accessible, max_nodes, max_depth)
302
+ .await?,
303
+ Some(base),
304
+ )
305
+ } else {
306
+ (client.snapshot(&root, max_nodes, max_depth).await?, None)
307
+ };
308
+ let (look_id, nodes) = lock(state)?.insert_look(root.clone(), nodes, base.as_ref());
309
+ let mut outline = outline_json(&root, &nodes, max_nodes);
310
+ let elapsed = started.elapsed().as_millis() as u64;
311
+ let frame = root
312
+ .frame
313
+ .as_ref()
314
+ .map(|r| json!({"x":r.x,"y":r.y,"w":r.width,"h":r.height}))
315
+ .unwrap_or_else(|| json!({"x":0,"y":0,"w":0,"h":0}));
316
+ let mut image_size = initial_image_size(
317
+ &root,
318
+ base.as_ref()
319
+ .map(|record| (record.image_width, record.image_height)),
320
+ );
321
+ let image = if args
322
+ .get("includeImage")
323
+ .and_then(Value::as_bool)
324
+ .unwrap_or(true)
325
+ {
326
+ match root.x11_window {
327
+ Some(window) => {
328
+ let shot = x11::capture_window(window, max_dimension)?;
329
+ image_size = (shot.width, shot.height);
330
+ Some(json!({
331
+ "jpegBase64": shot.png_base64, "mimeType":"image/png", "width":shot.width, "height":shot.height,
332
+ "metadata":{"source":shot.source,"warnings":shot.warnings}
333
+ }))
334
+ }
335
+ None => None,
336
+ }
337
+ } else {
338
+ None
339
+ };
340
+ lock(state)?.set_look_image_size(&look_id, image_size.0, image_size.1)?;
341
+ let (scale_x, scale_y) = root
342
+ .frame
343
+ .as_ref()
344
+ .map(|frame| {
345
+ (
346
+ f64::from(image_size.0) / f64::from(frame.width.max(1)),
347
+ f64::from(image_size.1) / f64::from(frame.height.max(1)),
348
+ )
349
+ })
350
+ .unwrap_or((1.0, 1.0));
351
+ scale_outline_rects(&mut outline, scale_x, scale_y);
352
+ let mut response = json!({
353
+ "lookId": look_id,
354
+ "capturedAt": now_seconds(),
355
+ "window": {
356
+ "windowId": root.x11_window,
357
+ "rootRef": root_ref,
358
+ "kind": "window",
359
+ "framePoints": frame,
360
+ "scaleFactor": scale_x,
361
+ "isModal": root.role.to_lowercase().contains("dialog"),
362
+ "role": root.role,
363
+ "subrole": "",
364
+ "metadata": {"backend":"at-spi2","imageScaleX":scale_x,"imageScaleY":scale_y}
365
+ },
366
+ "outline": outline,
367
+ "timings": {"captureMs":0,"describeMs":elapsed,"readTextMs":0,"totalMs":elapsed},
368
+ "readText": {"requested":read_text,"executed":false}
369
+ });
370
+ if let Some(image) = image {
371
+ response["image"] = image;
372
+ }
373
+ Ok(response)
374
+ }
375
+
376
+ fn focus_window(state: &Arc<Mutex<HelperState>>, args: &Value) -> Result<Value, ProtocolError> {
377
+ let reference = args
378
+ .get("rootRef")
379
+ .or_else(|| args.get("windowRef"))
380
+ .and_then(Value::as_str)
381
+ .ok_or_else(|| invalid("focusWindow requires rootRef"))?;
382
+ let root = lock(state)?
383
+ .root(reference)
384
+ .ok_or_else(|| ProtocolError::new("Root not found", ErrorCode::TargetNotFound))?;
385
+ let window = root
386
+ .x11_window
387
+ .ok_or_else(|| capability("Root is not correlated with an X11 window"))?;
388
+ let policy = if args.get("policy").is_some() {
389
+ PhysicalPolicy::parse(args.get("policy").and_then(Value::as_str))?
390
+ } else {
391
+ PhysicalPolicy::Foreground
392
+ };
393
+ x11::focus_window(window, policy)
394
+ }
395
+
396
+ fn open_browser_location(args: &Value) -> Result<Value, ProtocolError> {
397
+ let url = required_str(args, "url", "openBrowserLocation requires a non-empty url")?;
398
+ std::process::Command::new("xdg-open")
399
+ .arg(url)
400
+ .spawn()
401
+ .map_err(|error| {
402
+ ProtocolError::new(
403
+ format!("Failed to launch xdg-open: {error}"),
404
+ ErrorCode::CapabilityDeferred,
405
+ )
406
+ })?;
407
+ Ok(json!({"opened": true, "delivery": "xdg-open"}))
408
+ }
409
+
410
+ async fn act(state: &Arc<Mutex<HelperState>>, args: &Value) -> Result<Value, ProtocolError> {
411
+ let look_id = required_str(args, "lookId", "act requires lookId")?;
412
+ let action = required_str(args, "action", "act requires action")?;
413
+ let target = args
414
+ .get("target")
415
+ .ok_or_else(|| invalid("act requires target"))?;
416
+ let params = args.get("params").cloned().unwrap_or_else(|| json!({}));
417
+ let policy = PhysicalPolicy::parse(args.get("policy").and_then(Value::as_str))?;
418
+ let record = lock(state)?.look(look_id)?;
419
+ let client = AtspiClient::connect().await?;
420
+
421
+ if let Some(reference) = target.get("ref").and_then(Value::as_str) {
422
+ let node = lock(state)?.element(look_id, reference)?;
423
+ match action {
424
+ "press" | "click" => {
425
+ if let Ok(worked) = client.press(&node).await {
426
+ return Ok(
427
+ json!({"outcome":if worked{"worked"}else{"didnt"},"performed":semantic_performed()}),
428
+ );
429
+ }
430
+ }
431
+ "setText" | "typeText" => {
432
+ let text = params.get("text").and_then(Value::as_str).unwrap_or("");
433
+ if let Ok(worked) = client.set_text(&node, text).await {
434
+ return Ok(
435
+ json!({"outcome":if worked{"worked"}else{"didnt"},"performed":semantic_performed(),"evidence":{"value":text}}),
436
+ );
437
+ }
438
+ }
439
+ _ => {}
440
+ }
441
+ // Semantic delivery was attempted first. Only an explicitly physical-capable
442
+ // policy may fall back to the element centre.
443
+ let bounds = node.bounds.ok_or_else(|| {
444
+ ProtocolError::new(
445
+ "Element has no coordinate fallback",
446
+ ErrorCode::CoordinateUnavailableForRoot,
447
+ )
448
+ })?;
449
+ let frame = record.root.frame.clone().ok_or_else(|| {
450
+ ProtocolError::new(
451
+ "Root has no coordinate geometry",
452
+ ErrorCode::CoordinateUnavailableForRoot,
453
+ )
454
+ })?;
455
+ let owning_window = record.root.x11_window.ok_or_else(|| {
456
+ capability("Observed root is not correlated with an owning X11 window")
457
+ })?;
458
+ return physical_act(
459
+ action,
460
+ &params,
461
+ policy,
462
+ owning_window,
463
+ (bounds.x + bounds.width / 2, bounds.y + bounds.height / 2),
464
+ ActionGeometry {
465
+ frame,
466
+ image_width: record.image_width,
467
+ image_height: record.image_height,
468
+ },
469
+ );
470
+ }
471
+ let x = target
472
+ .get("x")
473
+ .and_then(Value::as_f64)
474
+ .ok_or_else(|| invalid("coordinate target requires x"))?;
475
+ let y = target
476
+ .get("y")
477
+ .and_then(Value::as_f64)
478
+ .ok_or_else(|| invalid("coordinate target requires y"))?;
479
+ let owning_window = record
480
+ .root
481
+ .x11_window
482
+ .ok_or_else(|| capability("Observed root is not correlated with an owning X11 window"))?;
483
+ let frame = record.root.frame.ok_or_else(|| {
484
+ ProtocolError::new(
485
+ "Root has no coordinate geometry",
486
+ ErrorCode::CoordinateUnavailableForRoot,
487
+ )
488
+ })?;
489
+ let (screen_x, screen_y) =
490
+ image_point_to_screen(&frame, record.image_width, record.image_height, x, y)?;
491
+ physical_act(
492
+ action,
493
+ &params,
494
+ policy,
495
+ owning_window,
496
+ (screen_x, screen_y),
497
+ ActionGeometry {
498
+ frame,
499
+ image_width: record.image_width,
500
+ image_height: record.image_height,
501
+ },
502
+ )
503
+ }
504
+
505
+ #[derive(Debug, Clone)]
506
+ struct ActionGeometry {
507
+ frame: Rect,
508
+ image_width: u16,
509
+ image_height: u16,
510
+ }
511
+
512
+ fn image_point_to_screen(
513
+ frame: &Rect,
514
+ image_width: u16,
515
+ image_height: u16,
516
+ x: f64,
517
+ y: f64,
518
+ ) -> Result<(i32, i32), ProtocolError> {
519
+ if !x.is_finite()
520
+ || !y.is_finite()
521
+ || x < 0.0
522
+ || y < 0.0
523
+ || x >= f64::from(image_width)
524
+ || y >= f64::from(image_height)
525
+ {
526
+ return Err(invalid("Coordinates are outside the owning look image"));
527
+ }
528
+ Ok((
529
+ frame.x + (x * f64::from(frame.width) / f64::from(image_width.max(1))).round() as i32,
530
+ frame.y + (y * f64::from(frame.height) / f64::from(image_height.max(1))).round() as i32,
531
+ ))
532
+ }
533
+
534
+ fn scale_outline_rects(value: &mut Value, scale_x: f64, scale_y: f64) {
535
+ match value {
536
+ Value::Object(object) => {
537
+ if let Some(Value::Object(rect)) = object.get_mut("rect") {
538
+ for (key, scale) in [
539
+ ("x", scale_x),
540
+ ("w", scale_x),
541
+ ("y", scale_y),
542
+ ("h", scale_y),
543
+ ] {
544
+ if let Some(number) = rect.get(key).and_then(Value::as_f64) {
545
+ rect.insert(key.to_owned(), json!(number * scale));
546
+ }
547
+ }
548
+ }
549
+ for child in object.values_mut() {
550
+ scale_outline_rects(child, scale_x, scale_y);
551
+ }
552
+ }
553
+ Value::Array(items) => {
554
+ for child in items {
555
+ scale_outline_rects(child, scale_x, scale_y);
556
+ }
557
+ }
558
+ _ => {}
559
+ }
560
+ }
561
+
562
+ fn physical_act(
563
+ action: &str,
564
+ params: &Value,
565
+ policy: PhysicalPolicy,
566
+ owning_window: u32,
567
+ point: (i32, i32),
568
+ geometry: ActionGeometry,
569
+ ) -> Result<Value, ProtocolError> {
570
+ let (x, y) = point;
571
+ let input = x11::Input::connect(policy, owning_window)?;
572
+ let button = params
573
+ .get("button")
574
+ .and_then(Value::as_str)
575
+ .unwrap_or("left");
576
+ match action {
577
+ "press" | "click" => input.click(
578
+ x,
579
+ y,
580
+ button,
581
+ params
582
+ .get("clickCount")
583
+ .and_then(Value::as_u64)
584
+ .unwrap_or(1),
585
+ )?,
586
+ "moveMouse" => input.move_pointer(x, y)?,
587
+ "scroll" => input.scroll(
588
+ x,
589
+ y,
590
+ params.get("scrollX").and_then(Value::as_f64).unwrap_or(0.0),
591
+ params.get("scrollY").and_then(Value::as_f64).unwrap_or(0.0),
592
+ )?,
593
+ "drag" => {
594
+ let path = params
595
+ .get("path")
596
+ .and_then(Value::as_array)
597
+ .ok_or_else(|| invalid("drag requires path"))?;
598
+ let points = path
599
+ .iter()
600
+ .map(|p| {
601
+ image_point_to_screen(
602
+ &geometry.frame,
603
+ geometry.image_width,
604
+ geometry.image_height,
605
+ p.get("x")
606
+ .and_then(Value::as_f64)
607
+ .ok_or_else(|| invalid("drag point requires x"))?,
608
+ p.get("y")
609
+ .and_then(Value::as_f64)
610
+ .ok_or_else(|| invalid("drag point requires y"))?,
611
+ )
612
+ })
613
+ .collect::<Result<Vec<_>, ProtocolError>>()?;
614
+ input.drag(&points, button)?;
615
+ }
616
+ "typeText" => input.type_text(params.get("text").and_then(Value::as_str).unwrap_or(""))?,
617
+ "setText" => {
618
+ input.click(x, y, button, 1)?;
619
+ input.keypress(&["control", "a"])?;
620
+ input.type_text(params.get("text").and_then(Value::as_str).unwrap_or(""))?;
621
+ }
622
+ "keypress" => {
623
+ let keys = params
624
+ .get("keys")
625
+ .and_then(Value::as_array)
626
+ .ok_or_else(|| invalid("keypress requires keys"))?
627
+ .iter()
628
+ .filter_map(Value::as_str)
629
+ .collect::<Vec<_>>();
630
+ input.keypress(&keys)?;
631
+ }
632
+ other => {
633
+ return Err(ProtocolError::new(
634
+ format!("Unsupported Linux action '{other}'"),
635
+ ErrorCode::UnsupportedCommand,
636
+ ))
637
+ }
638
+ }
639
+ Ok(
640
+ json!({"outcome":"unknown","performed":{"grounding":"coordinates","delivery":"hid","mechanism":"xtest","deltaSource":"snapshot","windowId":owning_window},"evidence":{"mechanism":"xtest","windowId":owning_window}}),
641
+ )
642
+ }
643
+
644
+ async fn act_batch(state: &Arc<Mutex<HelperState>>, args: &Value) -> Result<Value, ProtocolError> {
645
+ let actions = args
646
+ .get("actions")
647
+ .and_then(Value::as_array)
648
+ .ok_or_else(|| invalid("actBatch requires actions"))?;
649
+ if actions.is_empty() || actions.len() > 20 {
650
+ return Err(invalid("actBatch requires 1...20 actions"));
651
+ }
652
+ let first_look = required_str(&actions[0], "lookId", "actBatch actions require lookId")?;
653
+ if actions
654
+ .iter()
655
+ .any(|action| action.get("lookId").and_then(Value::as_str) != Some(first_look))
656
+ {
657
+ return Err(invalid("actBatch actions must belong to one look"));
658
+ }
659
+ let mut steps = Vec::new();
660
+ let mut stopped_at = None;
661
+ for (index, action) in actions.iter().enumerate() {
662
+ match act(state, action).await {
663
+ Ok(step) => {
664
+ let stopped = step.get("outcome").and_then(Value::as_str) == Some("didnt");
665
+ steps.push(step);
666
+ if stopped {
667
+ stopped_at = Some(index);
668
+ break;
669
+ }
670
+ }
671
+ Err(error) => {
672
+ steps.push(json!({"outcome":"didnt","error":{"code":error.code.to_string(),"message":error.message}}));
673
+ stopped_at = Some(index);
674
+ break;
675
+ }
676
+ }
677
+ }
678
+ let outcome = batch_outcome(&steps, stopped_at.is_some());
679
+ let mut response = json!({
680
+ "outcome": outcome,
681
+ "performed":{"transaction":true,"actionCount":steps.len(),"deltaSource":"snapshot"},
682
+ "steps":steps
683
+ });
684
+ if let Some(index) = stopped_at {
685
+ response["stoppedAt"] = json!(index);
686
+ }
687
+ Ok(response)
688
+ }
689
+
690
+ fn batch_outcome(steps: &[Value], stopped: bool) -> &'static str {
691
+ if stopped {
692
+ "didnt"
693
+ } else if steps
694
+ .iter()
695
+ .any(|step| step.get("outcome").and_then(Value::as_str) == Some("unknown"))
696
+ {
697
+ "unknown"
698
+ } else {
699
+ "worked"
700
+ }
701
+ }
702
+
703
+ async fn read_text(state: &Arc<Mutex<HelperState>>, args: &Value) -> Result<Value, ProtocolError> {
704
+ let look_id = required_str(args, "lookId", "atspiReadText requires lookId")?;
705
+ let element_ref = required_str(args, "elementRef", "atspiReadText requires elementRef")?;
706
+ let node = lock(state)?.element(look_id, element_ref)?;
707
+ if node.is_secure {
708
+ return Err(ProtocolError::new(
709
+ "Refers to a secure text field; refusing to read its value",
710
+ ErrorCode::SecureTextUnreadable,
711
+ ));
712
+ }
713
+ let text = AtspiClient::connect()
714
+ .await?
715
+ .read_text(&node.accessible)
716
+ .await?;
717
+ let characters = text.chars().collect::<Vec<_>>();
718
+ let offset = args.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
719
+ let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(4096) as usize;
720
+ let end = offset.saturating_add(limit).min(characters.len());
721
+ let slice = if offset >= characters.len() {
722
+ String::new()
723
+ } else {
724
+ characters[offset..end].iter().collect()
725
+ };
726
+ Ok(
727
+ json!({"text":slice,"offset":offset,"limit":limit,"totalChars":characters.len(),"hasMore":end<characters.len()}),
728
+ )
729
+ }
730
+
731
+ async fn wait_for(state: &Arc<Mutex<HelperState>>, args: &Value) -> Result<Value, ProtocolError> {
732
+ let text = args
733
+ .get("text")
734
+ .and_then(Value::as_str)
735
+ .map(|s| s.to_lowercase());
736
+ let role = args
737
+ .get("role")
738
+ .and_then(Value::as_str)
739
+ .map(|s| s.to_lowercase());
740
+ let value = args
741
+ .get("value")
742
+ .and_then(Value::as_str)
743
+ .map(|s| s.to_lowercase());
744
+ if text.is_none() && role.is_none() && value.is_none() {
745
+ return Err(invalid("atspiWaitFor requires text, role, or value"));
746
+ }
747
+ let (_, root) = resolve_root(state, args).await?;
748
+ let scope_exact = args
749
+ .get("scopeExact")
750
+ .and_then(Value::as_bool)
751
+ .unwrap_or(false);
752
+ let scope = match (
753
+ args.get("lookId").and_then(Value::as_str),
754
+ args.get("scopeRef").and_then(Value::as_str),
755
+ ) {
756
+ (Some(look_id), Some(scope_ref)) => {
757
+ let record = lock(state)?.look(look_id)?;
758
+ if record.root.accessible != root.accessible {
759
+ return Err(ProtocolError::new(
760
+ "Scope ref is outside the target root",
761
+ ErrorCode::StaleRef,
762
+ ));
763
+ }
764
+ Some(lock(state)?.element(look_id, scope_ref)?)
765
+ }
766
+ (None, Some(_)) => return Err(invalid("scopeRef requires lookId")),
767
+ _ => None,
768
+ };
769
+ let gone = args.get("gone").and_then(Value::as_bool).unwrap_or(false);
770
+ let timeout = args
771
+ .get("timeoutMs")
772
+ .and_then(Value::as_u64)
773
+ .unwrap_or(10_000)
774
+ .clamp(100, 60_000);
775
+ let deadline = Instant::now() + Duration::from_millis(timeout);
776
+ let client = AtspiClient::connect().await?;
777
+ let mut node_count = 0;
778
+ while Instant::now() < deadline {
779
+ let nodes = match &scope {
780
+ Some(scope) => {
781
+ client
782
+ .snapshot_from(&scope.accessible, DEFAULT_MAX_NODES, 20)
783
+ .await?
784
+ }
785
+ None => client.snapshot(&root, DEFAULT_MAX_NODES, 20).await?,
786
+ };
787
+ node_count = nodes.len();
788
+ let found = nodes
789
+ .iter()
790
+ .any(|node| matches_scoped_node(node, scope_exact, &text, &role, &value));
791
+ if found != gone {
792
+ return Ok(
793
+ json!({"found":true,"gone":if gone {Some(true)} else {None::<bool>},"nodeCount":node_count}),
794
+ );
795
+ }
796
+ tokio::time::sleep(Duration::from_millis(150)).await;
797
+ }
798
+ Ok(json!({"found":false,"timedOut":true,"nodeCount":node_count}))
799
+ }
800
+
801
+ fn semantic_performed() -> Value {
802
+ json!({"grounding":"description","delivery":"ax","deltaSource":"snapshot"})
803
+ }
804
+
805
+ fn matches_scoped_node(
806
+ node: &NodeSnapshot,
807
+ scope_exact: bool,
808
+ text: &Option<String>,
809
+ role: &Option<String>,
810
+ value: &Option<String>,
811
+ ) -> bool {
812
+ (!scope_exact || node.depth == 0) && matches_node(node, text, role, value)
813
+ }
814
+
815
+ fn matches_node(
816
+ node: &NodeSnapshot,
817
+ text: &Option<String>,
818
+ role: &Option<String>,
819
+ value: &Option<String>,
820
+ ) -> bool {
821
+ text.as_ref()
822
+ .map(|needle| node.searchable_text().contains(needle))
823
+ .unwrap_or(true)
824
+ && role
825
+ .as_ref()
826
+ .map(|expected| node.role.to_lowercase() == *expected)
827
+ .unwrap_or(true)
828
+ && value
829
+ .as_ref()
830
+ .map(|expected| node.value.trim().to_lowercase() == *expected)
831
+ .unwrap_or(true)
832
+ }
833
+
834
+ fn required_str<'a>(value: &'a Value, key: &str, message: &str) -> Result<&'a str, ProtocolError> {
835
+ value
836
+ .get(key)
837
+ .and_then(Value::as_str)
838
+ .filter(|text| !text.is_empty())
839
+ .ok_or_else(|| invalid(message))
840
+ }
841
+
842
+ fn lock(
843
+ state: &Arc<Mutex<HelperState>>,
844
+ ) -> Result<std::sync::MutexGuard<'_, HelperState>, ProtocolError> {
845
+ state
846
+ .lock()
847
+ .map_err(|_| ProtocolError::new("helper state lock poisoned", ErrorCode::InternalError))
848
+ }
849
+
850
+ fn now_seconds() -> f64 {
851
+ SystemTime::now()
852
+ .duration_since(UNIX_EPOCH)
853
+ .unwrap_or_default()
854
+ .as_secs_f64()
855
+ }
856
+
857
+ fn invalid(message: impl Into<String>) -> ProtocolError {
858
+ ProtocolError::new(message, ErrorCode::InvalidRequest)
859
+ }
860
+
861
+ fn capability(message: impl Into<String>) -> ProtocolError {
862
+ ProtocolError::new(message, ErrorCode::CapabilityDeferred)
863
+ }
864
+
865
+ #[cfg(test)]
866
+ mod tests {
867
+ use super::*;
868
+ use linux_bridge::atspi::AccessibleRef;
869
+ use std::collections::HashSet;
870
+
871
+ fn node() -> NodeSnapshot {
872
+ NodeSnapshot {
873
+ reference: "linux:look_1:@e1".into(),
874
+ accessible: AccessibleRef {
875
+ destination: ":1.1".into(),
876
+ path: "/node".into(),
877
+ },
878
+ parent: None,
879
+ name: "Save document".into(),
880
+ role: "push button".into(),
881
+ description: "Writes changes".into(),
882
+ value: String::new(),
883
+ interfaces: HashSet::new(),
884
+ bounds: None,
885
+ is_secure: false,
886
+ depth: 0,
887
+ }
888
+ }
889
+
890
+ #[test]
891
+ fn scoped_matching_includes_descendants_unless_exact() {
892
+ let mut descendant = node();
893
+ descendant.depth = 1;
894
+ assert!(matches_scoped_node(
895
+ &descendant,
896
+ false,
897
+ &Some("save".into()),
898
+ &None,
899
+ &None
900
+ ));
901
+ assert!(!matches_scoped_node(
902
+ &descendant,
903
+ true,
904
+ &Some("save".into()),
905
+ &None,
906
+ &None
907
+ ));
908
+ }
909
+
910
+ #[test]
911
+ fn semantic_delivery_uses_shared_ax_label() {
912
+ assert_eq!(semantic_performed()["delivery"], "ax");
913
+ assert_eq!(semantic_performed()["grounding"], "description");
914
+ }
915
+
916
+ #[test]
917
+ fn matching_is_case_insensitive_and_conjunctive() {
918
+ let node = node();
919
+ assert!(matches_node(
920
+ &node,
921
+ &Some("SAVE".to_lowercase()),
922
+ &Some("push button".into()),
923
+ &None
924
+ ));
925
+ assert!(!matches_node(
926
+ &node,
927
+ &Some("save".into()),
928
+ &Some("checkbox".into()),
929
+ &None
930
+ ));
931
+ }
932
+
933
+ #[test]
934
+ fn request_id_is_recovered_from_invalid_envelope() {
935
+ assert_eq!(
936
+ extract_id(r#"{"id":"abc","oops":1}"#).as_deref(),
937
+ Some("abc")
938
+ );
939
+ }
940
+
941
+ #[test]
942
+ fn successful_portal_diagnostics_are_capabilities_only() {
943
+ let value = portal_diagnostics(Some(Ok(PortalCapabilities {
944
+ remote_desktop_version: 2,
945
+ screen_cast_version: 6,
946
+ available_device_types: 3,
947
+ available_source_types: 3,
948
+ available_cursor_modes: 5,
949
+ })));
950
+ assert_eq!(value["available"], true);
951
+ assert_eq!(value["interactiveUseEnabled"], false);
952
+ assert_eq!(value["readOnlyProbe"], true);
953
+ assert_eq!(value["remoteDesktopVersion"], 2);
954
+ assert_eq!(value["screenCastVersion"], 6);
955
+ assert_eq!(value["availableDeviceTypes"], 3);
956
+ assert_eq!(value["availableSourceTypes"], 3);
957
+ assert_eq!(value["availableCursorModes"], 5);
958
+ }
959
+
960
+ #[test]
961
+ fn failed_portal_probe_is_reported_without_enabling_portal_use() {
962
+ let value = portal_diagnostics(Some(Err(PortalError::Unavailable(
963
+ "no portal service".into(),
964
+ ))));
965
+ assert_eq!(value["available"], false);
966
+ assert_eq!(value["interactiveUseEnabled"], false);
967
+ assert_eq!(value["error"]["kind"], "portal_unavailable");
968
+ assert!(value["error"]["message"]
969
+ .as_str()
970
+ .unwrap()
971
+ .contains("no portal service"));
972
+ }
973
+
974
+ #[test]
975
+ fn non_wayland_diagnostics_skip_the_portal_probe() {
976
+ let value = portal_diagnostics(None);
977
+ assert_eq!(value["probed"], false);
978
+ assert_eq!(value["reason"], "not_wayland");
979
+ assert_eq!(value["interactiveUseEnabled"], false);
980
+ }
981
+
982
+ #[test]
983
+ fn scoped_no_image_keeps_downscaled_coordinate_mapping() {
984
+ let mut root = RootSnapshot {
985
+ accessible: AccessibleRef {
986
+ destination: ":1.1".into(),
987
+ path: "/window".into(),
988
+ },
989
+ pid: 1,
990
+ name: "Window".into(),
991
+ app_name: "App".into(),
992
+ role: "frame".into(),
993
+ frame: Some(Rect {
994
+ x: 100,
995
+ y: 200,
996
+ width: 2000,
997
+ height: 1000,
998
+ }),
999
+ x11_window: None,
1000
+ is_focused: false,
1001
+ is_minimized: false,
1002
+ z_order: None,
1003
+ };
1004
+ let base_size = initial_image_size(&root, Some((1000, 500)));
1005
+ let base_point = image_point_to_screen(
1006
+ root.frame.as_ref().unwrap(),
1007
+ base_size.0,
1008
+ base_size.1,
1009
+ 250.0,
1010
+ 125.0,
1011
+ )
1012
+ .unwrap();
1013
+ root.name = "Scoped expansion".into();
1014
+ let scoped_size = initial_image_size(&root, Some(base_size));
1015
+ let scoped_point = image_point_to_screen(
1016
+ root.frame.as_ref().unwrap(),
1017
+ scoped_size.0,
1018
+ scoped_size.1,
1019
+ 250.0,
1020
+ 125.0,
1021
+ )
1022
+ .unwrap();
1023
+ assert_eq!(scoped_size, (1000, 500));
1024
+ assert_eq!(scoped_point, base_point);
1025
+ }
1026
+
1027
+ #[test]
1028
+ fn downscaled_image_coordinates_map_back_to_window_points() {
1029
+ let frame = Rect {
1030
+ x: 100,
1031
+ y: 200,
1032
+ width: 2000,
1033
+ height: 1000,
1034
+ };
1035
+ assert_eq!(
1036
+ image_point_to_screen(&frame, 1000, 500, 250.0, 125.0).unwrap(),
1037
+ (600, 450)
1038
+ );
1039
+ assert!(image_point_to_screen(&frame, 1000, 500, 1000.0, 0.0).is_err());
1040
+ }
1041
+
1042
+ #[test]
1043
+ fn outline_rects_follow_image_scaling() {
1044
+ let mut outline = json!({"rect":{"x":100,"y":50,"w":400,"h":200},"text":[{"rect":{"x":20,"y":10,"w":40,"h":20}}]});
1045
+ scale_outline_rects(&mut outline, 0.5, 0.25);
1046
+ assert_eq!(
1047
+ outline["rect"],
1048
+ json!({"x":50.0,"y":12.5,"w":200.0,"h":50.0})
1049
+ );
1050
+ assert_eq!(
1051
+ outline["text"][0]["rect"],
1052
+ json!({"x":10.0,"y":2.5,"w":20.0,"h":5.0})
1053
+ );
1054
+ }
1055
+
1056
+ #[test]
1057
+ fn batch_outcome_preserves_unknown_physical_results() {
1058
+ assert_eq!(
1059
+ batch_outcome(&[json!({"outcome":"worked"})], false),
1060
+ "worked"
1061
+ );
1062
+ assert_eq!(
1063
+ batch_outcome(
1064
+ &[json!({"outcome":"worked"}), json!({"outcome":"unknown"})],
1065
+ false
1066
+ ),
1067
+ "unknown"
1068
+ );
1069
+ assert_eq!(
1070
+ batch_outcome(&[json!({"outcome":"unknown"})], true),
1071
+ "didnt"
1072
+ );
1073
+ }
1074
+ }