VirtualDesktop 1.4.1__tar.gz → 1.5.0__tar.gz

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 (62) hide show
  1. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/PKG-INFO +51 -28
  2. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/README.md +49 -26
  3. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/__init__.py +1 -1
  4. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_desktop_runtime.py +79 -0
  5. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_winapi.py +25 -0
  6. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/agent.py +109 -17
  7. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/capture.py +6 -1
  8. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/desktop_session.py +9 -2
  9. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/window.py +1240 -889
  10. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/workspace.py +166 -29
  11. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop.egg-info/PKG-INFO +51 -28
  12. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/pyproject.toml +2 -2
  13. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_agent.py +28 -5
  14. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_workspace_instances.py +12 -6
  15. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/LICENSE +0 -0
  16. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/__main__.py +0 -0
  17. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_bundle.py +0 -0
  18. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_compat.py +0 -0
  19. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_devices.py +0 -0
  20. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_download.py +0 -0
  21. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_enumerate.py +0 -0
  22. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_helper.py +0 -0
  23. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_log.py +0 -0
  24. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_process.py +0 -0
  25. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_sigcheck.ps1 +0 -0
  26. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/_sigcheck.py +0 -0
  27. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/cli.py +0 -0
  28. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/config.py +0 -0
  29. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/desktop.py +0 -0
  30. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/desktop_capture.py +0 -0
  31. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/display_setup.py +0 -0
  32. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/driver.py +0 -0
  33. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/driver_manifest.json +0 -0
  34. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/errors.py +0 -0
  35. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/manifest.py +0 -0
  36. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop/session.py +0 -0
  37. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop.egg-info/SOURCES.txt +0 -0
  38. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop.egg-info/dependency_links.txt +0 -0
  39. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop.egg-info/entry_points.txt +0 -0
  40. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop.egg-info/requires.txt +0 -0
  41. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/VirtualDesktop.egg-info/top_level.txt +0 -0
  42. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/setup.cfg +0 -0
  43. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_accelerators.py +0 -0
  44. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_artifact_regression.py +0 -0
  45. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_capture.py +0 -0
  46. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_cli.py +0 -0
  47. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_compat.py +0 -0
  48. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_config_and_winapi.py +0 -0
  49. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_desktop_isolation.py +0 -0
  50. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_destructive_lifecycle.py +0 -0
  51. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_display_setup.py +0 -0
  52. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_download.py +0 -0
  53. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_driver.py +0 -0
  54. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_hide_modes.py +0 -0
  55. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_integration_windows.py +0 -0
  56. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_manifest.py +0 -0
  57. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_no_dialogs.py +0 -0
  58. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_packaging.py +0 -0
  59. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_process.py +0 -0
  60. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_virtual_desktop.py +0 -0
  61. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_workspace.py +0 -0
  62. {virtualdesktop-1.4.1 → virtualdesktop-1.5.0}/tests/test_workspace_move.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: VirtualDesktop
3
- Version: 1.4.1
4
- Summary: Multi-window virtual-display workspaces on Windows: one pip install bootstraps the virtual display driver, hosts programs on a virtual or private desktop, keeps them off the physical screens, and hands out real window handles for automation and PrintWindow capture.
3
+ Version: 1.5.0
4
+ Summary: win10-win11 虚拟桌面
5
5
  Author: VirtualDesktop contributors
6
6
  License: MIT License
7
7
 
@@ -57,7 +57,9 @@ Dynamic: license-file
57
57
 
58
58
  # VirtualDesktop
59
59
 
60
- 在 Windows 上把程序的窗口放到**你看不到的地方**去运行,并读取、操作它。窗口不出现在你的屏幕上,
60
+ win10-win11 虚拟桌面
61
+
62
+ 把程序的窗口放到**你看不到的地方**去运行,并读取、操作它。窗口不出现在你的屏幕上,
61
63
  退出时自动还原。
62
64
 
