VirtualDesktop 1.3.1__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.
Files changed (62) hide show
  1. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/PKG-INFO +100 -9
  2. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/README.md +99 -8
  3. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/__init__.py +1 -1
  4. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_desktop_runtime.py +82 -0
  5. virtualdesktop-1.4.0/VirtualDesktop/_enumerate.py +97 -0
  6. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/agent.py +45 -0
  7. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/desktop_session.py +230 -20
  8. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/workspace.py +45 -1
  9. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/PKG-INFO +100 -9
  10. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/SOURCES.txt +1 -0
  11. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/pyproject.toml +1 -1
  12. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/LICENSE +0 -0
  13. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/__main__.py +0 -0
  14. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_bundle.py +0 -0
  15. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_compat.py +0 -0
  16. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_devices.py +0 -0
  17. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_download.py +0 -0
  18. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_helper.py +0 -0
  19. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_log.py +0 -0
  20. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_process.py +0 -0
  21. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_sigcheck.ps1 +0 -0
  22. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_sigcheck.py +0 -0
  23. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/_winapi.py +0 -0
  24. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/capture.py +0 -0
  25. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/cli.py +0 -0
  26. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/config.py +0 -0
  27. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/desktop.py +0 -0
  28. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/desktop_capture.py +0 -0
  29. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/display_setup.py +0 -0
  30. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/driver.py +0 -0
  31. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/driver_manifest.json +0 -0
  32. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/errors.py +0 -0
  33. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/manifest.py +0 -0
  34. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/session.py +0 -0
  35. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop/window.py +0 -0
  36. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/dependency_links.txt +0 -0
  37. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/entry_points.txt +0 -0
  38. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/requires.txt +0 -0
  39. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/VirtualDesktop.egg-info/top_level.txt +0 -0
  40. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/setup.cfg +0 -0
  41. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_accelerators.py +0 -0
  42. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_agent.py +0 -0
  43. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_artifact_regression.py +0 -0
  44. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_capture.py +0 -0
  45. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_cli.py +0 -0
  46. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_compat.py +0 -0
  47. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_config_and_winapi.py +0 -0
  48. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_desktop_isolation.py +0 -0
  49. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_destructive_lifecycle.py +0 -0
  50. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_display_setup.py +0 -0
  51. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_download.py +0 -0
  52. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_driver.py +0 -0
  53. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_hide_modes.py +0 -0
  54. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_integration_windows.py +0 -0
  55. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_manifest.py +0 -0
  56. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_no_dialogs.py +0 -0
  57. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_packaging.py +0 -0
  58. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_process.py +0 -0
  59. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_virtual_desktop.py +0 -0
  60. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_workspace.py +0 -0
  61. {virtualdesktop-1.3.1 → virtualdesktop-1.4.0}/tests/test_workspace_instances.py +0 -0
  62. {virtualdesktop-1.3.1 → 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.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
- | 外部自动化库能用吗 | **不能**(要在桌面内跑代理) | **能**(pywinauto / UIA / pyautogui 直接用) |
91
- | 适合 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
89
+ | 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
90
+ | 程序窗口截图 | **不能** | **能** |
91
+ | 桌面截图 | **不能** | **能** |
92
+ | 读窗口属性 / 枚举子控件 | 能(桌面内代理) | 能(外部进程直接) |
93
+ | 点击控件 / 送入字符 | 能(窗口消息) | 能(消息 + 真实输入) |
94
+ | 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
95
+ | 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
96
+ | 窗口跨桌面搬运 | **不能**(Windows 限制) | 能(在显示器之间移动) |
97
+ | 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
92
98
 
93
- > **只想跑一个窗口、要截图** → 用虚拟显示器侧的 `OffscreenSession`,最省事。
94
- > **要多个实例互不干扰、不需要截图** → 用私有桌面。
99
+ ### 隔离强度分级
100
+
101
+ 两套方案的隔离**不是"强/弱"两档,而是三个层面各不相同**,按你需要防住什么来选:
102
+
103
+ | 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
104
+ |---|---|---|---|
105
+ | 窗口**发现**(能否枚举到) | **隔离** | 不隔离 | 私有桌面:`EnumWindows`/`FindWindow` 从外部看不到 |
106
+ | 窗口**输入**(真实鼠标键盘) | **隔离** | 不隔离 | 私有桌面:没有活动指针与键盘焦点 |
107
+ | 窗口**截图**(像素可读性) | **隔离** | 不隔离 | 私有桌面:从不参与系统合成,没有像素 |
108
+ | 窗口**消息**(`SendMessage` 等) | **不隔离** | 不隔离 | 拿到句柄即可投递消息,DACL 保护的是桌面对象而不是窗口消息 |
109
+ | 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束;虚拟显示器上的程序由你负责 |
110
+
111
+ > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**,
112
+ > 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
113
+ > 目标进程的权限,只是把它挪到了另一个窗口命名空间。
114
+
115
+ ### 推荐怎么选
116
+
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()` | 该桌面上的窗口清单 |
@@ -210,6 +249,38 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
210
249
  要点:**能用消息驱动的都能用,需要真实输入设备的都不能用,两种截图都不能用**。需要截图或需要
211
250
  真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
212
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` 放在文件顶部也不会影响私有桌面。
283
+
213
284
  ---
214
285
 
215
286
  # 第二部分:虚拟显示器
@@ -424,6 +495,26 @@ print(current_primary_size()) # (1920, 1080)
424
495
  > 用真实输入时,窗口所在显示器必须是活动桌面的一部分;`pyautogui` 这类库还需要窗口能取得前台
425
496
  > 焦点。若目标窗口拿不到焦点,请改用消息驱动(`agent.click()` / `WM_CHAR`)。
426
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
+
427
518
  ---
428
519
 
429
520
  # 通用功能
@@ -464,7 +555,8 @@ VIRTUALDESKTOP_LOG_FILE=<path>
464
555
  | `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
465
556
  | `unsupported-windows-build` | 系统低于 Win10 1809,没有 IddCx | 换系统,或用私有桌面(不需要驱动) |
466
557
  | `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图,改用虚拟显示器 |
467
- | `SetThreadDesktop ... error 170` | 当前线程已有窗口,无法再绑定桌面 | 在创建窗口之前 `bind_thread()`,或用 `agent()` |
558
+ | `SetThreadDesktop ... error 170` | 你自己在绑定线程,而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到;库内部的枚举不再绑定调用者线程 |
559
+ | 某程序在私有桌面上找不到窗口 | 控制台类程序(`cmd.exe`、`ping.exe`)的窗口属于 `conhost.exe`,不在自己 pid 下 | 报错会提示;用 `agent.exec()` 在桌面内驱动它,或换 GUI 程序 |
468
560
  | 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;`report.requires_reboot` 为真时重启 |
469
561
  | 驱动下载失败或极慢 | github.com 不可达 | 设 `VIRTUALDESKTOP_GITHUB_MIRRORS`,或预置离线缓存 |
470
562
  | 窗口截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制;换 `bitblt` 试试,或确认窗口有内容 |
@@ -472,7 +564,6 @@ VIRTUALDESKTOP_LOG_FILE=<path>
472
564
  | 桌面截图拍不到工作区窗口 | 用了 `offscreen`,窗口在所有显示器之外 | 改用 `hide_mode="virtual_screen"` |
473
565
  | `capture_desktop()` 报"workspace is not open" | offscreen 模式下没有绑定显示器 | 改用 `capture_all_screens()`,或用 `virtual_screen` |
474
566
  | 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
475
- | 某程序在私有桌面上找不到窗口 | 控制台类(cmd)等窗口不参与常规枚举 | 换程序,或用 `agent.exec()` 在桌面内直接操作 |
476
567
 
477
568
  **离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
478
569
  `artifact.filename`)放进 `VIRTUALDESKTOP_CACHE_DIR` 即可,不会联网;也可用
