@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.
- package/CHANGELOG.md +223 -0
- package/LICENSE +26 -0
- package/LICENSE.upstream +21 -0
- package/README.md +169 -0
- package/UPSTREAM.md +277 -0
- package/check.mjs +369 -0
- package/icon.png +0 -0
- package/install.sh +112 -0
- package/main.js +541 -0
- package/manifest.json +47 -0
- package/native/linux/bridge-rs/Cargo.lock +1204 -0
- package/native/linux/bridge-rs/Cargo.toml +18 -0
- package/native/linux/bridge-rs/src/atspi.rs +640 -0
- package/native/linux/bridge-rs/src/error.rs +65 -0
- package/native/linux/bridge-rs/src/lib.rs +11 -0
- package/native/linux/bridge-rs/src/main.rs +1074 -0
- package/native/linux/bridge-rs/src/protocol.rs +49 -0
- package/native/linux/bridge-rs/src/state.rs +293 -0
- package/native/linux/bridge-rs/src/wayland.rs +89 -0
- package/native/linux/bridge-rs/src/x11.rs +909 -0
- package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
- package/native/macos/agent_cursor.swift +184 -0
- package/native/macos/agent_cursor_motion.swift +252 -0
- package/native/macos/agent_cursor_tests.swift +128 -0
- package/native/macos/agent_highlight.swift +229 -0
- package/native/macos/agent_highlight_tests.swift +99 -0
- package/native/macos/bridge.swift +3852 -0
- package/native/macos/foreground_activity.swift +57 -0
- package/native/macos/foreground_activity_tests.swift +31 -0
- package/native/macos/live_stream.swift +289 -0
- package/native/windows/bridge-rs/Cargo.lock +396 -0
- package/native/windows/bridge-rs/Cargo.toml +30 -0
- package/native/windows/bridge-rs/src/capture.rs +518 -0
- package/native/windows/bridge-rs/src/error.rs +81 -0
- package/native/windows/bridge-rs/src/input.rs +501 -0
- package/native/windows/bridge-rs/src/lib.rs +16 -0
- package/native/windows/bridge-rs/src/main.rs +1683 -0
- package/native/windows/bridge-rs/src/protocol.rs +53 -0
- package/native/windows/bridge-rs/src/refs.rs +237 -0
- package/native/windows/bridge-rs/src/state.rs +55 -0
- package/native/windows/bridge-rs/src/uia.rs +1252 -0
- package/native/windows/bridge-rs/src/window.rs +718 -0
- package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
- package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
- package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
- package/package.json +74 -0
- package/prebuilt/linux/arm64/linux-bridge +0 -0
- package/prebuilt/linux/x64/linux-bridge +0 -0
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/macos/x64/bridge +0 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.json +10 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/blind-click.check.mjs +128 -0
- package/scripts/build-native.mjs +302 -0
- package/scripts/build.mjs +84 -0
- package/scripts/calc-fixture.mjs +78 -0
- package/scripts/cli-exit.check.mjs +49 -0
- package/scripts/helper-path.check.mjs +82 -0
- package/scripts/helper-refresh.check.mjs +62 -0
- package/scripts/helper-signal.check.mjs +59 -0
- package/scripts/highlight.check.mjs +32 -0
- package/scripts/keychain-free-install.check.mjs +140 -0
- package/scripts/live-view.check.mjs +124 -0
- package/scripts/macos-bundle.d.mts +1 -0
- package/scripts/macos-bundle.mjs +119 -0
- package/scripts/make-signing-cert.sh +57 -0
- package/scripts/mini-foreground.check.mjs +11 -0
- package/scripts/no-foreground.check.mjs +81 -0
- package/scripts/overlay-visible.check.mjs +221 -0
- package/scripts/package-macos-app.mjs +39 -0
- package/scripts/permissions.check.mjs +199 -0
- package/scripts/platform-contract.check.mjs +30 -0
- package/scripts/setup-helper.mjs +154 -0
- package/scripts/tangu-computer-use.entitlements +10 -0
- package/scripts/verify-macos-bundles.mjs +25 -0
- package/scripts/verify-package.mjs +74 -0
- package/skills/computer-use/SKILL.md +110 -0
- package/src/foregroundNote.ts +43 -0
- package/src/helperState.ts +31 -0
- package/src/index.ts +63 -0
- package/src/onboarding.ts +198 -0
- package/src/pi-compat.ts +49 -0
- package/src/settings.ts +11 -0
- package/src/setup.ts +97 -0
- package/src/tools.ts +289 -0
- package/src/vendor/actions.ts +130 -0
- package/src/vendor/bridge.ts +2405 -0
- package/src/vendor/cdp.ts +658 -0
- package/src/vendor/config.ts +113 -0
- package/src/vendor/contract.ts +104 -0
- package/src/vendor/note.ts +195 -0
- package/src/vendor/outline.ts +651 -0
- package/src/vendor/output.ts +134 -0
- package/src/vendor/permissions.ts +111 -0
- package/src/vendor/platform/architecture.ts +23 -0
- package/src/vendor/platform/coerce.ts +16 -0
- package/src/vendor/platform/index.ts +59 -0
- package/src/vendor/platform/linux/backend.ts +186 -0
- package/src/vendor/platform/linux/helper.ts +238 -0
- package/src/vendor/platform/macos/backend.ts +131 -0
- package/src/vendor/platform/macos/browser.ts +110 -0
- package/src/vendor/platform/macos/helper-path.d.mts +9 -0
- package/src/vendor/platform/macos/helper-path.mjs +34 -0
- package/src/vendor/platform/macos/helper.ts +291 -0
- package/src/vendor/platform/macos/permissions.ts +146 -0
- package/src/vendor/platform/types.ts +222 -0
- package/src/vendor/platform/windows/backend.ts +142 -0
- package/src/vendor/platform/windows/helper.ts +140 -0
- package/src/vendor/root-selection.ts +25 -0
- package/src/vendor/runtime.ts +129 -0
- package/src/vendor/state.ts +146 -0
- package/src/vendor/view.ts +147 -0
- package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
- package/tangu-plugins/computer-use/dist/index.js +5672 -0
- package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
- package/tsconfig.json +22 -0
- 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
|
+
}
|