63
65
  ```python
@@ -89,40 +91,46 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
89
91
  | 底层 | 内核**对象**(`CreateDesktopW`),独立窗口命名空间 + DACL | 显示**设备**(IddCx 驱动),系统多出一块 `\\.\DISPLAYn` |
90
92
  | 需要权限 | **零**:不需要驱动、不弹 UAC | 装一次驱动,弹**一次 UAC** |
91
93
  | 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
92
- | 程序窗口截图 | **不能** | **能** |
93
- | 桌面截图 | **不能** | **能** |
94
+ | **程序窗口截图** | **能**(窗口需已显示) | **能** |
95
+ | **桌面截图** | **不能**(没有屏幕 DC) | **能** |
94
96
  | 读窗口属性 / 枚举子控件 | 能(桌面内代理) | 能(外部进程直接) |
95
97
  | 点击控件 / 送入字符 | 能(窗口消息) | 能(消息 + 真实输入) |
96
98
  | 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
97
99
  | 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
98
100
  | 窗口跨桌面搬运 | **不能**(Windows 限制) | 能(在显示器之间移动) |
99
- | 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
101
+ | 推荐场景 | 多实例并行、强隔离、不需要桌面截图 | UI 自动化、截图 OCR、无人值守抓界面 |
100
102
 
101
103
  ### 隔离强度分级
102
104
 
103
- 两套方案的隔离**不是"强/弱"两档,而是三个层面各不相同**,按你需要防住什么来选:
105
+ 两套方案的隔离**不是"强/弱"两档,而是几个层面各不相同**,按你需要防住什么来选:
104
106
 
105
107
  | 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
106
108
  |---|---|---|---|
107
109
  | 窗口**发现**(能否枚举到) | **隔离** | 不隔离 | 私有桌面:`EnumWindows`/`FindWindow` 从外部看不到 |
108
110
  | 窗口**输入**(真实鼠标键盘) | **隔离** | 不隔离 | 私有桌面:没有活动指针与键盘焦点 |
109
- | 窗口**截图**(像素可读性) | **隔离** | 不隔离 | 私有桌面:从不参与系统合成,没有像素 |
111
+ | 窗口**像素**(能否截图) | **不隔离** | 不隔离 | `PrintWindow` 让**应用自己绘制**,不需要屏幕,所以私有桌面同样能截 |
110
112
  | 窗口**消息**(`SendMessage` 等) | **不隔离** | 不隔离 | 拿到句柄即可投递消息,DACL 保护的是桌面对象而不是窗口消息 |
111
113
  | 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束;虚拟显示器上的程序由你负责 |
112
114
 
113
- > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**,
114
- > 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
115
- > 目标进程的权限,只是把它挪到了另一个窗口命名空间。
115
+ > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**和**真实输入**,
116
+ > 挡不住"已知句柄后投递消息",也挡不住窗口自己绘制出来的像素。所以不要把私有桌面当作
117
+ > 进程级安全沙箱使用——它没有降低目标进程的权限,只是把它挪到了另一个窗口命名空间。
118
+ >
119
+ > 反过来,**"私有桌面截不了图"是个流传很广的误解**。真正的限制只有两条:
120
+ > ①**桌面截图**(拍整块屏幕)不行,因为那块桌面没有被合成到任何输出上;
121
+ > ②被 `SW_HIDE` 隐藏的窗口拍出来是纯色块,因为它压根不绘制。窗口只要**显示着**
122
+ > (本库默认如此),私有桌面上的窗口截图与共享模式**逐字节一致**。
116
123
 
117
124
  ### 推荐怎么选
118
125
 
119
126
  ```
120
- 要截图(窗口截图或桌面截图) → 虚拟显示器
127
+ 要桌面截图(拍整块屏幕/虚拟屏) → 虚拟显示器
121
128
  要用真实鼠标 / 滚轮 / 键盘,或 pyautogui → 虚拟显示器
122
129
  要用 pywinauto / UIA 从外部直接驱动窗口 → 虚拟显示器
123
130
  只处理一个窗口、要截图,越省事越好 → OffscreenSession(虚拟显示器侧)
131
+ 要窗口截图、但连驱动都不想装 → 私有桌面
124
132
  ─────────────────────────────────────────────────────────────
125
- 要多个实例互不干扰、且不需要截图 → 私有桌面
133
+ 要多个实例互不干扰 → 私有桌面
126
134
  不能弹 UAC、不能装驱动 → 私有桌面
127
135
  要"退出后什么都不留"(进程随桌面一起结束) → 私有桌面
128
136
  需要窗口完全不出现在任何可见位置 → 私有桌面
@@ -131,7 +139,7 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
131
139
  两条常用组合:
132
140
 
133
141
  - **只是想安静地跑一个窗口并抓图** → `OffscreenSession` + `virtual_screen`
134
- - **想并行跑很多互不干扰的实例** → 每个实例一个 `VirtualWorkspace("名字")`
142
+ - **想并行跑很多互不干扰的实例,同时要窗口截图** → 每个实例一个 `VirtualWorkspace("名字")`
135
143
 
136
144
  ---
137
145
 
@@ -218,17 +226,27 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
218
226
  `exec` 是逃生舱:里面的代码能 `import VirtualDesktop`、能用 `list_windows()`、`CaptureEngine`,
219
227
  也能用你自己装的任何库。代理本身只依赖 ctypes 与 comtypes。
220
228
 
221
- ### 私有桌面不能截图
229
+ ### 私有桌面的截图能力
222
230
 
223
- 两种截图都不行,且原因不同:
231
+ **程序窗口能截,桌面不能截**,原因是两条不同的路:
224
232
 
225
233
  | 类型 | 结果 | 原因 |
226
234
  |---|---|---|
227
- | 程序窗口截图 | **不能** | 该桌面的窗口从不被系统合成,没有像素可读 |
228
- | 桌面截图 | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
235
+ | 程序窗口截图 | **能** | `PrintWindow` 让**窗口自己绘制**到调用方给的 DC,不需要屏幕,也不需要合成 |
236
+ | 桌面截图 | **不能** | 拍整块屏幕需要该桌面的屏幕 DC,拿不到(`BitBlt` 返回拒绝访问) |
237
+
238
+ ```python
239
+ with VirtualWorkspace("lab") as space, space.agent() as agent:
240
+ window = agent.launch(["charmap.exe"])
241
+ frame = agent.screenshot(window, r"C:\绝对路径\window.png") # 路径必须绝对
242
+ print(frame.method, frame.size, frame.analysis.blank)
243
+ ```
244
+
245
+ 路径必须**绝对**:截图由桌面内的进程执行,它的工作目录不是调用者的。省略 `path` 则只返回
246
+ 内存中的帧、不落盘。
229
247
 
230
- `agent.screenshot()` 会**明确报错**(`code="capture-needs-shared-workspace"`)而不是产出空白图。
231
- 要读状态用 `agent.info()` / `agent.text()`;要像素请改用虚拟显示器。
248
+ > 窗口必须是**显示状态**。被 `SW_HIDE` 隐藏的窗口会返回纯色块(`code="capture-blank-window"`),
249
+ > 因为它压根不绘制——本库启动程序时会由放置流程负责在目标位置恢复显示。
232
250
 
