VirtualDesktop 1.3.0__tar.gz → 1.4.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.
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/PKG-INFO +170 -18
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/README.md +169 -17
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/__init__.py +1 -1
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_desktop_runtime.py +82 -0
- virtualdesktop-1.4.0/VirtualDesktop/_enumerate.py +97 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/agent.py +45 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/desktop_session.py +230 -20
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/workspace.py +45 -1
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/PKG-INFO +170 -18
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/SOURCES.txt +1 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/pyproject.toml +1 -1
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/LICENSE +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/__main__.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_bundle.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_compat.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_devices.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_download.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_helper.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_log.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_process.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_sigcheck.ps1 +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_sigcheck.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/_winapi.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/capture.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/cli.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/config.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/desktop.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/desktop_capture.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/display_setup.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/driver.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/driver_manifest.json +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/errors.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/manifest.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/session.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop/window.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/dependency_links.txt +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/entry_points.txt +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/requires.txt +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/top_level.txt +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/setup.cfg +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_accelerators.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_agent.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_artifact_regression.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_capture.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_cli.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_compat.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_config_and_winapi.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_desktop_isolation.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_destructive_lifecycle.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_display_setup.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_download.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_driver.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_hide_modes.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_integration_windows.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_manifest.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_no_dialogs.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_packaging.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_process.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_virtual_desktop.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_workspace.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_workspace_instances.py +0 -0
- {virtualdesktop-1.3.0 → virtualdesktop-1.4.0}/tests/test_workspace_move.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: VirtualDesktop
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
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.
|
|
5
5
|
Author: VirtualDesktop contributors
|
|
6
6
|
License: MIT License
|
|
@@ -84,14 +84,52 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
|
|
|
84
84
|
| | 私有桌面 | 虚拟显示器 |
|
|
85
85
|
|---|---|---|
|
|
86
86
|
| 一句话 | 另开一个桌面,窗口与你的桌面完全隔离 | 多一块屏幕,窗口仍在你的桌面上、只是画在看不见的地方 |
|
|
87
|
-
|
|
|
87
|
+
| 底层 | 内核**对象**(`CreateDesktopW`),独立窗口命名空间 + DACL | 显示**设备**(IddCx 驱动),系统多出一块 `\\.\DISPLAYn` |
|
|
88
88
|
| 需要权限 | **零**:不需要驱动、不弹 UAC | 装一次驱动,弹**一次 UAC** |
|
|
89
|
-
|
|
|
90
|
-
|
|
|
91
|
-
|
|
|
89
|
+
| 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
|
|
90
|
+
| 程序窗口截图 | **不能** | **能** |
|
|
91
|
+
| 桌面截图 | **不能** | **能** |
|
|
92
|
+
| 读窗口属性 / 枚举子控件 | 能(桌面内代理) | 能(外部进程直接) |
|
|
93
|
+
| 点击控件 / 送入字符 | 能(窗口消息) | 能(消息 + 真实输入) |
|
|
94
|
+
| 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
|
|
95
|
+
| 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
|
|
96
|
+
| 窗口跨桌面搬运 | **不能**(Windows 限制) | 能(在显示器之间移动) |
|
|
97
|
+
| 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
|
|
98
|
+
|
|
99
|
+
### 隔离强度分级
|
|
100
|
+
|
|
101
|
+
两套方案的隔离**不是"强/弱"两档,而是三个层面各不相同**,按你需要防住什么来选:
|
|
102
|
+
|
|
103
|
+
| 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
|
|
104
|
+
|---|---|---|---|
|
|
105
|
+
| 窗口**发现**(能否枚举到) | **隔离** | 不隔离 | 私有桌面:`EnumWindows`/`FindWindow` 从外部看不到 |
|
|
106
|
+
| 窗口**输入**(真实鼠标键盘) | **隔离** | 不隔离 | 私有桌面:没有活动指针与键盘焦点 |
|
|
107
|
+
| 窗口**截图**(像素可读性) | **隔离** | 不隔离 | 私有桌面:从不参与系统合成,没有像素 |
|
|
108
|
+
| 窗口**消息**(`SendMessage` 等) | **不隔离** | 不隔离 | 拿到句柄即可投递消息,DACL 保护的是桌面对象而不是窗口消息 |
|
|
109
|
+
| 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束;虚拟显示器上的程序由你负责 |
|
|
110
|
+
|
|
111
|
+
> 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**,
|
|
112
|
+
> 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
|
|
113
|
+
> 目标进程的权限,只是把它挪到了另一个窗口命名空间。
|
|
114
|
+
|
|
115
|
+
### 推荐怎么选
|
|
92
116
|
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
```
|
|
118
|
+
要截图(窗口截图或桌面截图) → 虚拟显示器
|
|
119
|
+
要用真实鼠标 / 滚轮 / 键盘,或 pyautogui → 虚拟显示器
|
|
120
|
+
要用 pywinauto / UIA 从外部直接驱动窗口 → 虚拟显示器
|
|
121
|
+
只处理一个窗口、要截图,越省事越好 → OffscreenSession(虚拟显示器侧)
|
|
122
|
+
─────────────────────────────────────────────────────────────
|
|
123
|
+
要多个实例互不干扰、且不需要截图 → 私有桌面
|
|
124
|
+
不能弹 UAC、不能装驱动 → 私有桌面
|
|
125
|
+
要"退出后什么都不留"(进程随桌面一起结束) → 私有桌面
|
|
126
|
+
需要窗口完全不出现在任何可见位置 → 私有桌面
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
两条常用组合:
|
|
130
|
+
|
|
131
|
+
- **只是想安静地跑一个窗口并抓图** → `OffscreenSession` + `virtual_screen`
|
|
132
|
+
- **想并行跑很多互不干扰的实例** → 每个实例一个 `VirtualWorkspace("名字")`
|
|
95
133
|
|
|
96
134
|
---
|
|
97
135
|
|
|
@@ -169,6 +207,7 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
|
|
|
169
207
|
| `agent.adopt(pid)` | 接上桌面内已在运行的程序 |
|
|
170
208
|
| `agent.info(window)` | 标题、类名、矩形、`child_count`、`buttons` |
|
|
171
209
|
| `agent.text(window)` | 窗口标题 |
|
|
210
|
+
| `agent.uia_tree(window)` | 完整 UIA 树(在桌面内遍历) |
|
|
172
211
|
| `agent.click(window, text=/index=)` | 点击控件(走窗口消息,见下) |
|
|
173
212
|
| `agent.keys(window, "...")` | 送入字符(走窗口消息) |
|
|
174
213
|
| `agent.windows()` | 该桌面上的窗口清单 |
|
|
@@ -177,6 +216,18 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
|
|
|
177
216
|
`exec` 是逃生舱:里面的代码能 `import VirtualDesktop`、能用 `list_windows()`、`CaptureEngine`,
|
|
178
217
|
也能用你自己装的任何库。代理本身只依赖 ctypes,不需要额外安装。
|
|
179
218
|
|
|
219
|
+
### 私有桌面不能截图
|
|
220
|
+
|
|
221
|
+
两种截图都不行,且原因不同:
|
|
222
|
+
|
|
223
|
+
| 类型 | 结果 | 原因 |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| 程序窗口截图 | **不能** | 该桌面的窗口从不被系统合成,没有像素可读 |
|
|
226
|
+
| 桌面截图 | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
|
|
227
|
+
|
|
228
|
+
`agent.screenshot()` 会**明确报错**(`code="capture-needs-shared-workspace"`)而不是产出空白图。
|
|
229
|
+
要读状态用 `agent.info()` / `agent.text()`;要像素请改用虚拟显示器。
|
|
230
|
+
|
|
180
231
|
## 能力边界
|
|
181
232
|
|
|
182
233
|
私有桌面的窗口**不在交互桌面上**,所以按输入机制分,能做的事如下:
|
|
@@ -185,17 +236,50 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
|
|
|
185
236
|
|---|---|---|
|
|
186
237
|
| 找到窗口、读标题 / 类名 / 矩形 | 能 | 读窗口属性,跨桌面可用 |
|
|
187
238
|
| 枚举子控件、读控件文字 | 能 | 同上 |
|
|
239
|
+
| 用外部进程的 pywinauto 连上并读属性 | **能** | 拿得到句柄就能读 |
|
|
188
240
|
| 点击控件(`BM_CLICK` 等窗口消息) | **能** | 消息直接投递给窗口,不需要指针 |
|
|
189
241
|
| 送入字符(`WM_CHAR` 消息) | **能** | 同上 |
|
|
190
|
-
|
|
|
242
|
+
| **程序窗口截图** | **不能** | 桌面从不被系统合成,窗口没有像素 |
|
|
243
|
+
| **桌面截图** | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
|
|
191
244
|
| 移动真实鼠标来点击 | **不能** | 该桌面不是活动桌面,没有指针 |
|
|
192
245
|
| 滚轮(真实滚轮事件) | **不能** | 同"移动真实鼠标" |
|
|
193
246
|
| 发送真实键盘输入(`SendInput`) | **不能** | 键盘只发给活动桌面 |
|
|
194
247
|
| `pywinauto` 的 `click_input()` / `type_keys()` | **不能** | 依赖真实输入与前台焦点 |
|
|
195
|
-
| 用外部进程的 pywinauto 直接连上它 | **不能** | 跨桌面拿不到窗口 |
|
|
196
248
|
|
|
197
|
-
|
|
198
|
-
|
|
249
|
+
要点:**能用消息驱动的都能用,需要真实输入设备的都不能用,两种截图都不能用**。需要截图或需要
|
|
250
|
+
真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
|
|
251
|
+
|
|
252
|
+
## 支持的能力与调用方式
|
|
253
|
+
|
|
254
|
+
| 能力 | 是否支持 | 怎么调 |
|
|
255
|
+
|---|---|---|
|
|
256
|
+
| 在一个桌面里跑多个程序 | **支持** | 连续 `agent.launch([...])`,各自拿到不同句柄 |
|
|
257
|
+
| 多个独立桌面同时在线 | **支持** | 多个 `VirtualWorkspace("名字")` 并存,句柄与名字互不相同 |
|
|
258
|
+
| 拿到窗口句柄 | **支持** | `agent.launch([...]).hwnd` / `agent.adopt(pid).hwnd` / `desktop.find_window(pid)` |
|
|
259
|
+
| 读标题 / 类名 / 矩形 / 尺寸 | **支持** | `agent.info(window)` |
|
|
260
|
+
| 枚举子控件与按钮文字 | **支持** | `agent.info(window)["child_count"]` / `["buttons"]` |
|
|
261
|
+
| 完整 UIA 树 | **支持** | `agent.uia_tree(window)` |
|
|
262
|
+
| 点击控件 / 送入字符 | **支持** | `agent.click(window, text=...)` / `agent.keys(window, "...")` |
|
|
263
|
+
| 在桌面内跑任意 Python | **支持** | `agent.exec("...")`(可用 comtypes、pywinauto) |
|
|
264
|
+
| 程序窗口截图 / 桌面截图 | **不支持** | 见上方原因;改用虚拟显示器 |
|
|
265
|
+
| 跨桌面搬运窗口、移到主屏 | **不支持** | `move_to` 一律报 `cannot-cross-desktop`;只能在目标桌面重新 `launch` |
|
|
266
|
+
|
|
267
|
+
`agent.uia_tree()` 在**桌面内**遍历 UIA,这是私有桌面唯一可用的取树方式:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
with VirtualWorkspace("lab") as space, space.agent() as agent:
|
|
271
|
+
window = agent.launch(["charmap.exe"])
|
|
272
|
+
tree = agent.uia_tree(window) # view="RawView" 默认;也可 ControlView / ContentView
|
|
273
|
+
print(tree["count"])
|
|
274
|
+
for node in tree["nodes"][:5]:
|
|
275
|
+
print(node["control"], node["name"], node["enabled"])
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
> 不要用 `GetRootElement()`:它属于**输入桌面**,在私有桌面里走它拿不到任何目标窗口。
|
|
279
|
+
> 库内部用的是 `ElementFromHandle(hwnd)`。
|
|
280
|
+
|
|
281
|
+
> 在里面用 comtypes / pywinauto 是安全的:窗口枚举已不再绑定调用者线程,所以
|
|
282
|
+
> `from pywinauto import Application` 放在文件顶部也不会影响私有桌面。
|
|
199
283
|
|
|
200
284
|
---
|
|
201
285
|
|
|
@@ -288,7 +372,17 @@ VirtualWorkspace(VirtualWorkspace.SHARED, monitor="primary") # 主屏
|
|
|
288
372
|
|
|
289
373
|
## 截图
|
|
290
374
|
|
|
291
|
-
|
|
375
|
+
要分清两种,它们走**完全不同的通路**,能力也不同:
|
|
376
|
+
|
|
377
|
+
| 类型 | 拍什么 | 走哪条路 | 怎么调 |
|
|
378
|
+
|---|---|---|---|
|
|
379
|
+
| **程序窗口截图** | 某一个窗口自己的像素 | `PrintWindow(hwnd)` | `CaptureEngine.capture(hwnd)` / `space.capture(hwnd)` |
|
|
380
|
+
| **桌面截图** | 整块屏幕(含其上所有窗口) | `ImageGrab` / `BitBlt` 屏幕 DC | `capture_all_screens()` / `capture_monitor()` / `space.capture_desktop()` |
|
|
381
|
+
|
|
382
|
+
### 程序窗口截图
|
|
383
|
+
|
|
384
|
+
按句柄拍,窗口在屏幕外也能拍,是抓单个程序界面最可靠的方式。`capture()` 依次尝试多种后端直到
|
|
385
|
+
拿到**有内容**的帧,默认顺序:
|
|
292
386
|
|
|
293
387
|
`printwindow`(`PW_RENDERFULLCONTENT`)→ `printwindow_plain` → `wm_print` → `bitblt`
|
|
294
388
|
|
|
@@ -303,6 +397,39 @@ frame = engine.capture(hwnd, require_content=True)
|
|
|
303
397
|
print(frame.method, frame.size)
|
|
304
398
|
```
|
|
305
399
|
|
|
400
|
+
```python
|
|
401
|
+
with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
|
|
402
|
+
entry = space.launch(["charmap.exe"])
|
|
403
|
+
space.capture(entry.hwnd, save_to="window.png")
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### 桌面截图
|
|
407
|
+
|
|
408
|
+
拍的是**屏幕**,不是某个窗口。`virtual_screen_rect()` 是整张虚拟桌面(含所有显示器,处理好负坐标),
|
|
409
|
+
`capture_monitor()` 是单块显示器。在虚拟显示器模式下,`space.capture_desktop()` 等价于
|
|
410
|
+
`capture_monitor(space.monitor)`。
|
|
411
|
+
|
|
412
|
+
```python
|
|
413
|
+
from VirtualDesktop import CaptureEngine
|
|
414
|
+
from VirtualDesktop.desktop_capture import capture_all_screens, capture_monitor
|
|
415
|
+
|
|
416
|
+
CaptureEngine().ensure_dpi_aware() # 拍摄前必须做,见下方警告
|
|
417
|
+
|
|
418
|
+
whole = capture_all_screens() # 整张虚拟桌面
|
|
419
|
+
whole.image.save("desktop.png")
|
|
420
|
+
print(whole.rect, whole.size, whole.method)
|
|
421
|
+
|
|
422
|
+
one = capture_monitor(device="\\\\.\\DISPLAY2") # 只拍某一块屏
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
> **拍摄前先确保 DPI 感知。** 矩形由 `GetSystemMetrics` / `EnumDisplayMonitors` 计算,它们对
|
|
426
|
+
> **不感知 DPI** 的进程返回的是**缩放后的逻辑值**;而像素抓取返回的是**物理像素**。两者不一致时
|
|
427
|
+
> 会**静默裁掉**画面右下角(125% 缩放下 1920×1080 的屏只拿到 1536×864,少 384×216 像素,且不报错)。
|
|
428
|
+
> 先调一次 `CaptureEngine().ensure_dpi_aware()` 即可,`VirtualWorkspace` 内部已自动处理。
|
|
429
|
+
|
|
430
|
+
> **桌面截图只有虚拟显示器模式可用。** offscreen 模式下窗口被移到所有显示器**之外**,桌面截图按
|
|
431
|
+
> 定义拍不到它们;而私有桌面根本没有可拍的屏幕 DC。
|
|
432
|
+
|
|
306
433
|
## 用自动化库控制窗口
|
|
307
434
|
|
|
308
435
|
窗口是普通窗口,直接从你的进程用即可:
|
|
@@ -358,7 +485,8 @@ print(current_primary_size()) # (1920, 1080)
|
|
|
358
485
|
| 枚举子控件、读控件文字 | 能 | 能 |
|
|
359
486
|
| 点击控件(窗口消息) | 能 | 能 |
|
|
360
487
|
| 送入字符(窗口消息) | 能 | 能 |
|
|
361
|
-
|
|
|
488
|
+
| **程序窗口截图** | **能** | **能** |
|
|
489
|
+
| **桌面截图** | **能**(拍到窗口) | **不能**(窗口在所有显示器之外) |
|
|
362
490
|
| 外部 pywinauto / UIA 直接连上 | 能 | 能 |
|
|
363
491
|
| 移动真实鼠标来点击 | 能 | **不能**(窗口在屏幕之外) |
|
|
364
492
|
| 滚轮 | 能 | **不能** |
|
|
@@ -367,6 +495,26 @@ print(current_primary_size()) # (1920, 1080)
|
|
|
367
495
|
> 用真实输入时,窗口所在显示器必须是活动桌面的一部分;`pyautogui` 这类库还需要窗口能取得前台
|
|
368
496
|
> 焦点。若目标窗口拿不到焦点,请改用消息驱动(`agent.click()` / `WM_CHAR`)。
|
|
369
497
|
|
|
498
|
+
## 支持的能力与调用方式
|
|
499
|
+
|
|
500
|
+
| 能力 | 是否支持 | 怎么调 |
|
|
501
|
+
|---|---|---|
|
|
502
|
+
| 在一个工作区里跑多个程序 | **支持** | 连续 `ws.launch([...])`,运行中可随时新增 |
|
|
503
|
+
| 多个工作区同时在线 | **支持** | 多个 `VirtualWorkspace(SHARED)` 并存,各自独立停靠位 |
|
|
504
|
+
| 拿到窗口句柄给外部用 | **支持** | `ws.describe()["windows"]`(真实 HWND,无包装) |
|
|
505
|
+
| 读标题 / 类名 / 矩形 | **支持** | 外部 pywinauto 直接读,或 `ws.windows()` |
|
|
506
|
+
| 枚举子控件 | **支持** | 外部 pywinauto `children(visible_only=False)` |
|
|
507
|
+
| 完整 UIA 树 | **支持** | 外部 UIA,或 `--ui-tree DIR` |
|
|
508
|
+
| 程序窗口截图 | **支持** | `ws.capture(hwnd, save_to=...)` / `CaptureEngine.capture(hwnd)` |
|
|
509
|
+
| 桌面截图 | **支持**(`virtual_screen`) | `ws.capture_desktop()` / `capture_monitor()` / `capture_all_screens()` |
|
|
510
|
+
| 真实鼠标 / 滚轮 / 键盘 | **支持**(`virtual_screen`) | pywinauto `click_input()` / `type_keys()`,或 pyautogui |
|
|
511
|
+
| 在显示器之间移动窗口 | **支持** | `move_to("\\\\.\\DISPLAY2")` / `move_all_to(...)` / `move_to_primary()` |
|
|
512
|
+
| 退出时还原窗口 | **支持** | `with` 退出自动还原;异常退出同样还原 |
|
|
513
|
+
| 退出时卸载驱动 | **支持** | `uninstall_on_exit=True` 或 `close(uninstall=True)` |
|
|
514
|
+
| 桌面截图(`offscreen` 模式) | **不支持** | 窗口在所有显示器之外,拍不到;改用 `virtual_screen` |
|
|
515
|
+
| 真实鼠标 / 滚轮 / 键盘(`offscreen`) | **不支持** | 同上:指针被钳制回屏幕边缘 |
|
|
516
|
+
| 跨私有桌面搬运窗口 | **不支持** | Windows 不允许;`move_to` 会明确报错 |
|
|
517
|
+
|
|
370
518
|
---
|
|
371
519
|
|
|
372
520
|
# 通用功能
|
|
@@ -407,12 +555,15 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
407
555
|
| `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
|
|
408
556
|
| `unsupported-windows-build` | 系统低于 Win10 1809,没有 IddCx | 换系统,或用私有桌面(不需要驱动) |
|
|
409
557
|
| `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图,改用虚拟显示器 |
|
|
410
|
-
| `SetThreadDesktop ... error 170` |
|
|
558
|
+
| `SetThreadDesktop ... error 170` | 你自己在绑定线程,而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到;库内部的枚举不再绑定调用者线程 |
|
|
559
|
+
| 某程序在私有桌面上找不到窗口 | 控制台类程序(`cmd.exe`、`ping.exe`)的窗口属于 `conhost.exe`,不在自己 pid 下 | 报错会提示;用 `agent.exec()` 在桌面内驱动它,或换 GUI 程序 |
|
|
411
560
|
| 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;`report.requires_reboot` 为真时重启 |
|
|
412
561
|
| 驱动下载失败或极慢 | github.com 不可达 | 设 `VIRTUALDESKTOP_GITHUB_MIRRORS`,或预置离线缓存 |
|
|
413
|
-
|
|
|
562
|
+
| 窗口截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制;换 `bitblt` 试试,或确认窗口有内容 |
|
|
563
|
+
| 桌面截图少了右下角 | 进程不感知 DPI,矩形按逻辑坐标算 | 先调 `CaptureEngine().ensure_dpi_aware()` |
|
|
564
|
+
| 桌面截图拍不到工作区窗口 | 用了 `offscreen`,窗口在所有显示器之外 | 改用 `hide_mode="virtual_screen"` |
|
|
565
|
+
| `capture_desktop()` 报"workspace is not open" | offscreen 模式下没有绑定显示器 | 改用 `capture_all_screens()`,或用 `virtual_screen` |
|
|
414
566
|
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
|
|
415
|
-
| 某程序在私有桌面上找不到窗口 | 控制台类(cmd)等窗口不参与常规枚举 | 换程序,或用 `agent.exec()` 在桌面内直接操作 |
|
|
416
567
|
|
|
417
568
|
**离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
|
|
418
569
|
`artifact.filename`)放进 `VIRTUALDESKTOP_CACHE_DIR` 即可,不会联网;也可用
|
|
@@ -420,8 +571,9 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
420
571
|
|
|
421
572
|
## 已知限制
|
|
422
573
|
|
|
423
|
-
-
|
|
424
|
-
|
|
574
|
+
- **私有桌面不能截图**:程序窗口截图和桌面截图都不行。窗口没有被系统合成、没有像素可读;
|
|
575
|
+
而该桌面的屏幕 DC 也拿不到(`BitBlt` 返回拒绝访问)。用 `agent.info()` / `agent.text()` 读状态,
|
|
576
|
+
需要像素请改用虚拟显示器。
|
|
425
577
|
- **私有桌面不能用真实鼠标 / 滚轮 / 真实键盘**:那里没有活动指针和键盘焦点;消息驱动可用。
|
|
426
578
|
- **窗口无法在私有桌面之间搬运**:Windows 不允许跨桌面移动窗口。
|
|
427
579
|
- 部分应用(UWP、硬件叠加层、DRM 受保护内容)任何离屏方式都抓不到——系统限制。
|
|
@@ -28,14 +28,52 @@ uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
|
|
|
28
28
|
| | 私有桌面 | 虚拟显示器 |
|
|
29
29
|
|---|---|---|
|
|
30
30
|
| 一句话 | 另开一个桌面,窗口与你的桌面完全隔离 | 多一块屏幕,窗口仍在你的桌面上、只是画在看不见的地方 |
|
|
31
|
-
|
|
|
31
|
+
| 底层 | 内核**对象**(`CreateDesktopW`),独立窗口命名空间 + DACL | 显示**设备**(IddCx 驱动),系统多出一块 `\\.\DISPLAYn` |
|
|
32
32
|
| 需要权限 | **零**:不需要驱动、不弹 UAC | 装一次驱动,弹**一次 UAC** |
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
|
|
|
33
|
+
| 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
|
|
34
|
+
| 程序窗口截图 | **不能** | **能** |
|
|
35
|
+
| 桌面截图 | **不能** | **能** |
|
|
36
|
+
| 读窗口属性 / 枚举子控件 | 能(桌面内代理) | 能(外部进程直接) |
|
|
37
|
+
| 点击控件 / 送入字符 | 能(窗口消息) | 能(消息 + 真实输入) |
|
|
38
|
+
| 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
|
|
39
|
+
| 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
|
|
40
|
+
| 窗口跨桌面搬运 | **不能**(Windows 限制) | 能(在显示器之间移动) |
|
|
41
|
+
| 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
|
|
42
|
+
|
|
43
|
+
### 隔离强度分级
|
|
44
|
+
|
|
45
|
+
两套方案的隔离**不是"强/弱"两档,而是三个层面各不相同**,按你需要防住什么来选:
|
|
46
|
+
|
|
47
|
+
| 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
|
|
48
|
+
|---|---|---|---|
|
|
49
|
+
| 窗口**发现**(能否枚举到) | **隔离** | 不隔离 | 私有桌面:`EnumWindows`/`FindWindow` 从外部看不到 |
|
|
50
|
+
| 窗口**输入**(真实鼠标键盘) | **隔离** | 不隔离 | 私有桌面:没有活动指针与键盘焦点 |
|
|
51
|
+
| 窗口**截图**(像素可读性) | **隔离** | 不隔离 | 私有桌面:从不参与系统合成,没有像素 |
|
|
52
|
+
| 窗口**消息**(`SendMessage` 等) | **不隔离** | 不隔离 | 拿到句柄即可投递消息,DACL 保护的是桌面对象而不是窗口消息 |
|
|
53
|
+
| 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束;虚拟显示器上的程序由你负责 |
|
|
54
|
+
|
|
55
|
+
> 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**,
|
|
56
|
+
> 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
|
|
57
|
+
> 目标进程的权限,只是把它挪到了另一个窗口命名空间。
|
|
58
|
+
|
|
59
|
+
### 推荐怎么选
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
61
|
+
```
|
|
62
|
+
要截图(窗口截图或桌面截图) → 虚拟显示器
|
|
63
|
+
要用真实鼠标 / 滚轮 / 键盘,或 pyautogui → 虚拟显示器
|
|
64
|
+
要用 pywinauto / UIA 从外部直接驱动窗口 → 虚拟显示器
|
|
65
|
+
只处理一个窗口、要截图,越省事越好 → OffscreenSession(虚拟显示器侧)
|
|
66
|
+
─────────────────────────────────────────────────────────────
|
|
67
|
+
要多个实例互不干扰、且不需要截图 → 私有桌面
|
|
68
|
+
不能弹 UAC、不能装驱动 → 私有桌面
|
|
69
|
+
要"退出后什么都不留"(进程随桌面一起结束) → 私有桌面
|
|
70
|
+
需要窗口完全不出现在任何可见位置 → 私有桌面
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
两条常用组合:
|
|
74
|
+
|
|
75
|
+
- **只是想安静地跑一个窗口并抓图** → `OffscreenSession` + `virtual_screen`
|
|
76
|
+
- **想并行跑很多互不干扰的实例** → 每个实例一个 `VirtualWorkspace("名字")`
|
|
39
77
|
|
|
40
78
|
---
|
|
41
79
|
|
|
@@ -113,6 +151,7 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
|
|
|
113
151
|
| `agent.adopt(pid)` | 接上桌面内已在运行的程序 |
|
|
114
152
|
| `agent.info(window)` | 标题、类名、矩形、`child_count`、`buttons` |
|
|
115
153
|
| `agent.text(window)` | 窗口标题 |
|
|
154
|
+
| `agent.uia_tree(window)` | 完整 UIA 树(在桌面内遍历) |
|
|
116
155
|
| `agent.click(window, text=/index=)` | 点击控件(走窗口消息,见下) |
|
|
117
156
|
| `agent.keys(window, "...")` | 送入字符(走窗口消息) |
|
|
118
157
|
| `agent.windows()` | 该桌面上的窗口清单 |
|
|
@@ -121,6 +160,18 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
|
|
|
121
160
|
`exec` 是逃生舱:里面的代码能 `import VirtualDesktop`、能用 `list_windows()`、`CaptureEngine`,
|
|
122
161
|
也能用你自己装的任何库。代理本身只依赖 ctypes,不需要额外安装。
|
|
123
162
|
|
|
163
|
+
### 私有桌面不能截图
|
|
164
|
+
|
|
165
|
+
两种截图都不行,且原因不同:
|
|
166
|
+
|
|
167
|
+
| 类型 | 结果 | 原因 |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| 程序窗口截图 | **不能** | 该桌面的窗口从不被系统合成,没有像素可读 |
|
|
170
|
+
| 桌面截图 | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
|
|
171
|
+
|
|
172
|
+
`agent.screenshot()` 会**明确报错**(`code="capture-needs-shared-workspace"`)而不是产出空白图。
|
|
173
|
+
要读状态用 `agent.info()` / `agent.text()`;要像素请改用虚拟显示器。
|
|
174
|
+
|
|
124
175
|
## 能力边界
|
|
125
176
|
|
|
126
177
|
私有桌面的窗口**不在交互桌面上**,所以按输入机制分,能做的事如下:
|
|
@@ -129,17 +180,50 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
|
|
|
129
180
|
|---|---|---|
|
|
130
181
|
| 找到窗口、读标题 / 类名 / 矩形 | 能 | 读窗口属性,跨桌面可用 |
|
|
131
182
|
| 枚举子控件、读控件文字 | 能 | 同上 |
|
|
183
|
+
| 用外部进程的 pywinauto 连上并读属性 | **能** | 拿得到句柄就能读 |
|
|
132
184
|
| 点击控件(`BM_CLICK` 等窗口消息) | **能** | 消息直接投递给窗口,不需要指针 |
|
|
133
185
|
| 送入字符(`WM_CHAR` 消息) | **能** | 同上 |
|
|
134
|
-
|
|
|
186
|
+
| **程序窗口截图** | **不能** | 桌面从不被系统合成,窗口没有像素 |
|
|
187
|
+
| **桌面截图** | **不能** | 拿不到该桌面的屏幕 DC(`BitBlt` 返回拒绝访问) |
|
|
135
188
|
| 移动真实鼠标来点击 | **不能** | 该桌面不是活动桌面,没有指针 |
|
|
136
189
|
| 滚轮(真实滚轮事件) | **不能** | 同"移动真实鼠标" |
|
|
137
190
|
| 发送真实键盘输入(`SendInput`) | **不能** | 键盘只发给活动桌面 |
|
|
138
191
|
| `pywinauto` 的 `click_input()` / `type_keys()` | **不能** | 依赖真实输入与前台焦点 |
|
|
139
|
-
| 用外部进程的 pywinauto 直接连上它 | **不能** | 跨桌面拿不到窗口 |
|
|
140
192
|
|
|
141
|
-
|
|
142
|
-
|
|
193
|
+
要点:**能用消息驱动的都能用,需要真实输入设备的都不能用,两种截图都不能用**。需要截图或需要
|
|
194
|
+
真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
|
|
195
|
+
|
|
196
|
+
## 支持的能力与调用方式
|
|
197
|
+
|
|
198
|
+
| 能力 | 是否支持 | 怎么调 |
|
|
199
|
+
|---|---|---|
|
|
200
|
+
| 在一个桌面里跑多个程序 | **支持** | 连续 `agent.launch([...])`,各自拿到不同句柄 |
|
|
201
|
+
| 多个独立桌面同时在线 | **支持** | 多个 `VirtualWorkspace("名字")` 并存,句柄与名字互不相同 |
|
|
202
|
+
| 拿到窗口句柄 | **支持** | `agent.launch([...]).hwnd` / `agent.adopt(pid).hwnd` / `desktop.find_window(pid)` |
|
|
203
|
+
| 读标题 / 类名 / 矩形 / 尺寸 | **支持** | `agent.info(window)` |
|
|
204
|
+
| 枚举子控件与按钮文字 | **支持** | `agent.info(window)["child_count"]` / `["buttons"]` |
|
|
205
|
+
| 完整 UIA 树 | **支持** | `agent.uia_tree(window)` |
|
|
206
|
+
| 点击控件 / 送入字符 | **支持** | `agent.click(window, text=...)` / `agent.keys(window, "...")` |
|
|
207
|
+
| 在桌面内跑任意 Python | **支持** | `agent.exec("...")`(可用 comtypes、pywinauto) |
|
|
208
|
+
| 程序窗口截图 / 桌面截图 | **不支持** | 见上方原因;改用虚拟显示器 |
|
|
209
|
+
| 跨桌面搬运窗口、移到主屏 | **不支持** | `move_to` 一律报 `cannot-cross-desktop`;只能在目标桌面重新 `launch` |
|
|
210
|
+
|
|
211
|
+
`agent.uia_tree()` 在**桌面内**遍历 UIA,这是私有桌面唯一可用的取树方式:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
with VirtualWorkspace("lab") as space, space.agent() as agent:
|
|
215
|
+
window = agent.launch(["charmap.exe"])
|
|
216
|
+
tree = agent.uia_tree(window) # view="RawView" 默认;也可 ControlView / ContentView
|
|
217
|
+
print(tree["count"])
|
|
218
|
+
for node in tree["nodes"][:5]:
|
|
219
|
+
print(node["control"], node["name"], node["enabled"])
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
> 不要用 `GetRootElement()`:它属于**输入桌面**,在私有桌面里走它拿不到任何目标窗口。
|
|
223
|
+
> 库内部用的是 `ElementFromHandle(hwnd)`。
|
|
224
|
+
|
|
225
|
+
> 在里面用 comtypes / pywinauto 是安全的:窗口枚举已不再绑定调用者线程,所以
|
|
226
|
+
> `from pywinauto import Application` 放在文件顶部也不会影响私有桌面。
|
|
143
227
|
|
|
144
228
|
---
|
|
145
229
|
|
|
@@ -232,7 +316,17 @@ VirtualWorkspace(VirtualWorkspace.SHARED, monitor="primary") # 主屏
|
|
|
232
316
|
|
|
233
317
|
## 截图
|
|
234
318
|
|
|
235
|
-
|
|
319
|
+
要分清两种,它们走**完全不同的通路**,能力也不同:
|
|
320
|
+
|
|
321
|
+
| 类型 | 拍什么 | 走哪条路 | 怎么调 |
|
|
322
|
+
|---|---|---|---|
|
|
323
|
+
| **程序窗口截图** | 某一个窗口自己的像素 | `PrintWindow(hwnd)` | `CaptureEngine.capture(hwnd)` / `space.capture(hwnd)` |
|
|
324
|
+
| **桌面截图** | 整块屏幕(含其上所有窗口) | `ImageGrab` / `BitBlt` 屏幕 DC | `capture_all_screens()` / `capture_monitor()` / `space.capture_desktop()` |
|
|
325
|
+
|
|
326
|
+
### 程序窗口截图
|
|
327
|
+
|
|
328
|
+
按句柄拍,窗口在屏幕外也能拍,是抓单个程序界面最可靠的方式。`capture()` 依次尝试多种后端直到
|
|
329
|
+
拿到**有内容**的帧,默认顺序:
|
|
236
330
|
|
|
237
331
|
`printwindow`(`PW_RENDERFULLCONTENT`)→ `printwindow_plain` → `wm_print` → `bitblt`
|
|
238
332
|
|
|
@@ -247,6 +341,39 @@ frame = engine.capture(hwnd, require_content=True)
|
|
|
247
341
|
print(frame.method, frame.size)
|
|
248
342
|
```
|
|
249
343
|
|
|
344
|
+
```python
|
|
345
|
+
with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
|
|
346
|
+
entry = space.launch(["charmap.exe"])
|
|
347
|
+
space.capture(entry.hwnd, save_to="window.png")
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### 桌面截图
|
|
351
|
+
|
|
352
|
+
拍的是**屏幕**,不是某个窗口。`virtual_screen_rect()` 是整张虚拟桌面(含所有显示器,处理好负坐标),
|
|
353
|
+
`capture_monitor()` 是单块显示器。在虚拟显示器模式下,`space.capture_desktop()` 等价于
|
|
354
|
+
`capture_monitor(space.monitor)`。
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
from VirtualDesktop import CaptureEngine
|
|
358
|
+
from VirtualDesktop.desktop_capture import capture_all_screens, capture_monitor
|
|
359
|
+
|
|
360
|
+
CaptureEngine().ensure_dpi_aware() # 拍摄前必须做,见下方警告
|
|
361
|
+
|
|
362
|
+
whole = capture_all_screens() # 整张虚拟桌面
|
|
363
|
+
whole.image.save("desktop.png")
|
|
364
|
+
print(whole.rect, whole.size, whole.method)
|
|
365
|
+
|
|
366
|
+
one = capture_monitor(device="\\\\.\\DISPLAY2") # 只拍某一块屏
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
> **拍摄前先确保 DPI 感知。** 矩形由 `GetSystemMetrics` / `EnumDisplayMonitors` 计算,它们对
|
|
370
|
+
> **不感知 DPI** 的进程返回的是**缩放后的逻辑值**;而像素抓取返回的是**物理像素**。两者不一致时
|
|
371
|
+
> 会**静默裁掉**画面右下角(125% 缩放下 1920×1080 的屏只拿到 1536×864,少 384×216 像素,且不报错)。
|
|
372
|
+
> 先调一次 `CaptureEngine().ensure_dpi_aware()` 即可,`VirtualWorkspace` 内部已自动处理。
|
|
373
|
+
|
|
374
|
+
> **桌面截图只有虚拟显示器模式可用。** offscreen 模式下窗口被移到所有显示器**之外**,桌面截图按
|
|
375
|
+
> 定义拍不到它们;而私有桌面根本没有可拍的屏幕 DC。
|
|
376
|
+
|
|
250
377
|
## 用自动化库控制窗口
|
|
251
378
|
|
|
252
379
|
窗口是普通窗口,直接从你的进程用即可:
|
|
@@ -302,7 +429,8 @@ print(current_primary_size()) # (1920, 1080)
|
|
|
302
429
|
| 枚举子控件、读控件文字 | 能 | 能 |
|
|
303
430
|
| 点击控件(窗口消息) | 能 | 能 |
|
|
304
431
|
| 送入字符(窗口消息) | 能 | 能 |
|
|
305
|
-
|
|
|
432
|
+
| **程序窗口截图** | **能** | **能** |
|
|
433
|
+
| **桌面截图** | **能**(拍到窗口) | **不能**(窗口在所有显示器之外) |
|
|
306
434
|
| 外部 pywinauto / UIA 直接连上 | 能 | 能 |
|
|
307
435
|
| 移动真实鼠标来点击 | 能 | **不能**(窗口在屏幕之外) |
|
|
308
436
|
| 滚轮 | 能 | **不能** |
|
|
@@ -311,6 +439,26 @@ print(current_primary_size()) # (1920, 1080)
|
|
|
311
439
|
> 用真实输入时,窗口所在显示器必须是活动桌面的一部分;`pyautogui` 这类库还需要窗口能取得前台
|
|
312
440
|
> 焦点。若目标窗口拿不到焦点,请改用消息驱动(`agent.click()` / `WM_CHAR`)。
|
|
313
441
|
|
|
442
|
+
## 支持的能力与调用方式
|
|
443
|
+
|
|
444
|
+
| 能力 | 是否支持 | 怎么调 |
|
|
445
|
+
|---|---|---|
|
|
446
|
+
| 在一个工作区里跑多个程序 | **支持** | 连续 `ws.launch([...])`,运行中可随时新增 |
|
|
447
|
+
| 多个工作区同时在线 | **支持** | 多个 `VirtualWorkspace(SHARED)` 并存,各自独立停靠位 |
|
|
448
|
+
| 拿到窗口句柄给外部用 | **支持** | `ws.describe()["windows"]`(真实 HWND,无包装) |
|
|
449
|
+
| 读标题 / 类名 / 矩形 | **支持** | 外部 pywinauto 直接读,或 `ws.windows()` |
|
|
450
|
+
| 枚举子控件 | **支持** | 外部 pywinauto `children(visible_only=False)` |
|
|
451
|
+
| 完整 UIA 树 | **支持** | 外部 UIA,或 `--ui-tree DIR` |
|
|
452
|
+
| 程序窗口截图 | **支持** | `ws.capture(hwnd, save_to=...)` / `CaptureEngine.capture(hwnd)` |
|
|
453
|
+
| 桌面截图 | **支持**(`virtual_screen`) | `ws.capture_desktop()` / `capture_monitor()` / `capture_all_screens()` |
|
|
454
|
+
| 真实鼠标 / 滚轮 / 键盘 | **支持**(`virtual_screen`) | pywinauto `click_input()` / `type_keys()`,或 pyautogui |
|
|
455
|
+
| 在显示器之间移动窗口 | **支持** | `move_to("\\\\.\\DISPLAY2")` / `move_all_to(...)` / `move_to_primary()` |
|
|
456
|
+
| 退出时还原窗口 | **支持** | `with` 退出自动还原;异常退出同样还原 |
|
|
457
|
+
| 退出时卸载驱动 | **支持** | `uninstall_on_exit=True` 或 `close(uninstall=True)` |
|
|
458
|
+
| 桌面截图(`offscreen` 模式) | **不支持** | 窗口在所有显示器之外,拍不到;改用 `virtual_screen` |
|
|
459
|
+
| 真实鼠标 / 滚轮 / 键盘(`offscreen`) | **不支持** | 同上:指针被钳制回屏幕边缘 |
|
|
460
|
+
| 跨私有桌面搬运窗口 | **不支持** | Windows 不允许;`move_to` 会明确报错 |
|
|
461
|
+
|
|
314
462
|
---
|
|
315
463
|
|
|
316
464
|
# 通用功能
|
|
@@ -351,12 +499,15 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
351
499
|
| `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
|
|
352
500
|
| `unsupported-windows-build` | 系统低于 Win10 1809,没有 IddCx | 换系统,或用私有桌面(不需要驱动) |
|
|
353
501
|
| `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图,改用虚拟显示器 |
|
|
354
|
-
| `SetThreadDesktop ... error 170` |
|
|
502
|
+
| `SetThreadDesktop ... error 170` | 你自己在绑定线程,而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到;库内部的枚举不再绑定调用者线程 |
|
|
503
|
+
| 某程序在私有桌面上找不到窗口 | 控制台类程序(`cmd.exe`、`ping.exe`)的窗口属于 `conhost.exe`,不在自己 pid 下 | 报错会提示;用 `agent.exec()` 在桌面内驱动它,或换 GUI 程序 |
|
|
355
504
|
| 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;`report.requires_reboot` 为真时重启 |
|
|
356
505
|
| 驱动下载失败或极慢 | github.com 不可达 | 设 `VIRTUALDESKTOP_GITHUB_MIRRORS`,或预置离线缓存 |
|
|
357
|
-
|
|
|
506
|
+
| 窗口截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制;换 `bitblt` 试试,或确认窗口有内容 |
|
|
507
|
+
| 桌面截图少了右下角 | 进程不感知 DPI,矩形按逻辑坐标算 | 先调 `CaptureEngine().ensure_dpi_aware()` |
|
|
508
|
+
| 桌面截图拍不到工作区窗口 | 用了 `offscreen`,窗口在所有显示器之外 | 改用 `hide_mode="virtual_screen"` |
|
|
509
|
+
| `capture_desktop()` 报"workspace is not open" | offscreen 模式下没有绑定显示器 | 改用 `capture_all_screens()`,或用 `virtual_screen` |
|
|
358
510
|
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
|
|
359
|
-
| 某程序在私有桌面上找不到窗口 | 控制台类(cmd)等窗口不参与常规枚举 | 换程序,或用 `agent.exec()` 在桌面内直接操作 |
|
|
360
511
|
|
|
361
512
|
**离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
|
|
362
513
|
`artifact.filename`)放进 `VIRTUALDESKTOP_CACHE_DIR` 即可,不会联网;也可用
|
|
@@ -364,8 +515,9 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
364
515
|
|
|
365
516
|
## 已知限制
|
|
366
517
|
|
|
367
|
-
-
|
|
368
|
-
|
|
518
|
+
- **私有桌面不能截图**:程序窗口截图和桌面截图都不行。窗口没有被系统合成、没有像素可读;
|
|
519
|
+
而该桌面的屏幕 DC 也拿不到(`BitBlt` 返回拒绝访问)。用 `agent.info()` / `agent.text()` 读状态,
|
|
520
|
+
需要像素请改用虚拟显示器。
|
|
369
521
|
- **私有桌面不能用真实鼠标 / 滚轮 / 真实键盘**:那里没有活动指针和键盘焦点;消息驱动可用。
|
|
370
522
|
- **窗口无法在私有桌面之间搬运**:Windows 不允许跨桌面移动窗口。
|
|
371
523
|
- 部分应用(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.
|
|
66
|
+
__version__ = "1.4.0"
|
|
67
67
|
|
|
68
68
|
__all__ = [
|
|
69
69
|
"__version__",
|
|
@@ -229,6 +229,81 @@ def _exec(code: str) -> dict:
|
|
|
229
229
|
return {"ok": True, "stdout": buffer.getvalue()}
|
|
230
230
|
|
|
231
231
|
|
|
232
|
+
def _uia_tree(hwnd: int, *, depth: int = 8, max_nodes: int = 500, view: str = "RawView") -> dict:
|
|
233
|
+
"""Walk the UI Automation tree of ``hwnd`` **from inside this desktop**.
|
|
234
|
+
|
|
235
|
+
Uses ``ElementFromHandle`` rather than ``GetRootElement``: the root element belongs to the
|
|
236
|
+
*input* desktop, so from a private desktop it yields a single node and none of the application's
|
|
237
|
+
windows. An element obtained from a handle is not restricted that way (measured: root -> 1
|
|
238
|
+
node, ``FromHandle`` -> the window's real subtree).
|
|
239
|
+
|
|
240
|
+
Running inside the desktop is the whole point: a UIA client elsewhere resolves elements against
|
|
241
|
+
the desktop it is attached to, so an outside dump returns nothing for a private desktop.
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
import comtypes.client
|
|
245
|
+
from comtypes.gen import UIAutomationClient as UIA
|
|
246
|
+
except Exception as exc: # noqa: BLE001 - comtypes is optional
|
|
247
|
+
return {
|
|
248
|
+
"ok": True,
|
|
249
|
+
"tree": {
|
|
250
|
+
"error": f"UI Automation unavailable inside the desktop: {type(exc).__name__}: {exc}",
|
|
251
|
+
"nodes": [],
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
automation = comtypes.client.CreateObject(UIA.CUIAutomation, interface=UIA.IUIAutomation)
|
|
256
|
+
root = automation.ElementFromHandle(hwnd)
|
|
257
|
+
if root is None:
|
|
258
|
+
return {"ok": True, "tree": {"error": "ElementFromHandle returned null", "nodes": []}}
|
|
259
|
+
walker = {
|
|
260
|
+
"RawView": automation.RawViewWalker,
|
|
261
|
+
"ControlView": automation.ControlViewWalker,
|
|
262
|
+
"ContentView": automation.ContentViewWalker,
|
|
263
|
+
}.get(view, automation.RawViewWalker)
|
|
264
|
+
|
|
265
|
+
nodes: list = []
|
|
266
|
+
|
|
267
|
+
def visit(element, level: int) -> None:
|
|
268
|
+
if level > depth or len(nodes) >= max_nodes:
|
|
269
|
+
return
|
|
270
|
+
try:
|
|
271
|
+
box = element.CurrentBoundingRectangle
|
|
272
|
+
nodes.append(
|
|
273
|
+
{
|
|
274
|
+
"depth": level,
|
|
275
|
+
"name": element.CurrentName or "",
|
|
276
|
+
"control": str(element.CurrentControlType),
|
|
277
|
+
"type_id": int(element.CurrentControlType),
|
|
278
|
+
"automation": element.CurrentAutomationId or "",
|
|
279
|
+
"class": element.CurrentClassName or "",
|
|
280
|
+
"handle": int(element.CurrentNativeWindowHandle or 0),
|
|
281
|
+
"x": int(box.left),
|
|
282
|
+
"y": int(box.top),
|
|
283
|
+
"w": int(box.right - box.left),
|
|
284
|
+
"h": int(box.bottom - box.top),
|
|
285
|
+
"enabled": bool(element.CurrentIsEnabled),
|
|
286
|
+
"offscreen": bool(element.CurrentIsOffscreen),
|
|
287
|
+
"focusable": bool(element.CurrentIsKeyboardFocusable),
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
except Exception: # noqa: BLE001 - one dying element must not end the walk
|
|
291
|
+
return
|
|
292
|
+
try:
|
|
293
|
+
child = walker.GetFirstChildElement(element)
|
|
294
|
+
except Exception: # noqa: BLE001
|
|
295
|
+
return
|
|
296
|
+
while child is not None and len(nodes) < max_nodes:
|
|
297
|
+
visit(child, level + 1)
|
|
298
|
+
try:
|
|
299
|
+
child = walker.GetNextSiblingElement(child)
|
|
300
|
+
except Exception: # noqa: BLE001
|
|
301
|
+
break
|
|
302
|
+
|
|
303
|
+
visit(root, 0)
|
|
304
|
+
return {"ok": True, "tree": {"view": view, "count": len(nodes), "nodes": nodes}}
|
|
305
|
+
|
|
306
|
+
|
|
232
307
|
def _handle(request: dict) -> dict:
|
|
233
308
|
if not _kernel32.GetCurrentProcessId(): # pragma: no cover - keeps kernel32 referenced
|
|
234
309
|
pass
|
|
@@ -246,6 +321,13 @@ def _handle(request: dict) -> dict:
|
|
|
246
321
|
return _screenshot(int(request["hwnd"], 0), str(request["path"]))
|
|
247
322
|
if command == "windows":
|
|
248
323
|
return _windows()
|
|
324
|
+
if command == "uia_tree":
|
|
325
|
+
return _uia_tree(
|
|
326
|
+
int(request["hwnd"], 0),
|
|
327
|
+
depth=int(request.get("depth", 8)),
|
|
328
|
+
max_nodes=int(request.get("max_nodes", 500)),
|
|
329
|
+
view=str(request.get("view", "RawView")),
|
|
330
|
+
)
|
|
249
331
|
if command == "exec":
|
|
250
332
|
return _exec(str(request.get("code", "")))
|
|
251
333
|
return {"ok": False, "error": f"unknown command {command!r}"}
|