@@ -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
- | 外部自动化库能用吗 | **不能**(要在桌面内跑代理) | **能**(pywinauto / UIA / pyautogui 直接用) |
35
- | 适合 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
33
+ | 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
34
+ | 程序窗口截图 | **不能** | **能** |
35
+ | 桌面截图 | **不能** | **能** |
36
+ | 读窗口属性 / 枚举子控件 | 能(桌面内代理) | 能(外部进程直接) |
37
+ | 点击控件 / 送入字符 | 能(窗口消息) | 能(消息 + 真实输入) |
38
+ | 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
39
+ | 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
40
+ | 窗口跨桌面搬运 | **不能**(Windows 限制) | 能(在显示器之间移动) |
41
+ | 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
36
42
 
37
- > **只想跑一个窗口、要截图** → 用虚拟显示器侧的 `OffscreenSession`,最省事。
38
- > **要多个实例互不干扰、不需要截图** → 用私有桌面。
43
+ ### 隔离强度分级
44
+
45
+ 两套方案的隔离**不是"强/弱"两档,而是三个层面各不相同**,按你需要防住什么来选:
46
+
47
+ | 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
48
+ |---|---|---|---|
49
+ | 窗口**发现**(能否枚举到) | **隔离** | 不隔离 | 私有桌面:`EnumWindows`/`FindWindow` 从外部看不到 |
50
+ | 窗口**输入**(真实鼠标键盘) | **隔离** | 不隔离 | 私有桌面:没有活动指针与键盘焦点 |
51
+ | 窗口**截图**(像素可读性) | **隔离** | 不隔离 | 私有桌面:从不参与系统合成,没有像素 |
52
+ | 窗口**消息**(`SendMessage` 等) | **不隔离** | 不隔离 | 拿到句柄即可投递消息,DACL 保护的是桌面对象而不是窗口消息 |
53
+ | 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束;虚拟显示器上的程序由你负责 |
54
+
55
+ > 关键提醒:私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**,
56
+ > 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
57
+ > 目标进程的权限,只是把它挪到了另一个窗口命名空间。
58
+
59
+ ### 推荐怎么选
60
+
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()` | 该桌面上的窗口清单 |
@@ -154,6 +193,38 @@ with VirtualWorkspace("lab") as space, space.agent() as agent:
154
193
  要点:**能用消息驱动的都能用,需要真实输入设备的都不能用,两种截图都不能用**。需要截图或需要
155
194
  真实鼠标/滚轮的应用,请改用下面的虚拟显示器方案。
156
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` 放在文件顶部也不会影响私有桌面。
227
+
157
228
  ---