233
251
  ## 能力边界
234
252
 
@@ -241,15 +259,15 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
241
259
  | 用外部进程的 pywinauto 连上并读属性 | **能** | 拿得到句柄就能读 |
242
260
  | 点击控件(`BM_CLICK` 等窗口消息) | **能** | 消息直接投递给窗口,不需要指针 |
243
261
  | 送入字符(`WM_CHAR` 消息) | **能** | 同上 |
244
- | **程序窗口截图** | **不能** | 桌面从不被系统合成,窗口没有像素 |
262
+ | **程序窗口截图** | **能** | `PrintWindow` 让窗口自己绘制,不需要屏幕或合成 |
245
263
  | **桌面截图** | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
246
264
  | 移动真实鼠标来点击 | **不能** | 该桌面不是活动桌面,没有指针 |
247
265
  | 滚轮(真实滚轮事件) | **不能** | 同"移动真实鼠标" |
248
266
  | 发送真实键盘输入(`SendInput`) | **不能** | 键盘只发给活动桌面 |
249
267
  | `pywinauto` 的 `click_input()` / `type_keys()` | **不能** | 依赖真实输入与前台焦点 |
250
268
 
251
- 要点:**能用消息驱动的都能用,需要真实输入设备的都不能用,两种截图都不能用**。需要截图或需要
252
- 真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
269
+ 要点:**能用消息驱动的都能用,需要真实输入设备的都不能用;程序窗口截图可以,桌面截图不行**。
270
+ 需要桌面截图或真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
253
271
 
254
272
  ## 支持的能力与调用方式
255
273
 
@@ -263,7 +281,8 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
263
281
  | 完整 UIA 树 | **支持** | `agent.uia_tree(window)` |
264
282
  | 点击控件 / 送入字符 | **支持** | `agent.click(window, text=...)` / `agent.keys(window, "...")` |
265
283
  | 在桌面内跑任意 Python | **支持** | `agent.exec("...")`(可用 comtypes、pywinauto) |
266
- | 程序窗口截图 / 桌面截图 | **不支持** | 见上方原因;改用虚拟显示器 |
284
+ | 程序窗口截图 | **支持** | `agent.screenshot(window, "绝对路径")` |
285
+ | 桌面截图 | **不支持** | 拿不到该桌面的屏幕 DC;需要拍屏幕请用虚拟显示器 |
267
286
  | 跨桌面搬运窗口、移到主屏 | **不支持** | `move_to` 一律报 `cannot-cross-desktop`;只能在目标桌面重新 `launch` |
268
287
 
269
288
  `agent.uia_tree()` 在**桌面内**遍历 UIA,这是私有桌面唯一可用的取树方式:
@@ -556,7 +575,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
556
575
  | `DriverNotFoundError` | 未装虚拟显示器且不允许安装 | 去掉限制,或先跑 `VirtualDesktop install-driver` |
557
576
  | `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
558
577
  | `unsupported-windows-build` | 系统低于 Win10 1809,没有 IddCx | 换系统,或用私有桌面(不需要驱动) |
559
- | `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图,改用虚拟显示器 |
578
+ | `capture-blank-window` | 窗口被 `SW_HIDE` 隐藏,不绘制,只能拍到纯色块 | 让它处于显示状态;本库启动程序时会自动在目标位置恢复显示 |
560
579
  | `SetThreadDesktop ... error 170` | 你自己在绑定线程,而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到;库内部的枚举不再绑定调用者线程 |
561
580
  | 某程序在私有桌面上找不到窗口 | 控制台类程序(`cmd.exe`、`ping.exe`)的窗口属于 `conhost.exe`,不在自己 pid 下 | 报错会提示;用 `agent.exec()` 在桌面内驱动它,或换 GUI 程序 |
562
581
  | 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;`report.requires_reboot` 为真时重启 |
@@ -573,9 +592,13 @@ VIRTUALDESKTOP_LOG_FILE=<path>
573
592
 
574
593
  ## 已知限制
575
594
 
576
- - **私有桌面不能截图**:程序窗口截图和桌面截图都不行。窗口没有被系统合成、没有像素可读;
577
- 而该桌面的屏幕 DC 也拿不到(`BitBlt` 返回拒绝访问)。用 `agent.info()` / `agent.text()` 读状态,
578
- 需要像素请改用虚拟显示器。
595
+ - **私有桌面不能做桌面截图**:拍整块屏幕不行,因为该桌面没有被合成到任何输出上,屏幕 DC 也
596
+ 拿不到。**但程序窗口截图可以**——`PrintWindow` 让窗口自己绘制到调用方给的 DC,不需要屏幕,
597
+ 所以私有桌面上的窗口截图与共享模式逐字节一致。
598
+ - **窗口必须是显示状态才可截图**:被 `SW_HIDE` 隐藏的窗口会返回纯色块(它压根不绘制)。
599
+ 本库启动程序时按此处理,由放置流程负责在正确位置恢复显示。
600
+ - **共享模式下程序启动瞬间可能极短暂可见**:程序自己显示窗口,库随即隐藏并移走它。这个间隙在
601
+ 私有桌面上不存在(窗口创建在另一块桌面上,从不经过你的屏幕);需要完全杜绝请用私有桌面。
579
602
  - **私有桌面不能用真实鼠标 / 滚轮 / 真实键盘**:那里没有活动指针和键盘焦点;消息驱动可用。
580
603
  - **窗口无法在私有桌面之间搬运**:Windows 不允许跨桌面移动窗口。
