@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,518 @@
1
+ //! GDI screenshot capture for Windows.
2
+ //!
3
+ //! Captures window content via GDI (PrintWindow) on Windows.
4
+ //! On non-Windows platforms all entry points return a deterministic
5
+ //! `unsupported_platform` error.
6
+ //!
7
+ //! Selected-window capture only. Desktop capture is not implemented. DXGI and
8
+ //! Windows Graphics Capture are not used.
9
+
10
+ use serde_json::Value;
11
+
12
+ use crate::error::{ErrorCode, ProtocolError};
13
+ use crate::refs::{RefStore, WindowRef};
14
+
15
+ #[cfg(windows)]
16
+ use crate::state::StateId;
17
+ #[cfg(windows)]
18
+ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
19
+ #[cfg(windows)]
20
+ use image::codecs::png::PngEncoder;
21
+ #[cfg(windows)]
22
+ use image::{imageops::FilterType, ExtendedColorType, ImageEncoder, RgbaImage};
23
+ #[cfg(windows)]
24
+ use serde_json::json;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Public interface
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /// Capture a screenshot of the target window, optionally extracting UIA
31
+ /// accessibility elements from the same window.
32
+ ///
33
+ /// # Arguments
34
+ ///
35
+ /// * `store` - RefStore containing the window handle for `target_ref`.
36
+ /// Also receives inserted element refs when `include_elements` is true.
37
+ /// * `target_ref` - Reference to the window to capture.
38
+ /// * `include_elements` - When true, also extracts UIA elements and includes
39
+ /// them in the response as an `axTargets` array.
40
+ ///
41
+ /// # Returns
42
+ ///
43
+ /// On success, a JSON value with shape:
44
+ /// ```json
45
+ /// {
46
+ /// "target": "@w1",
47
+ /// "capture": { ... },
48
+ /// "warnings": [],
49
+ /// "axTargets": []
50
+ /// }
51
+ /// ```
52
+ /// The `axTargets` field is only present when `include_elements` is true
53
+ /// and at least one element was found.
54
+ ///
55
+ /// On non-Windows this always returns `UnsupportedPlatform`.
56
+ pub fn screenshot(
57
+ store: &mut RefStore,
58
+ target_ref: &WindowRef,
59
+ include_elements: bool,
60
+ max_dimension: Option<u32>,
61
+ ) -> Result<Value, ProtocolError> {
62
+ #[cfg(not(windows))]
63
+ {
64
+ let _ = store;
65
+ let _ = target_ref;
66
+ let _ = include_elements;
67
+ let _ = max_dimension;
68
+ Err(ProtocolError::new(
69
+ "Screenshot capture is only supported on Windows",
70
+ ErrorCode::UnsupportedPlatform,
71
+ ))
72
+ }
73
+
74
+ #[cfg(windows)]
75
+ {
76
+ screenshot_impl(store, target_ref, include_elements, max_dimension)
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Windows-specific implementation
82
+ // ---------------------------------------------------------------------------
83
+
84
+ #[cfg(windows)]
85
+ use windows::Win32::Foundation::{HWND, RECT};
86
+ #[cfg(windows)]
87
+ use windows::Win32::Graphics::Gdi::{
88
+ BitBlt, CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDC, GetDIBits,
89
+ ReleaseDC, SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HGDIOBJ,
90
+ SRCCOPY,
91
+ };
92
+ #[cfg(windows)]
93
+ use windows::Win32::Storage::Xps::{PrintWindow, PRINT_WINDOW_FLAGS};
94
+ #[cfg(windows)]
95
+ use windows::Win32::UI::WindowsAndMessaging::{GetWindowRect, IsIconic};
96
+
97
+ #[cfg(windows)]
98
+ fn screenshot_impl(
99
+ store: &mut RefStore,
100
+ target_ref: &WindowRef,
101
+ include_elements: bool,
102
+ max_dimension: Option<u32>,
103
+ ) -> Result<Value, ProtocolError> {
104
+ // 1. Look up the window handle.
105
+ let native = store.get_window(target_ref).ok_or_else(|| {
106
+ ProtocolError::new(
107
+ format!("Window ref '{}' not found", target_ref),
108
+ ErrorCode::TargetNotFound,
109
+ )
110
+ })?;
111
+ let hwnd = HWND(native.raw() as *mut _);
112
+
113
+ let mut warnings: Vec<String> = Vec::new();
114
+
115
+ // 2. Check for minimized state.
116
+ let is_minimized = unsafe { IsIconic(hwnd).as_bool() };
117
+ if is_minimized {
118
+ warnings.push("window_minimized".to_owned());
119
+ }
120
+
121
+ // 3. Get the window rect so we know capture dimensions.
122
+ let (x, y, width, height) = unsafe {
123
+ let mut rect = RECT::default();
124
+ if GetWindowRect(hwnd, &mut rect).is_err() {
125
+ return Err(ProtocolError::new(
126
+ "Failed to get window bounds",
127
+ ErrorCode::CaptureFailed,
128
+ ));
129
+ }
130
+ let w = (rect.right - rect.left).max(0);
131
+ let h = (rect.bottom - rect.top).max(0);
132
+ (rect.left, rect.top, w, h)
133
+ };
134
+
135
+ if width == 0 || height == 0 {
136
+ warnings.push("zero_sized_window".to_owned());
137
+ // Return capture metadata with zero dimensions and no image data.
138
+ let state_id = StateId::fresh("s");
139
+ return Ok(json!({
140
+ "target": target_ref.to_string(),
141
+ "capture": {
142
+ "stateId": state_id,
143
+ "x": x,
144
+ "y": y,
145
+ "width": 0,
146
+ "height": 0,
147
+ "imageFormat": "png",
148
+ "imageBase64": null,
149
+ },
150
+ "warnings": warnings,
151
+ }));
152
+ }
153
+
154
+ // 4. GDI capture (unsafe FFI block).
155
+ // SAFETY: All GDI objects are created and destroyed within this
156
+ // function. Object lifetimes follow the Acquire → Use → Release
157
+ // pattern with proper cleanup on every error path.
158
+ let (png_base64, output_width, output_height) =
159
+ unsafe { gdi_capture_to_base64(hwnd, x, y, width, height, max_dimension) }?;
160
+
161
+ let state_id = StateId::fresh("s");
162
+
163
+ // Build the base response.
164
+ let mut result = json!({
165
+ "target": target_ref.to_string(),
166
+ "capture": {
167
+ "stateId": state_id,
168
+ "x": x,
169
+ "y": y,
170
+ "width": output_width,
171
+ "height": output_height,
172
+ "imageFormat": "png",
173
+ "imageBase64": png_base64,
174
+ },
175
+ "warnings": warnings,
176
+ });
177
+
178
+ // 5. Optionally extract UIA accessibility elements.
179
+ if include_elements {
180
+ let elements = crate::uia::extract_elements(store, hwnd.0 as isize);
181
+ if !elements.is_empty() {
182
+ if let Some(obj) = result.as_object_mut() {
183
+ obj.insert("axTargets".to_owned(), Value::Array(elements));
184
+ }
185
+ }
186
+ }
187
+
188
+ Ok(result)
189
+ }
190
+
191
+ /// Perform GDI capture of the given window and return a base64-encoded PNG.
192
+ ///
193
+ /// # Safety
194
+ ///
195
+ /// Caller must provide a valid HWND and positive dimensions.
196
+ #[cfg(windows)]
197
+ unsafe fn gdi_capture_to_base64(
198
+ hwnd: HWND,
199
+ window_x: i32,
200
+ window_y: i32,
201
+ width: i32,
202
+ height: i32,
203
+ max_dimension: Option<u32>,
204
+ ) -> Result<(String, u32, u32), ProtocolError> {
205
+ // Acquire the window DC.
206
+ let hdc_window = GetDC(hwnd);
207
+ if hdc_window.is_invalid() {
208
+ return Err(ProtocolError::new("GetDC failed", ErrorCode::CaptureFailed));
209
+ }
210
+
211
+ // Create a compatible memory DC.
212
+ let hdc_mem = CreateCompatibleDC(hdc_window);
213
+ if hdc_mem.is_invalid() {
214
+ ReleaseDC(hwnd, hdc_window);
215
+ return Err(ProtocolError::new(
216
+ "CreateCompatibleDC failed",
217
+ ErrorCode::CaptureFailed,
218
+ ));
219
+ }
220
+
221
+ // Create a compatible bitmap.
222
+ let hbitmap = CreateCompatibleBitmap(hdc_window, width, height);
223
+ if hbitmap.is_invalid() {
224
+ let _ = DeleteDC(hdc_mem);
225
+ ReleaseDC(hwnd, hdc_window);
226
+ return Err(ProtocolError::new(
227
+ "CreateCompatibleBitmap failed",
228
+ ErrorCode::CaptureFailed,
229
+ ));
230
+ }
231
+
232
+ // Select bitmap into memory DC (save old to restore later).
233
+ let old_bitmap = SelectObject(hdc_mem, hbitmap);
234
+
235
+ // Render the window content using PrintWindow (client area).
236
+ let pw_ok = PrintWindow(hwnd, hdc_mem, PRINT_WINDOW_FLAGS(0));
237
+ if !pw_ok.as_bool() {
238
+ // PrintWindow can fail for various reasons. We note it but
239
+ // continue — the DC might still have partial content.
240
+ }
241
+
242
+ // Prepare BITMAPINFO for GetDIBits (request 32-bit BGRA top-down).
243
+ let header = BITMAPINFOHEADER {
244
+ biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
245
+ biWidth: width,
246
+ biHeight: -height, // negative = top-down
247
+ biPlanes: 1,
248
+ biBitCount: 32,
249
+ biCompression: BI_RGB.0,
250
+ biSizeImage: 0,
251
+ biXPelsPerMeter: 0,
252
+ biYPelsPerMeter: 0,
253
+ biClrUsed: 0,
254
+ biClrImportant: 0,
255
+ };
256
+ let mut bmi = BITMAPINFO {
257
+ bmiHeader: header,
258
+ ..Default::default()
259
+ };
260
+
261
+ // Allocate the pixel buffer.
262
+ let buf_size = (width as usize) * (height as usize) * 4;
263
+ let mut bits: Vec<u8> = vec![0u8; buf_size];
264
+
265
+ let mut dib_ok = GetDIBits(
266
+ hdc_mem,
267
+ hbitmap,
268
+ 0,
269
+ height as u32,
270
+ Some(bits.as_mut_ptr() as *mut std::ffi::c_void),
271
+ &mut bmi,
272
+ DIB_RGB_COLORS,
273
+ );
274
+
275
+ // PrintWindow frequently returns a successful but black bitmap for GPU
276
+ // surfaces (Chromium/Electron). Fall back to the compositor-visible screen
277
+ // pixels only when the semantic capture failed or is effectively blank.
278
+ let print_window_blank = bits
279
+ .chunks_exact(4)
280
+ .step_by(97)
281
+ .all(|pixel| pixel[0] < 8 && pixel[1] < 8 && pixel[2] < 8);
282
+ if !pw_ok.as_bool() || dib_ok == 0 || print_window_blank {
283
+ let screen_dc = GetDC(HWND(std::ptr::null_mut()));
284
+ if !screen_dc.is_invalid()
285
+ && BitBlt(
286
+ hdc_mem, 0, 0, width, height, screen_dc, window_x, window_y, SRCCOPY,
287
+ )
288
+ .is_ok()
289
+ {
290
+ dib_ok = GetDIBits(
291
+ hdc_mem,
292
+ hbitmap,
293
+ 0,
294
+ height as u32,
295
+ Some(bits.as_mut_ptr() as *mut std::ffi::c_void),
296
+ &mut bmi,
297
+ DIB_RGB_COLORS,
298
+ );
299
+ }
300
+ if !screen_dc.is_invalid() {
301
+ ReleaseDC(HWND(std::ptr::null_mut()), screen_dc);
302
+ }
303
+ }
304
+
305
+ // Restore old bitmap and destroy GDI objects.
306
+ SelectObject(hdc_mem, old_bitmap);
307
+ let _ = DeleteObject(HGDIOBJ(hbitmap.0));
308
+ let _ = DeleteDC(hdc_mem);
309
+ ReleaseDC(hwnd, hdc_window);
310
+
311
+ if dib_ok == 0 {
312
+ return Err(ProtocolError::new(
313
+ "GetDIBits failed to retrieve bitmap data",
314
+ ErrorCode::CaptureFailed,
315
+ ));
316
+ }
317
+
318
+ // Convert BGRA → RGBA (GDI returns B,G,R,A; PNG expects R,G,B,A).
319
+ for chunk in bits.chunks_exact_mut(4) {
320
+ chunk.swap(0, 2);
321
+ }
322
+
323
+ let source_width = width as u32;
324
+ let source_height = height as u32;
325
+ let (output_width, output_height) = match max_dimension.filter(|limit| *limit > 0) {
326
+ Some(limit) if source_width.max(source_height) > limit => {
327
+ let scale = limit as f64 / source_width.max(source_height) as f64;
328
+ (
329
+ (source_width as f64 * scale).round().max(1.0) as u32,
330
+ (source_height as f64 * scale).round().max(1.0) as u32,
331
+ )
332
+ }
333
+ _ => (source_width, source_height),
334
+ };
335
+ let pixels = if (output_width, output_height) == (source_width, source_height) {
336
+ bits
337
+ } else {
338
+ let source = RgbaImage::from_raw(source_width, source_height, bits).ok_or_else(|| {
339
+ ProtocolError::new(
340
+ "Captured bitmap had an invalid byte length",
341
+ ErrorCode::CaptureFailed,
342
+ )
343
+ })?;
344
+ image::imageops::resize(&source, output_width, output_height, FilterType::Triangle)
345
+ .into_raw()
346
+ };
347
+
348
+ // Encode to PNG in memory.
349
+ let mut png_data: Vec<u8> = Vec::new();
350
+ {
351
+ let encoder = PngEncoder::new(&mut png_data);
352
+ encoder
353
+ .write_image(
354
+ &pixels,
355
+ output_width,
356
+ output_height,
357
+ ExtendedColorType::Rgba8,
358
+ )
359
+ .map_err(|e| {
360
+ ProtocolError::new(
361
+ format!("PNG encoding failed: {e}"),
362
+ ErrorCode::CaptureFailed,
363
+ )
364
+ })?;
365
+ }
366
+
367
+ // Base64-encode the PNG bytes.
368
+ Ok((BASE64.encode(&png_data), output_width, output_height))
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Tests
373
+ // ---------------------------------------------------------------------------
374
+
375
+ #[cfg(test)]
376
+ mod unit_tests {
377
+ use super::*;
378
+ use crate::error::ErrorCode;
379
+ #[cfg(windows)]
380
+ use crate::refs::NativeHandle;
381
+ use crate::state::StateId;
382
+
383
+ // -- Platform support check (non-Windows) -------------------------------
384
+
385
+ #[test]
386
+ #[cfg(not(windows))]
387
+ fn test_screenshot_unsupported_platform() {
388
+ let mut store = RefStore::new();
389
+ let wref = WindowRef { id: 1 };
390
+ let result = screenshot(&mut store, &wref, false, None);
391
+ assert!(result.is_err());
392
+ let err = result.unwrap_err();
393
+ assert_eq!(err.code, ErrorCode::UnsupportedPlatform);
394
+ }
395
+
396
+ // -- Response shape tests (cross-platform) -----------------------------
397
+
398
+ #[test]
399
+ fn test_screenshot_response_shape_json() {
400
+ // Verify that a successful screenshot response serialises to the
401
+ // expected JSON shape. This test does not require a real window.
402
+ let state_id = StateId::fresh("s");
403
+ let response = serde_json::json!({
404
+ "target": "@w1",
405
+ "capture": {
406
+ "stateId": state_id,
407
+ "width": 800,
408
+ "height": 600,
409
+ "imageFormat": "png",
410
+ "imageBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
411
+ },
412
+ "warnings": [],
413
+ });
414
+
415
+ // Validate structural fields
416
+ assert_eq!(response["target"].as_str(), Some("@w1"));
417
+ assert!(response["capture"].is_object());
418
+ assert!(response["warnings"].is_array());
419
+ assert!(response["warnings"].as_array().unwrap().is_empty());
420
+
421
+ let capture = &response["capture"];
422
+ assert!(capture["stateId"].as_str().unwrap().starts_with("s-"));
423
+ assert_eq!(capture["width"].as_u64(), Some(800));
424
+ assert_eq!(capture["height"].as_u64(), Some(600));
425
+ assert_eq!(capture["imageFormat"].as_str(), Some("png"));
426
+ assert!(capture["imageBase64"].as_str().unwrap().len() > 10);
427
+ }
428
+
429
+ #[test]
430
+ fn test_screenshot_response_with_warnings() {
431
+ // Verify response with warnings and null image for zero-sized capture.
432
+ let state_id = StateId::fresh("s");
433
+ let response = serde_json::json!({
434
+ "target": "@w1",
435
+ "capture": {
436
+ "stateId": state_id,
437
+ "width": 0,
438
+ "height": 0,
439
+ "imageFormat": "png",
440
+ "imageBase64": null,
441
+ },
442
+ "warnings": ["zero_sized_window"],
443
+ });
444
+
445
+ assert_eq!(response["target"].as_str(), Some("@w1"));
446
+ assert_eq!(
447
+ response["warnings"].as_array().unwrap(),
448
+ &[serde_json::json!("zero_sized_window")]
449
+ );
450
+ assert!(response["capture"]["imageBase64"].is_null());
451
+ assert_eq!(response["capture"]["width"].as_u64(), Some(0));
452
+ assert_eq!(response["capture"]["height"].as_u64(), Some(0));
453
+ }
454
+
455
+ #[test]
456
+ fn test_screenshot_target_not_found_error() {
457
+ // Verify that a non-existent window ref produces the expected error.
458
+ // On non-Windows the platform check returns UnsupportedPlatform first;
459
+ // on Windows an empty store would return TargetNotFound.
460
+ let mut store = RefStore::new();
461
+ let wref = WindowRef { id: 999 };
462
+ let result = screenshot(&mut store, &wref, false, None);
463
+ assert!(result.is_err());
464
+ let err = result.unwrap_err();
465
+ #[cfg(not(windows))]
466
+ assert_eq!(err.code, ErrorCode::UnsupportedPlatform);
467
+ #[cfg(windows)]
468
+ assert_eq!(err.code, ErrorCode::TargetNotFound);
469
+ }
470
+
471
+ // -- Windows-only integration tests ------------------------------------
472
+
473
+ #[test]
474
+ #[cfg(windows)]
475
+ fn test_screenshot_capture_fresh_state_id() {
476
+ // On Windows, verify that each screenshot gets a unique stateId.
477
+ let mut store = RefStore::new();
478
+ // Only works if there's at least one visible HWND.
479
+ // We use a synthetic handle — the test checks the stateId property
480
+ // not the actual capture quality.
481
+ let wref = store.insert_window(NativeHandle::new(0)); // HWND 0 is invalid
482
+
483
+ match screenshot(&mut store, &wref, false, None) {
484
+ Ok(val) => {
485
+ let sid = val["capture"]["stateId"]
486
+ .as_str()
487
+ .expect("stateId should be a string");
488
+ assert!(sid.starts_with("s-"), "stateId should start with s-");
489
+ }
490
+ Err(e) => {
491
+ // In CI / headless environments this will fail with
492
+ // CaptureFailed because HWND 0 is not a valid window.
493
+ // That's acceptable — the error path is exercised.
494
+ assert_eq!(
495
+ e.code,
496
+ ErrorCode::CaptureFailed,
497
+ "Expected CaptureFailed for invalid HWND: {e:?}",
498
+ );
499
+ }
500
+ }
501
+ }
502
+
503
+ #[test]
504
+ #[cfg(windows)]
505
+ fn test_screenshot_fresh_state_ids_differ() {
506
+ let mut store = RefStore::new();
507
+ let wref = store.insert_window(NativeHandle::new(0));
508
+
509
+ // Same as above; just check that two calls produce different IDs
510
+ // when they succeed or the same error when they fail.
511
+ let result_a = screenshot(&mut store, &wref, false);
512
+ let result_b = screenshot(&mut store, &wref, false);
513
+
514
+ let err_a = result_a.as_ref().err().map(|e| e.code);
515
+ let err_b = result_b.as_ref().err().map(|e| e.code);
516
+ assert_eq!(err_a, err_b, "both calls should produce the same outcome");
517
+ }
518
+ }
@@ -0,0 +1,81 @@
1
+ use serde::Serialize;
2
+ use std::fmt;
3
+
4
+ /// Error code strings sent in JSON error responses.
5
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
6
+ pub enum ErrorCode {
7
+ #[serde(rename = "capability_deferred")]
8
+ CapabilityDeferred,
9
+ #[serde(rename = "unsupported_command")]
10
+ UnsupportedCommand,
11
+ #[serde(rename = "invalid_request")]
12
+ InvalidRequest,
13
+ #[serde(rename = "target_not_found")]
14
+ TargetNotFound,
15
+ #[serde(rename = "internal_error")]
16
+ InternalError,
17
+ #[serde(rename = "unsupported_platform")]
18
+ UnsupportedPlatform,
19
+ #[serde(rename = "capture_failed")]
20
+ CaptureFailed,
21
+ #[serde(rename = "stale_look")]
22
+ StaleLook,
23
+ #[serde(rename = "stale_ref")]
24
+ StaleRef,
25
+ #[serde(rename = "coordinate_unavailable_for_root")]
26
+ CoordinateUnavailableForRoot,
27
+ #[serde(rename = "coordinate_blocked")]
28
+ CoordinateBlocked,
29
+ #[serde(rename = "foreground_required")]
30
+ ForegroundRequired,
31
+ #[serde(rename = "occluded_target")]
32
+ OccludedTarget,
33
+ #[serde(rename = "secure_text_unreadable")]
34
+ SecureTextUnreadable,
35
+ }
36
+
37
+ impl fmt::Display for ErrorCode {
38
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39
+ let s = match self {
40
+ ErrorCode::CapabilityDeferred => "capability_deferred",
41
+ ErrorCode::UnsupportedCommand => "unsupported_command",
42
+ ErrorCode::InvalidRequest => "invalid_request",
43
+ ErrorCode::TargetNotFound => "target_not_found",
44
+ ErrorCode::InternalError => "internal_error",
45
+ ErrorCode::UnsupportedPlatform => "unsupported_platform",
46
+ ErrorCode::CaptureFailed => "capture_failed",
47
+ ErrorCode::StaleLook => "stale_look",
48
+ ErrorCode::StaleRef => "stale_ref",
49
+ ErrorCode::CoordinateUnavailableForRoot => "coordinate_unavailable_for_root",
50
+ ErrorCode::CoordinateBlocked => "coordinate_blocked",
51
+ ErrorCode::ForegroundRequired => "foreground_required",
52
+ ErrorCode::OccludedTarget => "occluded_target",
53
+ ErrorCode::SecureTextUnreadable => "secure_text_unreadable",
54
+ };
55
+ write!(f, "{s}")
56
+ }
57
+ }
58
+
59
+ /// A typed protocol error with a human-readable message and a machine-readable code.
60
+ #[derive(Debug, Clone, Serialize)]
61
+ pub struct ProtocolError {
62
+ pub message: String,
63
+ pub code: ErrorCode,
64
+ }
65
+
66
+ impl ProtocolError {
67
+ pub fn new(message: impl Into<String>, code: ErrorCode) -> Self {
68
+ Self {
69
+ message: message.into(),
70
+ code,
71
+ }
72
+ }
73
+ }
74
+
75
+ impl std::error::Error for ProtocolError {}
76
+
77
+ impl fmt::Display for ProtocolError {
78
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79
+ write!(f, "[{}] {}", self.code, self.message)
80
+ }
81
+ }