158
229
 
159
230
  # 第二部分:虚拟显示器
@@ -368,6 +439,26 @@ print(current_primary_size()) # (1920, 1080)
368
439
  > 用真实输入时,窗口所在显示器必须是活动桌面的一部分;`pyautogui` 这类库还需要窗口能取得前台
369
440
  > 焦点。若目标窗口拿不到焦点,请改用消息驱动(`agent.click()` / `WM_CHAR`)。
370
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
+
371
462
  ---
372
463
 
373
464
  # 通用功能
@@ -408,7 +499,8 @@ VIRTUALDESKTOP_LOG_FILE=<path>
408
499
  | `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
409
500
  | `unsupported-windows-build` | 系统低于 Win10 1809,没有 IddCx | 换系统,或用私有桌面(不需要驱动) |
410
501
  | `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图,改用虚拟显示器 |
411
- | `SetThreadDesktop ... error 170` | 当前线程已有窗口,无法再绑定桌面 | 在创建窗口之前 `bind_thread()`,或用 `agent()` |
502
+ | `SetThreadDesktop ... error 170` | 你自己在绑定线程,而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到;库内部的枚举不再绑定调用者线程 |
503
+ | 某程序在私有桌面上找不到窗口 | 控制台类程序(`cmd.exe`、`ping.exe`)的窗口属于 `conhost.exe`,不在自己 pid 下 | 报错会提示;用 `agent.exec()` 在桌面内驱动它,或换 GUI 程序 |
412
504
  | 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;`report.requires_reboot` 为真时重启 |
413
505
  | 驱动下载失败或极慢 | github.com 不可达 | 设 `VIRTUALDESKTOP_GITHUB_MIRRORS`,或预置离线缓存 |
414
506
  | 窗口截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制;换 `bitblt` 试试,或确认窗口有内容 |