581
604
  - 部分应用(UWP、硬件叠加层、DRM 受保护内容)任何离屏方式都抓不到——系统限制。
@@ -1,6 +1,8 @@
1
1
  # VirtualDesktop
2
2
 
3
- 在 Windows 上把程序的窗口放到**你看不到的地方**去运行,并读取、操作它。窗口不出现在你的屏幕上,
3
+ win10-win11 虚拟桌面
4
+
5
+ 把程序的窗口放到**你看不到的地方**去运行,并读取、操作它。窗口不出现在你的屏幕上,
4
6
  退出时自动还原。
5
7
 
6
8
  ```python
@@ -32,40 +34,46 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
32
34
  | 底层 | 内核**对象**(`CreateDesktopW`),独立窗口命名空间 + DACL | 显示**设备**(IddCx 驱动),系统多出一块 `\\.\DISPLAYn` |
33
35
  | 需要权限 | **零**:不需要驱动、不弹 UAC | 装一次驱动,弹**一次 UAC** |
34
36
  | 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
35
- | 程序窗口截图 | **不能** | **能** |
36
- | 桌面截图 | **不能** | **能** |
37
+ | **程序窗口截图** | **能**(窗口需已显示) | **能** |
38
+ | **桌面截图** | **不能**(没有屏幕 DC) | **能** |
37
39
  | 读窗口属性 / 枚举子控件 | 能(桌面内代理) | 能(外部进程直接) |
38
40
  | 点击控件 / 送入字符 | 能(窗口消息) | 能(消息 + 真实输入) |
39
41
  | 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
40
42
  | 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
41
43
  | 窗口跨桌面搬运 | **不能**(Windows 限制) | 能(在显示器之间移动) |
42
- | 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
44
+ | 推荐场景 | 多实例并行、强隔离、不需要桌面截图 | UI 自动化、截图 OCR、无人值守抓界面 |
43
45
 
44
46
  ### 隔离强度分级
45
47
 
46
- 两套方案的隔离**不是"强/弱"两档,而是三个层面各不相同**,按你需要防住什么来选:
48
+ 两套方案的隔离**不是"强/弱"两档,而是几个层面各不相同**,按你需要防住什么来选:
47
49
 
48
50
  | 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
49
51
  |---|---|---|---|
50
52
  | 窗口**发现**(能否枚举到) | **隔离** | 不隔离 | 私有桌面:`EnumWindows`/`FindWindow` 从外部看不到 |
51
53
  | 窗口**输入**(真实鼠标键盘) | **隔离** | 不隔离 | 私有桌面:没有活动指针与键盘焦点 |
52
- | 窗口**截图**(像素可读性) | **隔离** | 不隔离 | 私有桌面:从不参与系统合成,没有像素 |
54
+ | 窗口**像素**(能否截图) | **不隔离** | 不隔离 | `PrintWindow` 让**应用自己绘制**,不需要屏幕,所以私有桌面同样能截 |
53
55
  | 窗口**消息**(`SendMessage` 等) | **不隔离** | 不隔离 | 拿到句柄即可投递消息,DACL 保护的是桌面对象而不是窗口消息 |
54
56
  | 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束;虚拟显示器上的程序由你负责 |
55
57
 
56
- > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**,
57
- > 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
58
- > 目标进程的权限,只是把它挪到了另一个窗口命名空间。
58
+ > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**和**真实输入**,
59
+ > 挡不住"已知句柄后投递消息",也挡不住窗口自己绘制出来的像素。所以不要把私有桌面当作
60
+ > 进程级安全沙箱使用——它没有降低目标进程的权限,只是把它挪到了另一个窗口命名空间。
61
+ >
62
+ > 反过来,**"私有桌面截不了图"是个流传很广的误解**。真正的限制只有两条:
63
+ > ①**桌面截图**(拍整块屏幕)不行,因为那块桌面没有被合成到任何输出上;
64
+ > ②被 `SW_HIDE` 隐藏的窗口拍出来是纯色块,因为它压根不绘制。窗口只要**显示着**
65
+ > (本库默认如此),私有桌面上的窗口截图与共享模式**逐字节一致**。
59
66
 
60
67
  ### 推荐怎么选
61
68
 
62
69
  ```
63
- 要截图(窗口截图或桌面截图) → 虚拟显示器
70
+ 要桌面截图(拍整块屏幕/虚拟屏) → 虚拟显示器
64
71
  要用真实鼠标 / 滚轮 / 键盘,或 pyautogui → 虚拟显示器
65
72
  要用 pywinauto / UIA 从外部直接驱动窗口 → 虚拟显示器
66
73
  只处理一个窗口、要截图,越省事越好 → OffscreenSession(虚拟显示器侧)
74
+ 要窗口截图、但连驱动都不想装 → 私有桌面
67
75
  ─────────────────────────────────────────────────────────────
68
- 要多个实例互不干扰、且不需要截图 → 私有桌面
76
+ 要多个实例互不干扰 → 私有桌面
69
77
  不能弹 UAC、不能装驱动 → 私有桌面
70
78
  要"退出后什么都不留"(进程随桌面一起结束) → 私有桌面
71
79
  需要窗口完全不出现在任何可见位置 → 私有桌面
@@ -74,7 +82,7 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
74
82
  两条常用组合:
75
83
 
76
84
  - **只是想安静地跑一个窗口并抓图** → `OffscreenSession` + `virtual_screen`
77
- - **想并行跑很多互不干扰的实例** → 每个实例一个 `VirtualWorkspace("名字")`
85
+ - **想并行跑很多互不干扰的实例,同时要窗口截图** → 每个实例一个 `VirtualWorkspace("名字")`
78
86
 
79
87
  ---
80
88
 
@@ -161,17 +169,27 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
161
169
  `exec` 是逃生舱:里面的代码能 `import VirtualDesktop`、能用 `list_windows()`、`CaptureEngine`,
162
170
  也能用你自己装的任何库。代理本身只依赖 ctypes 与 comtypes。
163
171
 
164
- ### 私有桌面不能截图
172
+ ### 私有桌面的截图能力
165
173
 
166
- 两种截图都不行,且原因不同:
174
+ **程序窗口能截,桌面不能截**,原因是两条不同的路:
167
175
 
168
176
  | 类型 | 结果 | 原因 |
169
177
  |---|---|---|
170
- | 程序窗口截图 | **不能** | 该桌面的窗口从不被系统合成,没有像素可读 |
171
- | 桌面截图 | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
178
+ | 程序窗口截图 | **能** | `PrintWindow` 让**窗口自己绘制**到调用方给的 DC,不需要屏幕,也不需要合成 |
179
+ | 桌面截图 | **不能** | 拍整块屏幕需要该桌面的屏幕 DC,拿不到(`BitBlt` 返回拒绝访问) |
180
+
181
+ ```python
182
+ with VirtualWorkspace("lab") as space, space.agent() as agent:
183
+ window = agent.launch(["charmap.exe"])
184
+ frame = agent.screenshot(window, r"C:\绝对路径\window.png") # 路径必须绝对
185
+ print(frame.method, frame.size, frame.analysis.blank)
186
+ ```
187
+
188
+ 路径必须**绝对**:截图由桌面内的进程执行,它的工作目录不是调用者的。省略 `path` 则只返回
189
+ 内存中的帧、不落盘。
172
190
 
173
- `agent.screenshot()` 会**明确报错**(`code="capture-needs-shared-workspace"`)而不是产出空白图。
174
- 要读状态用 `agent.info()` / `agent.text()`;要像素请改用虚拟显示器。
191
+ > 窗口必须是**显示状态**。被 `SW_HIDE` 隐藏的窗口会返回纯色块(`code="capture-blank-window"`),
192
+ > 因为它压根不绘制——本库启动程序时会由放置流程负责在目标位置恢复显示。
175
193
 
176
194
  ## 能力边界
177
195
 
@@ -184,15 +202,15 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
184
202
  | 用外部进程的 pywinauto 连上并读属性 | **能** | 拿得到句柄就能读 |
185
203
  | 点击控件(`BM_CLICK` 等窗口消息) | **能** | 消息直接投递给窗口,不需要指针 |
186
204
  | 送入字符(`WM_CHAR` 消息) | **能** | 同上 |
187
- | **程序窗口截图** | **不能** | 桌面从不被系统合成,窗口没有像素 |
205
+ | **程序窗口截图** | **能** | `PrintWindow` 让窗口自己绘制,不需要屏幕或合成 |
188
206
  | **桌面截图** | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
189
207
  | 移动真实鼠标来点击 | **不能** | 该桌面不是活动桌面,没有指针 |
190
208
  | 滚轮(真实滚轮事件) | **不能** | 同"移动真实鼠标" |
191
209
  | 发送真实键盘输入(`SendInput`) | **不能** | 键盘只发给活动桌面 |
192
210
  | `pywinauto` 的 `click_input()` / `type_keys()` | **不能** | 依赖真实输入与前台焦点 |
193
211
 
194
- 要点:**能用消息驱动的都能用,需要真实输入设备的都不能用,两种截图都不能用**。需要截图或需要
195
- 真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
212
+ 要点:**能用消息驱动的都能用,需要真实输入设备的都不能用;程序窗口截图可以,桌面截图不行**。
213
+ 需要桌面截图或真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
196
214
 
197
215
  ## 支持的能力与调用方式
198
216
 
@@ -206,7 +224,8 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
206
224
  | 完整 UIA 树 | **支持** | `agent.uia_tree(window)` |
207
225
  | 点击控件 / 送入字符 | **支持** | `agent.click(window, text=...)` / `agent.keys(window, "...")` |
208
226
  | 在桌面内跑任意 Python | **支持** | `agent.exec("...")`(可用 comtypes、pywinauto) |
209
- | 程序窗口截图 / 桌面截图 | **不支持** | 见上方原因;改用虚拟显示器 |
227
+ | 程序窗口截图 | **支持** | `agent.screenshot(window, "绝对路径")` |
228
+ | 桌面截图 | **不支持** | 拿不到该桌面的屏幕 DC;需要拍屏幕请用虚拟显示器 |
210
229
  | 跨桌面搬运窗口、移到主屏 | **不支持** | `move_to` 一律报 `cannot-cross-desktop`;只能在目标桌面重新 `launch` |
211
230
 
212
231
  `agent.uia_tree()` 在**桌面内**遍历 UIA,这是私有桌面唯一可用的取树方式:
@@ -499,7 +518,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
499
518
  | `DriverNotFoundError` | 未装虚拟显示器且不允许安装 | 去掉限制,或先跑 `VirtualDesktop install-driver` |
500
519
  | `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
501
520
  | `unsupported-windows-build` | 系统低于 Win10 1809,没有 IddCx | 换系统,或用私有桌面(不需要驱动) |
502
- | `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图,改用虚拟显示器 |
521
+ | `capture-blank-window` | 窗口被 `SW_HIDE` 隐藏,不绘制,只能拍到纯色块 | 让它处于显示状态;本库启动程序时会自动在目标位置恢复显示 |
503
522
  | `SetThreadDesktop ... error 170` | 你自己在绑定线程,而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到;库内部的枚举不再绑定调用者线程 |