@@ -416,7 +508,6 @@ VIRTUALDESKTOP_LOG_FILE=<path>
416
508
  | 桌面截图拍不到工作区窗口 | 用了 `offscreen`,窗口在所有显示器之外 | 改用 `hide_mode="virtual_screen"` |
417
509
  | `capture_desktop()` 报"workspace is not open" | offscreen 模式下没有绑定显示器 | 改用 `capture_all_screens()`,或用 `virtual_screen` |
418
510
  | 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
419
- | 某程序在私有桌面上找不到窗口 | 控制台类(cmd)等窗口不参与常规枚举 | 换程序,或用 `agent.exec()` 在桌面内直接操作 |
420
511
 
421
512
  **离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
422
513
  `artifact.filename`)放进 `VIRTUALDESKTOP_CACHE_DIR` 即可,不会联网;也可用
@@ -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.3.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}"}
@@ -0,0 +1,97 @@
1
+ """One-shot window enumerator that runs *on* a private desktop.
2
+
3
+ ``VirtualDesktop.list_windows`` and ``find_window`` used to enumerate in-process after calling
4
+ ``SetThreadDesktop`` on the caller's thread. That is fragile for a reason that has nothing to do
5
+ with this library: ``SetThreadDesktop`` fails with ``ERROR_BUSY`` (170) once the thread owns any
6
+ window or hook, and initialising COM/OLE — which merely **importing** pywinauto or comtypes does —
7
+ creates a thread-scoped hidden window. A caller who wrote the natural
8
+
9
+ from pywinauto import Application
10
+ from VirtualDesktop import VirtualWorkspace
11
+
12
+ could therefore never use a private desktop at all, and the error said nothing about why.
13
+
14
+ This helper removes the caller's thread from the picture: it is started **on** the desktop through
15
+ ``VirtualDesktop.launch`` (``CreateProcessW`` with ``lpDesktop``), so ``EnumWindows`` sees that
16
+ desktop's windows from a thread that belongs to it. It writes one JSON document and exits.
17
+
18
+ Invoked by the library, not by users; this file is also fine to run by hand for debugging:
19
+
20
+ python _enumerate.py --out windows.json
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ # Started through CreateProcessW by absolute path, so this file's directory is on sys.path and its
31
+ # parent (the package root) is what ``import VirtualDesktop`` needs.
32
+ _PACKAGE_ROOT = Path(__file__).resolve().parent.parent
33
+ if str(_PACKAGE_ROOT) not in sys.path:
34
+ sys.path.insert(0, str(_PACKAGE_ROOT))
35
+
36
+
37
+ def _record_to_dict(record) -> dict:
38
+ """Reuse ``WindowRecord.to_dict`` so both enumeration paths return one shape."""
39
+ try:
40
+ return record.to_dict()
41
+ except Exception: # noqa: BLE001 - fall back to the fields the callers rely on
42
+ return {
43
+ "hwnd": int(record.hwnd),
44
+ "title": record.title,
45
+ "class_name": record.class_name,
46
+ "pid": record.pid,
47
+ "process_name": record.process_name,
48
+ "visible": bool(record.visible),
49
+ "minimized": bool(record.minimized),
50
+ "rect": list(record.rect),
51
+ "owner": int(getattr(record, "owner", 0) or 0),
52
+ "tool_window": bool(getattr(record, "tool_window", False)),
53
+ }
54
+
55
+
56
+ def enumerate_here() -> dict:
57
+ from VirtualDesktop import list_windows
58
+
59
+ return {
60
+ "ok": True,
61
+ "windows": [_record_to_dict(r) for r in list_windows()],
62
+ }
63
+
64
+
65
+ def main() -> int:
66
+ parser = argparse.ArgumentParser()
67
+ parser.add_argument("--out", required=True, help="path of the JSON file to write")
68
+ parser.add_argument(
69
+ "--errors",
70
+ default="",
71
+ help="optional path to also receive a traceback when enumeration fails",
72
+ )
73
+ args = parser.parse_args()
74
+
75
+ out = Path(args.out)
76
+ try:
77
+ payload = enumerate_here()
78
+ except BaseException as exc: # noqa: BLE001 - the parent needs the reason, not a silent exit
79
+ import traceback
80
+
81
+ text = f"{type(exc).__name__}: {exc}\n\n{traceback.format_exc()}"
82
+ payload = {"ok": False, "error": text}
83
+ if args.errors:
84
+ try:
85
+ Path(args.errors).write_text(text, encoding="utf-8")
86
+ except OSError:
87
+ pass
88
+
89
+ try:
90
+ out.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
91
+ except OSError:
92
+ return 2
93
+ return 0 if payload.get("ok") else 1
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(main())
@@ -181,6 +181,26 @@ class AgentSession:
181
181
 
182
182
  # -- convenience -------------------------------------------------------------------
183
183
 
184
+ def _report_to_workspace(self, record) -> None:
185
+ """Tell the owning workspace about a window found here, when there is one.
186
+
187
+ Agents are also used through a bare :class:`~VirtualDesktop.desktop_session.VirtualDesktop`,
188
+ which has no workspace, so this is a no-op there.
189
+ """
190
+ workspace = getattr(self.desktop, "_workspace", None)
191
+ if workspace is None:
192
+ return
193
+ try:
194
+ workspace._remember_isolated(
195
+ hwnd=record.hwnd,
196
+ pid=record.pid,
197
+ title=record.title,
198
+ class_name=record.class_name,
199
+ rect=record.rect,
200
+ )
201
+ except Exception: # noqa: BLE001 - bookkeeping must not break the launch
202
+ log.debug("could not report window 0x%X to the workspace", record.hwnd, exc_info=True)
203
+
184
204
  def launch(
185
205
  self,
186
206
  argv: Sequence[str],
@@ -194,6 +214,7 @@ class AgentSession:
194
214
  record = self.desktop.find_window(
195
215
  process.pid, timeout=timeout or self.timeout, title_hint=title
196
216
  )
217
+ self._report_to_workspace(record)
197
218
  return AgentProcess(
198
219
  pid=process.pid, hwnd=record.hwnd, title=record.title, command=command[0]
199
220
  )
@@ -201,6 +222,7 @@ class AgentSession:
201
222
  def adopt(self, pid: int, *, timeout: Optional[float] = None, title: Optional[str] = None) -> AgentProcess:
202
223
  """Attach to a program already running on the desktop."""
203
224
  record = self.desktop.find_window(pid, timeout=timeout or self.timeout, title_hint=title)
225
+ self._report_to_workspace(record)
204
226
  return AgentProcess(pid=pid, hwnd=record.hwnd, title=record.title)
205
227
 
206
228
  def windows(self) -> List[Dict[str, Any]]:
@@ -223,6 +245,29 @@ class AgentSession:
223
245
  """Type ``text`` into ``window`` using character messages (no keyboard focus needed)."""
224
246
  return int(self.command("keys", hwnd=_handle(window), text=text).get("sent", 0))
225
247
 
248
+ def uia_tree(
249
+ self,
250
+ window: Any,
251
+ *,
252
+ depth: int = 8,
253
+ max_nodes: int = 500,
254
+ view: str = "RawView",
255
+ ) -> Dict[str, Any]:
256
+ """The UI Automation tree of ``window``, walked **inside this desktop**.
257
+
258
+ This is the only way to read the tree of a window on a private desktop: a UIA client
259
+ resolves elements against the desktop it is attached to, so an outside dump sees nothing.
260
+ Inside here it uses ``ElementFromHandle`` (``GetRootElement`` would return the *input*
261
+ desktop's root and find none of this desktop's windows).
262
+
263
+ ``view`` is ``RawView`` (everything, needed for Electron/Chromium), ``ControlView``
264
+ (meaningful controls only) or ``ContentView``.
265
+ """
266
+ payload = self.command(
267
+ "uia_tree", hwnd=_handle(window), depth=depth, max_nodes=max_nodes, view=view
268
+ )
269
+ return dict(payload.get("tree") or {})
270
+
226
271
  def screenshot(self, window: Any, path: str) -> Dict[str, Any]:
227
272
  """Not available: a window on a private desktop has no pixels to capture.
228
273