504
523
  | 某程序在私有桌面上找不到窗口 | 控制台类程序(`cmd.exe`、`ping.exe`)的窗口属于 `conhost.exe`,不在自己 pid 下 | 报错会提示;用 `agent.exec()` 在桌面内驱动它,或换 GUI 程序 |
505
524
  | 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;`report.requires_reboot` 为真时重启 |
@@ -516,9 +535,13 @@ VIRTUALDESKTOP_LOG_FILE=<path>
516
535
 
517
536
  ## 已知限制
518
537
 
519
- - **私有桌面不能截图**:程序窗口截图和桌面截图都不行。窗口没有被系统合成、没有像素可读;
520
- 而该桌面的屏幕 DC 也拿不到(`BitBlt` 返回拒绝访问)。用 `agent.info()` / `agent.text()` 读状态,
521
- 需要像素请改用虚拟显示器。
538
+ - **私有桌面不能做桌面截图**:拍整块屏幕不行,因为该桌面没有被合成到任何输出上,屏幕 DC 也
539
+ 拿不到。**但程序窗口截图可以**——`PrintWindow` 让窗口自己绘制到调用方给的 DC,不需要屏幕,
540
+ 所以私有桌面上的窗口截图与共享模式逐字节一致。
541
+ - **窗口必须是显示状态才可截图**:被 `SW_HIDE` 隐藏的窗口会返回纯色块(它压根不绘制)。
542
+ 本库启动程序时按此处理,由放置流程负责在正确位置恢复显示。
543
+ - **共享模式下程序启动瞬间可能极短暂可见**:程序自己显示窗口,库随即隐藏并移走它。这个间隙在
544
+ 私有桌面上不存在(窗口创建在另一块桌面上,从不经过你的屏幕);需要完全杜绝请用私有桌面。
522
545
  - **私有桌面不能用真实鼠标 / 滚轮 / 真实键盘**:那里没有活动指针和键盘焦点;消息驱动可用。
523
546
  - **窗口无法在私有桌面之间搬运**:Windows 不允许跨桌面移动窗口。
524
547
  - 部分应用(UWP、硬件叠加层、DRM 受保护内容)任何离屏方式都抓不到——系统限制。
@@ -63,7 +63,7 @@ from .workspace import SHARED, VirtualWorkspace, WorkspaceWindow
63
63
  from .agent import AgentProcess, AgentSession
64
64
  from .display_setup import configure_virtual_display, current_primary_dpi, current_primary_size
65
65
 
66
- __version__ = "1.4.1"
66
+ __version__ = "1.5.0"
67
67
 
68
68
  __all__ = [
69
69
  "__version__",
@@ -310,6 +310,77 @@ def _uia_tree(hwnd: int, *, depth: int = 8, max_nodes: int = 500, view: str = "R
310
310
  return {"ok": True, "tree": {"view": view, "count": len(nodes), "nodes": nodes}}
311
311
 
312
312
 
313
+ def _capture(hwnd: int, path: str, *, client_only=None, methods=None, require_content=None) -> dict:
314
+ """Capture ``hwnd`` and write it to ``path``, **from inside this desktop**.
315
+
316
+ The capture has to happen here for the same reason the UIA walk does: ``IsWindow`` and
317
+ ``GetWindowRect`` are scoped to the calling thread's desktop and report
318
+ ``ERROR_INVALID_WINDOW_HANDLE`` for a window on another desktop, so the capture engine cannot
319
+ even validate a cross-desktop handle. Run inside, the handle is ordinary and every backend
320
+ works — ``PrintWindow`` asks the owning process to paint itself, which needs no screen.
321
+
322
+ A window that was never shown still answers ``PrintWindow`` successfully and paints a solid
323
+ rectangle, so a blank frame is reported as an error rather than saved as a real screenshot.
324
+ """
325
+ from VirtualDesktop.capture import CaptureEngine
326
+
327
+ engine = CaptureEngine(methods=tuple(methods)) if methods else CaptureEngine()
328
+ engine.ensure_dpi_aware()
329
+
330
+ # A window that was just created may not have painted yet, and a window created with SW_HIDE
331
+ # never paints at all. Retry briefly so the first case succeeds and the second still gets a
332
+ # clear "blank" answer instead of a generic failure.
333
+ #
334
+ # ``require_content=False`` here on purpose: the engine's own validation raises a generic
335
+ # CaptureError for a blank frame, which looks identical to a real backend failure. Taking the
336
+ # frame and judging it ourselves is what makes "the window was never shown" reportable.
337
+ deadline = time.monotonic() + 6.0
338
+ last_error = ""
339
+ last_stats: dict = {}
340
+ last_method = ""
341
+ last_size: list = []
342
+ while True:
343
+ try:
344
+ frame = engine.capture(
345
+ hwnd,
346
+ client_only=client_only,
347
+ require_content=False,
348
+ save_to=path,
349
+ )
350
+ except Exception as exc: # noqa: BLE001 - retried below, then reported
351
+ last_error = f"{type(exc).__name__}: {exc}"
352
+ if time.monotonic() >= deadline:
353
+ return {"ok": False, "error": last_error}
354
+ time.sleep(0.3)
355
+ continue
356
+
357
+ blank = bool(frame.analysis.blank) if frame.analysis is not None else False
358
+ last_stats = frame.analysis.to_dict() if frame.analysis is not None else {}
359
+ last_method = frame.method
360
+ last_size = list(frame.size)
361
+ if not blank:
362
+ return {
363
+ "ok": True,
364
+ "saved": path,
365
+ "method": frame.method,
366
+ "size": list(frame.size),
367
+ "blank": False,
368
+ "analysis": last_stats,
369
+ }
370
+ if time.monotonic() >= deadline:
371
+ return {
372
+ "ok": False,
373
+ "error": "the captured frame is blank; the window was probably never shown",
374
+ "hint": "A window created with SW_HIDE does not paint itself. Launch it with "
375
+ "show=True (agent.launch does this by default), or call ShowWindow(SW_SHOW) first.",
376
+ "blank": True,
377
+ "method": last_method,
378
+ "size": last_size,
379
+ "analysis": last_stats,
380
+ }
381
+ time.sleep(0.3)
382
+
383
+
313
384
  def _handle(request: dict) -> dict:
314
385
  if not _kernel32.GetCurrentProcessId(): # pragma: no cover - keeps kernel32 referenced
315
386
  pass
@@ -325,6 +396,14 @@ def _handle(request: dict) -> dict:
325
396
  return _keys(int(request["hwnd"], 0), str(request.get("text", "")))
326
397
  if command == "screenshot":
327
398
  return _screenshot(int(request["hwnd"], 0), str(request["path"]))
399
+ if command == "capture":
400
+ return _capture(
401
+ int(request["hwnd"], 0),
402
+ str(request["path"]),
403
+ client_only=request.get("client_only"),
404
+ methods=request.get("methods"),
405
+ require_content=request.get("require_content"),
406
+ )
328
407
  if command == "windows":
329
408
  return _windows()
330
409
  if command == "uia_tree":
@@ -646,6 +646,31 @@ def _process_name_for_pid(pid: int, cache: Optional[dict] = None) -> str:
646
646
  return name
647
647
 
648
648
 
649
+ def pid_alive(pid: int) -> bool:
650
+ """Whether a process still exists.
651
+
652
+ A process that has exited but whose handle we never had is not a failure here: the caller only
653
+ needs "is it still running", and ``ERROR_INVALID_PARAMETER`` from ``OpenProcess`` for a dead pid
654
+ answers exactly that.
655
+ """
656
+ if not IS_WINDOWS: # pragma: no cover - Windows-only library
657
+ return False
658
+ if not pid:
659
+ return False
660
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
661
+ STILL_ACTIVE = 259
662
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid))
663
+ if not handle:
664
+ return False
665
+ try:
666
+ code = wintypes.DWORD()
667
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
668
+ return False
669
+ return code.value == STILL_ACTIVE
670
+ finally:
671
+ kernel32.CloseHandle(handle)
672
+
673
+
649
674
  def list_windows(*, include_hidden: bool = True) -> List[WindowRecord]:
650
675
  """Enumerate top-level windows with the metadata needed for selection."""
651
676
  require_windows("list_windows")
@@ -207,10 +207,17 @@ class AgentSession:
207
207
  *,
208
208
  title: Optional[str] = None,
209
209
  timeout: Optional[float] = None,
210
+ show: bool = True,
210
211
  ) -> AgentProcess:
211
- """Start a program on the desktop and wait for its window."""
212
+ """Start a program on the desktop and wait for its window.
213
+
214
+ ``show`` defaults to ``True`` — counter-intuitive for a *hidden* desktop, but load-bearing:
215
+ a window created with ``SW_HIDE`` still answers ``PrintWindow`` successfully, yet paints
216
+ nothing, so ``screenshot()`` would return a solid rectangle. Showing it costs nothing (the
217
+ desktop is not displayed on any monitor) and is what makes the window capturable.
218
+ """
212
219
  command = [str(a) for a in argv]
213
- process = self.desktop.launch(command, show=False, wait_for_window=0.0)
220
+ process = self.desktop.launch(command, show=show, wait_for_window=0.0)
214
221
  record = self.desktop.find_window(
215
222
  process.pid, timeout=timeout or self.timeout, title_hint=title
216
223
  )
@@ -268,22 +275,102 @@ class AgentSession:
268
275
  )
269
276
  return dict(payload.get("tree") or {})
270
277
 
271
- def screenshot(self, window: Any, path: str) -> Dict[str, Any]:
272
- """Not available: a window on a private desktop has no pixels to capture.
273
-
274
- Kept as an explicit failure rather than removed, so a caller who expects it gets a reason
275
- instead of an ``AttributeError``. All four capture backends were measured inside a private
276
- desktop and all return a blank frame — ``PrintWindow`` (both flag variants) and ``WM_PRINT``
277
- give variance 0, and ``BitBlt`` fails outright, because the desktop is never composited.
278
-
279
- Use :class:`~VirtualDesktop.workspace.VirtualWorkspace` in shared mode when screenshots are
280
- required: its windows live on the virtual display but stay ordinary, paintable windows.
278
+ def screenshot(
279
+ self,
280
+ window: Any,
281
+ path: Optional[str] = None,
282
+ *,
283
+ client_only: Optional[bool] = None,
284
+ methods: Optional[Sequence[str]] = None,
285
+ require_content: Optional[bool] = None,
286
+ ):
287
+ """Capture ``window`` and return the :class:`~VirtualDesktop.capture.Frame`.
288
+
289
+ The capture runs **inside the desktop**, not in the calling process. That is not a
290
+ preference: ``IsWindow`` and ``GetWindowRect`` are scoped to the caller's desktop and report
291
+ ``ERROR_INVALID_WINDOW_HANDLE`` for a window elsewhere, so the capture engine cannot even
292
+ validate a cross-desktop handle from outside. Inside, the handle is ordinary and
293
+ ``PrintWindow`` works — it asks the owning process to paint itself into our DC, which needs
294
+ no screen and no compositing.
295
+
296
+ A window that was never shown still answers ``PrintWindow`` successfully and paints a solid
297
+ rectangle, so a blank result raises rather than being returned as a screenshot.
298
+ :meth:`launch` shows windows by default for exactly this reason.
299
+
300
+ ``path`` is optional. When given it must be **absolute** — it is resolved by the desktop
301
+ process, whose working directory is not the caller's. When omitted, the frame is captured
302
+ to a temporary file inside the desktop and read back into memory here.
281
303
  """
282
- raise OffscreenMountError(
283
- "windows on a private desktop cannot be captured; there are no pixels to read",
284
- code="capture-needs-shared-workspace",
285
- hint="Use VirtualWorkspace(VirtualWorkspace.SHARED) — its windows are ordinary windows "
286
- "on the virtual display and capture normally — or read state with info()/text() here.",
304
+ hwnd = _handle(window)
305
+ required_path = Path(path) if path else None
306
+ target = required_path or Path(self._control) / f"shot-{uuid.uuid4().hex[:8]}.png"
307
+ try:
308
+ payload = self.command(
309
+ "capture",
310
+ hwnd=hwnd,
311
+ path=str(target),
312
+ client_only=client_only,
313
+ methods=list(methods) if methods else None,
314
+ require_content=require_content,
315
+ )
316
+ except OffscreenMountError as exc:
317
+ if exc.code != "agent-command-failed":
318
+ raise
319
+ # The runtime reports a blank frame as data; surface it with the same explicit code the
320
+ # rest of the library uses instead of the generic "command failed".
321
+ details = exc.details or {}
322
+ blank = bool(details.get("blank"))
323
+ raise OffscreenMountError(
324
+ str(details.get("error") or exc),
325
+ code="capture-blank-window" if blank else "capture-failed",
326
+ hint=details.get("hint"),
327
+ details=details,
328
+ ) from exc
329
+ try:
330
+ return self._frame_from(payload, target, hwnd, client_only=client_only)
331
+ finally:
332
+ if required_path is None:
333
+ try:
334
+ target.unlink(missing_ok=True)
335
+ except OSError: # pragma: no cover - scratch cleanup is best effort
336
+ pass
337
+
338
+ def _frame_from(
339
+ self, payload: Dict[str, Any], image_path: Path, hwnd: str, *, client_only
340
+ ):
341
+ """Rebuild a :class:`~VirtualDesktop.capture.Frame` from the desktop's report."""
342
+ from PIL import Image
343
+
344
+ from .capture import Frame, ImageAnalysis
345
+
346
+ image = Image.open(image_path)
347
+ image.load()
348
+ stats = payload.get("analysis") or {}
349
+ analysis = (
350
+ ImageAnalysis(
351
+ width=int(stats.get("width", image.width)),
352
+ height=int(stats.get("height", image.height)),
353
+ variance=float(stats.get("variance", 0.0)),
354
+ mean=float(stats.get("mean", 0.0)),
355
+ max_value=int(stats.get("max_value", 0)),
356
+ dominant_ratio=float(stats.get("dominant_ratio", 0.0)),
357
+ uniform=bool(stats.get("uniform", False)),
358
+ blank=bool(stats.get("blank", False)),
359
+ reason=str(stats.get("reason", "")),
360
+ )
361
+ if stats
362
+ else None
363
+ )
364
+ return Frame(
365
+ image=image,
366
+ hwnd=int(hwnd, 16),
367
+ method=str(payload.get("method", "")),
368
+ region=None,
369
+ client_only=bool(client_only) if client_only is not None else None,
370
+ dpi_aware=True,
371
+ attempts=[],
372
+ analysis=analysis,
373
+ duration=0.0,
287
374
  )
288
375
 
289
376
  def exec(self, code: str, *, timeout: Optional[float] = None) -> str:
@@ -304,6 +391,11 @@ def _handle(window: Any) -> str:
304
391
  return hex(int(handle))
305
392
 
306
393
 
394
+ def _handle_int(window: Any) -> int:
395
+ """The same handle as :func:`_handle`, but as an ``int`` for the in-process capture API."""
396
+ return int(_handle(window), 16)
397
+
398
+
307
399
  def _python() -> str:
308
400
  """Same interpreter as the caller, so imports resolve the same way."""
309
401
  import sys
@@ -446,7 +446,12 @@ class CaptureEngine:
446
446
  (PNG by default) before returning it.
447
447
  """
448
448
  require_windows("CaptureEngine.capture")
449
- if not user32.IsWindow(hwnd):
449
+ # ``IsWindow`` is scoped to the **calling thread's desktop**, so it answers False for a
450
+ # window that lives on another desktop — which is exactly the case this library creates
451
+ # (``VirtualDesktop``) and would make capture impossible there. ``GetWindowRect`` accepts
452
+ # any valid handle regardless of desktop, so it is the right validity probe here.
453
+ probe = _RECT()
454
+ if not user32.GetWindowRect(hwnd, ctypes.byref(probe)):
450
455
  raise CaptureError(
451
456
  f"0x{hwnd:X} is not a window (it may have been closed)", code="invalid-hwnd"
452
457
  )