Metadata-Version: 2.4
Name: VirtualDesktop
Version: 1.4.0
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.
Author: VirtualDesktop contributors
License: MIT License
        
        Copyright (c) 2026 offscreen-mount contributors
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Documentation, https://github.com/Luofeng-Cloud/VirtualDesktop#readme
Project-URL: Source, https://github.com/Luofeng-Cloud/VirtualDesktop
Project-URL: Issues, https://github.com/Luofeng-Cloud/VirtualDesktop/issues
Keywords: windows,virtual-display,idd,printwindow,automation,screenshot,ocr
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Win32 (MS Windows)
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: Microsoft :: Windows :: Windows 10
Classifier: Operating System :: Microsoft :: Windows :: Windows 11
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Systems Administration
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Pillow>=9.0
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Provides-Extra: build
Requires-Dist: build>=1.0; extra == "build"
Requires-Dist: twine>=5.0; extra == "build"
Dynamic: license-file

# VirtualDesktop

在 Windows 上把程序的窗口放到**你看不到的地方**去运行，并读取、操作它。窗口不出现在你的屏幕上，
退出时自动还原。

```python
from VirtualDesktop import VirtualWorkspace

with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
    space.launch(["charmap.exe"])
    print(space.describe()["count"])
# 退出时窗口还原、进程结束
```

本库提供两套**互不相通**的方案。它们的隔离强度、所需权限、以及"外部代码能对里面的窗口做什么"
都不一样，请按需要选一套，不要混用。

## 安装

```bash
pip install VirtualDesktop        # pip
uv add VirtualDesktop             # uv（写入 pyproject.toml 并锁定）
```

运行时依赖只有 **Pillow**。若国内镜像同步滞后导致找不到最新版，加
`--default-index https://pypi.org/simple`（uv）或 `-i https://pypi.org/simple`（pip）。

| | 私有桌面 | 虚拟显示器 |
|---|---|---|
| 一句话 | 另开一个桌面，窗口与你的桌面完全隔离 | 多一块屏幕，窗口仍在你的桌面上、只是画在看不见的地方 |
| 底层 | 内核**对象**（`CreateDesktopW`），独立窗口命名空间 + DACL | 显示**设备**（IddCx 驱动），系统多出一块 `\\.\DISPLAYn` |
| 需要权限 | **零**：不需要驱动、不弹 UAC | 装一次驱动，弹**一次 UAC** |
| 系统要求 | Win10 / 11 任意 | Win10 1809 (17763) 起 |
| 程序窗口截图 | **不能** | **能** |
| 桌面截图 | **不能** | **能** |
| 读窗口属性 / 枚举子控件 | 能（桌面内代理） | 能（外部进程直接） |
| 点击控件 / 送入字符 | 能（窗口消息） | 能（消息 + 真实输入） |
| 真实鼠标 / 滚轮 / 键盘 | **不能** | **能** |
| 外部 pywinauto / pyautogui 直接驱动 | **不能** | **能** |
| 窗口跨桌面搬运 | **不能**（Windows 限制） | 能（在显示器之间移动） |
| 推荐场景 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |

### 隔离强度分级

两套方案的隔离**不是"强/弱"两档，而是三个层面各不相同**，按你需要防住什么来选：

| 隔离层面 | 私有桌面 | 虚拟显示器 | 说明 |
|---|---|---|---|
| 窗口**发现**（能否枚举到） | **隔离** | 不隔离 | 私有桌面：`EnumWindows`/`FindWindow` 从外部看不到 |
| 窗口**输入**（真实鼠标键盘） | **隔离** | 不隔离 | 私有桌面：没有活动指针与键盘焦点 |
| 窗口**截图**（像素可读性） | **隔离** | 不隔离 | 私有桌面：从不参与系统合成，没有像素 |
| 窗口**消息**（`SendMessage` 等） | **不隔离** | 不隔离 | 拿到句柄即可投递消息，DACL 保护的是桌面对象而不是窗口消息 |
| 进程**生命周期** | **隔离** | 不隔离 | 私有桌面关闭时其上进程一并结束；虚拟显示器上的程序由你负责 |

> 关键提醒：私有桌面**不等于**"完全无法访问"。它挡的是**发现**、**输入**和**截图**，
> 挡不住"已知句柄后投递消息"。所以不要把私有桌面当作进程级安全沙箱使用——它没有降低
> 目标进程的权限，只是把它挪到了另一个窗口命名空间。

### 推荐怎么选

```
要截图（窗口截图或桌面截图）              → 虚拟显示器
要用真实鼠标 / 滚轮 / 键盘，或 pyautogui    → 虚拟显示器
要用 pywinauto / UIA 从外部直接驱动窗口     → 虚拟显示器
只处理一个窗口、要截图，越省事越好          → OffscreenSession（虚拟显示器侧）
─────────────────────────────────────────────────────────────
要多个实例互不干扰、且不需要截图            → 私有桌面
不能弹 UAC、不能装驱动                     → 私有桌面
要"退出后什么都不留"（进程随桌面一起结束）   → 私有桌面
需要窗口完全不出现在任何可见位置          → 私有桌面
```

两条常用组合：

- **只是想安静地跑一个窗口并抓图** → `OffscreenSession` + `virtual_screen`
- **想并行跑很多互不干扰的实例** → 每个实例一个 `VirtualWorkspace("名字")`

---

# 第一部分：私有桌面

用 `CreateDesktopW` 建一个独立桌面。它有自己的窗口命名空间：你的桌面上看不到它的窗口，它上面的
程序也看不到你的窗口。**不需要任何驱动、不需要管理员权限、不弹 UAC。**

## 环境要求

| 项目 | 要求 |
|---|---|
| 系统 | Windows 10 / 11（无 IddCx 版本要求） |
| 权限 | 当前用户即可，**不提权** |
| 驱动 | 不需要 |
| 依赖 | 仅 Pillow |

## 基本用法

```python
from VirtualDesktop import VirtualDesktop

with VirtualDesktop("my-workspace") as desktop:
    process = desktop.launch(["charmap.exe"], show=False)
    desktop.bind_thread()
    record = desktop.find_window(process.pid)
    print(record.title, record.rect)
# 退出时桌面关闭，其上进程一并结束
```

工作区形式，一个桌面装多个窗口：

```python
from VirtualDesktop import VirtualWorkspace

# 每个工作区一个独立桌面，互不干扰
with VirtualWorkspace("lab-a") as a, VirtualWorkspace("lab-b") as b:
    a.launch(["charmap.exe"])
    b.launch(["charmap.exe"])
    print(a.describe()["count"])     # 1，只看到自己的
    print(b.describe()["count"])     # 1
```

名字**不会撞车**：`CreateDesktopW` 对同名桌面会复用同一对象，直接复用会让两个实例共享窗口命名
空间，所以默认加随机后缀去重。确实要共享同一个桌面时用 `unique=False`。

## 在桌面内操作窗口：`agent()`

私有桌面是隔离边界，外部进程拿不到它的窗口。**所有操作都要在桌面内执行**，`agent()` 提供一个
`with` 可用的常驻代理：

```python
from VirtualDesktop import VirtualDesktop

with VirtualDesktop("lab").create() as desktop, desktop.agent() as agent:
    window = agent.launch(["charmap.exe"])       # 在里面启动
    info = agent.info(window)                    # 读标题/类名/矩形/子控件数/按钮
    print(info["title"], info["size"], info["buttons"][:3])
    agent.click(window, text="选择")              # 按按钮文字点击
    agent.keys(window, "41")                     # 送入字符（不需要键盘焦点）
    print(agent.windows())
```

隔离工作区同样可用：

```python
with VirtualWorkspace("lab") as space, space.agent() as agent:
    window = agent.launch(["charmap.exe"])
    print(agent.info(window))
```

| 方法 | 作用 |
|---|---|
| `agent.launch([...])` | 在桌面内启动程序并等它的窗口 |
| `agent.adopt(pid)` | 接上桌面内已在运行的程序 |
| `agent.info(window)` | 标题、类名、矩形、`child_count`、`buttons` |
| `agent.text(window)` | 窗口标题 |
| `agent.uia_tree(window)` | 完整 UIA 树（在桌面内遍历） |
| `agent.click(window, text=/index=)` | 点击控件（走窗口消息，见下） |
| `agent.keys(window, "...")` | 送入字符（走窗口消息） |
| `agent.windows()` | 该桌面上的窗口清单 |
| `agent.exec("python 代码")` | 在桌面内跑任意 Python，返回打印内容 |

`exec` 是逃生舱：里面的代码能 `import VirtualDesktop`、能用 `list_windows()`、`CaptureEngine`，
也能用你自己装的任何库。代理本身只依赖 ctypes，不需要额外安装。

### 私有桌面不能截图

两种截图都不行，且原因不同：

| 类型 | 结果 | 原因 |
|---|---|---|
| 程序窗口截图 | **不能** | 该桌面的窗口从不被系统合成，没有像素可读 |
| 桌面截图 | **不能** | 拿不到该桌面的屏幕 DC（`BitBlt` 返回拒绝访问） |

`agent.screenshot()` 会**明确报错**（`code="capture-needs-shared-workspace"`）而不是产出空白图。
要读状态用 `agent.info()` / `agent.text()`；要像素请改用虚拟显示器。

## 能力边界

私有桌面的窗口**不在交互桌面上**，所以按输入机制分，能做的事如下：

| 想做的事 | 私有桌面 | 原因 |
|---|---|---|
| 找到窗口、读标题 / 类名 / 矩形 | 能 | 读窗口属性，跨桌面可用 |
| 枚举子控件、读控件文字 | 能 | 同上 |
| 用外部进程的 pywinauto 连上并读属性 | **能** | 拿得到句柄就能读 |
| 点击控件（`BM_CLICK` 等窗口消息） | **能** | 消息直接投递给窗口，不需要指针 |
| 送入字符（`WM_CHAR` 消息） | **能** | 同上 |
| **程序窗口截图** | **不能** | 桌面从不被系统合成，窗口没有像素 |
| **桌面截图** | **不能** | 拿不到该桌面的屏幕 DC（`BitBlt` 返回拒绝访问） |
| 移动真实鼠标来点击 | **不能** | 该桌面不是活动桌面，没有指针 |
| 滚轮（真实滚轮事件） | **不能** | 同"移动真实鼠标" |
| 发送真实键盘输入（`SendInput`） | **不能** | 键盘只发给活动桌面 |
| `pywinauto` 的 `click_input()` / `type_keys()` | **不能** | 依赖真实输入与前台焦点 |

要点：**能用消息驱动的都能用，需要真实输入设备的都不能用，两种截图都不能用**。需要截图或需要
真实鼠标/滚轮的应用，请改用下面的虚拟显示器方案。

## 支持的能力与调用方式

| 能力 | 是否支持 | 怎么调 |
|---|---|---|
| 在一个桌面里跑多个程序 | **支持** | 连续 `agent.launch([...])`，各自拿到不同句柄 |
| 多个独立桌面同时在线 | **支持** | 多个 `VirtualWorkspace("名字")` 并存，句柄与名字互不相同 |
| 拿到窗口句柄 | **支持** | `agent.launch([...]).hwnd` / `agent.adopt(pid).hwnd` / `desktop.find_window(pid)` |
| 读标题 / 类名 / 矩形 / 尺寸 | **支持** | `agent.info(window)` |
| 枚举子控件与按钮文字 | **支持** | `agent.info(window)["child_count"]` / `["buttons"]` |
| 完整 UIA 树 | **支持** | `agent.uia_tree(window)` |
| 点击控件 / 送入字符 | **支持** | `agent.click(window, text=...)` / `agent.keys(window, "...")` |
| 在桌面内跑任意 Python | **支持** | `agent.exec("...")`（可用 comtypes、pywinauto） |
| 程序窗口截图 / 桌面截图 | **不支持** | 见上方原因；改用虚拟显示器 |
| 跨桌面搬运窗口、移到主屏 | **不支持** | `move_to` 一律报 `cannot-cross-desktop`；只能在目标桌面重新 `launch` |

`agent.uia_tree()` 在**桌面内**遍历 UIA，这是私有桌面唯一可用的取树方式：

```python
with VirtualWorkspace("lab") as space, space.agent() as agent:
    window = agent.launch(["charmap.exe"])
    tree = agent.uia_tree(window)          # view="RawView" 默认；也可 ControlView / ContentView
    print(tree["count"])
    for node in tree["nodes"][:5]:
        print(node["control"], node["name"], node["enabled"])
```

> 不要用 `GetRootElement()`：它属于**输入桌面**，在私有桌面里走它拿不到任何目标窗口。
> 库内部用的是 `ElementFromHandle(hwnd)`。

> 在里面用 comtypes / pywinauto 是安全的：窗口枚举已不再绑定调用者线程，所以
> `from pywinauto import Application` 放在文件顶部也不会影响私有桌面。

---

# 第二部分：虚拟显示器

装一个 IddCx 间接显示驱动，系统就多出一块屏幕（`\\.\DISPLAYn`）。把窗口搬到那块屏上：
**窗口仍是普通窗口，仍留在你的交互桌面上**，只是画在你看不到的地方。因此外部程序能正常找到并
操作它，也能正常截图。

## 环境要求

| 项目 | 要求 |
|---|---|
| 系统 | **Windows 10 1809 (build 17763) 或更高**（更低版本没有 IddCx 框架） |
| 权限 | 安装/卸载驱动需要管理员，弹**一次 UAC** |
| 驱动 | 首次使用时自动下载安装（也可预先装好） |
| 依赖 | 仅 Pillow |

## 装驱动

```bash
VirtualDesktop install-driver --dry-run   # 先看将要执行的特权命令，不安装
VirtualDesktop install-driver             # 安装（弹一次 UAC）
```

或在代码里让它按需装：`VirtualWorkspace(..., install_driver=True)`（默认）。

默认**不**自动卸载：否则下一次使用又要弹一次 UAC。只在"用完即还机器"的场景开启：

```python
with VirtualWorkspace(VirtualWorkspace.SHARED, uninstall_on_exit=True) as space:
    ...
print(space.uninstall_report)     # {"ok": True, "message": "device node removed"}
```

## 两种隐藏方式

| `SessionConfig(hide_mode=...)` | 做法 | 需要驱动 |
|---|---|---|
| `virtual_screen`（默认） | 移到虚拟显示器上 | 是 |
| `offscreen` | 移出所有显示器的坐标区 | 否 |

> **`offscreen` 不需要驱动，但代价是窗口在所有显示器之外**，真实鼠标到不了那里（指针被钳制回
> 屏幕边缘），因此真实鼠标点击、滚轮、真实键盘输入都不可用；截图和消息驱动仍然可用。
> 需要真实输入或 `pyautogui`，请用 `virtual_screen`。

## 基本用法

```python
from VirtualDesktop import VirtualWorkspace

# 共享模式：窗口可被外部自动化库直接控制
with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
    space.launch(["charmap.exe"])
    space.launch(["charmap.exe"])          # 运行中可随时继续新增
    print(space.describe()["count"])       # 2
```

`desktop` 是**必填参数**，因为它决定窗口能不能被外部进程控制，这不该由库替你猜：

```python
VirtualWorkspace(VirtualWorkspace.SHARED)    # 共享：窗口留在交互桌面，外部可控
# VirtualWorkspace()                        # TypeError：不猜
# VirtualWorkspace(None)                    # ConfigError：并提示该传什么
```

## 单窗口会话

只处理一个窗口时最省事：

```python
from VirtualDesktop import OffscreenSession

with OffscreenSession(target_window_title="微信") as session:
    session.mount()                 # 按需装驱动 + 隐藏窗口
    frame = session.capture()
    frame.image.save("wechat.png")
# 退出时窗口回到原位，任务栏样式恢复
```

也可用 `window_class=` / `process_name=` / `window_pid=` 定位。`unmount()` 幂等；异常退出同样会还原。

## 指定窗口放到哪块屏

```python
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="virtual")           # 第一块虚拟显示器（默认）
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="\\\\.\\DISPLAY2")     # 指定设备
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="primary")           # 主屏（不装驱动也能跑）
```

## 截图

要分清两种，它们走**完全不同的通路**，能力也不同：

| 类型 | 拍什么 | 走哪条路 | 怎么调 |
|---|---|---|---|
| **程序窗口截图** | 某一个窗口自己的像素 | `PrintWindow(hwnd)` | `CaptureEngine.capture(hwnd)` / `space.capture(hwnd)` |
| **桌面截图** | 整块屏幕（含其上所有窗口） | `ImageGrab` / `BitBlt` 屏幕 DC | `capture_all_screens()` / `capture_monitor()` / `space.capture_desktop()` |

### 程序窗口截图

按句柄拍，窗口在屏幕外也能拍，是抓单个程序界面最可靠的方式。`capture()` 依次尝试多种后端直到
拿到**有内容**的帧，默认顺序：

`printwindow`（`PW_RENDERFULLCONTENT`）→ `printwindow_plain` → `wm_print` → `bitblt`

每帧都做统计判定：方差接近 0（未绘制/全黑/全白），或单色占比 ≥ 99.9% 且无结构，即判为失败并换
下一个后端。所以"偶尔拿到黑图"会变成确定性的回退，而不是静默产出坏图。

```python
from VirtualDesktop import CaptureEngine

engine = CaptureEngine(methods=("bitblt",), client_only=False)
frame = engine.capture(hwnd, require_content=True)
print(frame.method, frame.size)
```

```python
with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
    entry = space.launch(["charmap.exe"])
    space.capture(entry.hwnd, save_to="window.png")
```

### 桌面截图

拍的是**屏幕**，不是某个窗口。`virtual_screen_rect()` 是整张虚拟桌面（含所有显示器，处理好负坐标），
`capture_monitor()` 是单块显示器。在虚拟显示器模式下，`space.capture_desktop()` 等价于
`capture_monitor(space.monitor)`。

```python
from VirtualDesktop import CaptureEngine
from VirtualDesktop.desktop_capture import capture_all_screens, capture_monitor

CaptureEngine().ensure_dpi_aware()      # 拍摄前必须做，见下方警告

whole = capture_all_screens()           # 整张虚拟桌面
whole.image.save("desktop.png")
print(whole.rect, whole.size, whole.method)

one = capture_monitor(device="\\\\.\\DISPLAY2")   # 只拍某一块屏
```

> **拍摄前先确保 DPI 感知。** 矩形由 `GetSystemMetrics` / `EnumDisplayMonitors` 计算，它们对
> **不感知 DPI** 的进程返回的是**缩放后的逻辑值**；而像素抓取返回的是**物理像素**。两者不一致时
> 会**静默裁掉**画面右下角（125% 缩放下 1920×1080 的屏只拿到 1536×864，少 384×216 像素，且不报错）。
> 先调一次 `CaptureEngine().ensure_dpi_aware()` 即可，`VirtualWorkspace` 内部已自动处理。

> **桌面截图只有虚拟显示器模式可用。** offscreen 模式下窗口被移到所有显示器**之外**，桌面截图按
> 定义拍不到它们；而私有桌面根本没有可拍的屏幕 DC。

## 用自动化库控制窗口

窗口是普通窗口，直接从你的进程用即可：

```python
from VirtualDesktop import SessionConfig, VirtualWorkspace
from pywinauto import Application

config = SessionConfig(hide_mode="virtual_screen", keep_window_size=True)

with VirtualWorkspace(VirtualWorkspace.SHARED, config=config) as space:
    first = space.launch(["charmap.exe"], timeout=45.0)

    dialog = Application(backend="win32").connect(process=first.pid).top_window()
    print(dialog.window_text(), dialog.rectangle(), len(dialog.children()))

    space.capture(first.hwnd, save_to="window.png")
    print(space.describe())                      # count / externally_controllable ...
```

## 把窗口移到别的屏幕

```python
with VirtualWorkspace(VirtualWorkspace.SHARED) as src:
    w = src.launch(["charmap.exe"])

    src.move_to_primary()                  # 还给用户：还原到主屏、恢复任务栏
    src.move_to("\\\\.\\DISPLAY2")           # 移到指定的扩展屏 / 虚拟屏
    src.move_all_to("primary")             # 全部移走
    src.move_to_primary(windows=[w.hwnd])  # 只移指定窗口（也接受标题）
```

## 虚拟显示器大小与 DPI

默认与当前主屏一致：

```bash
VirtualDesktop capture "微信" --virtual-size 1920x1080 --virtual-dpi 125%
```

```python
from VirtualDesktop import configure_virtual_display, current_primary_size

configure_virtual_display(size="1920x1080", dpi="125%")   # 传 None 表示跟随主屏
print(current_primary_size())                            # (1920, 1080)
```

## 能力边界

| 想做的事 | 虚拟显示器 | offscreen |
|---|---|---|
| 找到窗口、读标题 / 类名 / 矩形 | 能 | 能 |
| 枚举子控件、读控件文字 | 能 | 能 |
| 点击控件（窗口消息） | 能 | 能 |
| 送入字符（窗口消息） | 能 | 能 |
| **程序窗口截图** | **能** | **能** |
| **桌面截图** | **能**（拍到窗口） | **不能**（窗口在所有显示器之外） |
| 外部 pywinauto / UIA 直接连上 | 能 | 能 |
| 移动真实鼠标来点击 | 能 | **不能**（窗口在屏幕之外） |
| 滚轮 | 能 | **不能** |
| 真实键盘（`SendInput`） | 能 | **不能** |

> 用真实输入时，窗口所在显示器必须是活动桌面的一部分；`pyautogui` 这类库还需要窗口能取得前台
> 焦点。若目标窗口拿不到焦点，请改用消息驱动（`agent.click()` / `WM_CHAR`）。

## 支持的能力与调用方式

| 能力 | 是否支持 | 怎么调 |
|---|---|---|
| 在一个工作区里跑多个程序 | **支持** | 连续 `ws.launch([...])`，运行中可随时新增 |
| 多个工作区同时在线 | **支持** | 多个 `VirtualWorkspace(SHARED)` 并存，各自独立停靠位 |
| 拿到窗口句柄给外部用 | **支持** | `ws.describe()["windows"]`（真实 HWND，无包装） |
| 读标题 / 类名 / 矩形 | **支持** | 外部 pywinauto 直接读，或 `ws.windows()` |
| 枚举子控件 | **支持** | 外部 pywinauto `children(visible_only=False)` |
| 完整 UIA 树 | **支持** | 外部 UIA，或 `--ui-tree DIR` |
| 程序窗口截图 | **支持** | `ws.capture(hwnd, save_to=...)` / `CaptureEngine.capture(hwnd)` |
| 桌面截图 | **支持**（`virtual_screen`） | `ws.capture_desktop()` / `capture_monitor()` / `capture_all_screens()` |
| 真实鼠标 / 滚轮 / 键盘 | **支持**（`virtual_screen`） | pywinauto `click_input()` / `type_keys()`，或 pyautogui |
| 在显示器之间移动窗口 | **支持** | `move_to("\\\\.\\DISPLAY2")` / `move_all_to(...)` / `move_to_primary()` |
| 退出时还原窗口 | **支持** | `with` 退出自动还原；异常退出同样还原 |
| 退出时卸载驱动 | **支持** | `uninstall_on_exit=True` 或 `close(uninstall=True)` |
| 桌面截图（`offscreen` 模式） | **不支持** | 窗口在所有显示器之外，拍不到；改用 `virtual_screen` |
| 真实鼠标 / 滚轮 / 键盘（`offscreen`） | **不支持** | 同上：指针被钳制回屏幕边缘 |
| 跨私有桌面搬运窗口 | **不支持** | Windows 不允许；`move_to` 会明确报错 |

---

# 通用功能

## 命令行

```bash
VirtualDesktop check                      # 只读体检：系统版本 / 显示器 / 驱动 / 缓存 / 签名
VirtualDesktop list-windows               # 列出可选窗口
VirtualDesktop capture "微信" --out wx.png --unmount
VirtualDesktop install-driver --dry-run
VirtualDesktop install-driver
VirtualDesktop uninstall-driver --purge-files
```

`capture` / `mount` 还支持 `--hide-mode`、`--monitor`、`--methods`、`--virtual-size`、
`--virtual-dpi`、`--ui-tree`、`--desktop-shot`，用 `--help` 查看。

## 配置项

环境变量形式，便于计划任务与 CI：

```
VIRTUALDESKTOP_DRIVER_POLICY=auto|never|always    VIRTUALDESKTOP_MONITOR=auto|virtual|primary|DISPLAY2
VIRTUALDESKTOP_HIDE_MODE=virtual_screen|offscreen|auto
VIRTUALDESKTOP_GITHUB_MIRRORS=<url 前缀,逗号分隔>  VIRTUALDESKTOP_CACHE_DIR=<path>
VIRTUALDESKTOP_MANIFEST=<path to manifest.json>   VIRTUALDESKTOP_SIGNATURE_POLICY=warn|strict|off
VIRTUALDESKTOP_CAPTURE_METHODS=printwindow,bitblt VIRTUALDESKTOP_DEBUG=1
VIRTUALDESKTOP_LOG_FILE=<path>
```

## 故障排查

| 现象 | 原因 | 处理 |
|---|---|---|
| `TypeError` 缺 `desktop` | `VirtualWorkspace` 必须显式选择模式 | 传工作区名或 `VirtualWorkspace.SHARED` |
| `DriverNotFoundError` | 未装虚拟显示器且不允许安装 | 去掉限制，或先跑 `VirtualDesktop install-driver` |
| `ElevationDenied` | UAC 被取消或被策略阻止 | 在管理员终端执行一次 `VirtualDesktop install-driver` |
| `unsupported-windows-build` | 系统低于 Win10 1809，没有 IddCx | 换系统，或用私有桌面（不需要驱动） |
| `capture-needs-shared-workspace` | 想截私有桌面里的窗口 | 私有桌面不支持截图，改用虚拟显示器 |
| `SetThreadDesktop ... error 170` | 你自己在绑定线程，而该线程已有窗口或已初始化 COM | 直接调用 `bind_thread()` 时才会遇到；库内部的枚举不再绑定调用者线程 |
| 某程序在私有桌面上找不到窗口 | 控制台类程序（`cmd.exe`、`ping.exe`）的窗口属于 `conhost.exe`，不在自己 pid 下 | 报错会提示；用 `agent.exec()` 在桌面内驱动它，或换 GUI 程序 |
| 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用；`report.requires_reboot` 为真时重启 |
| 驱动下载失败或极慢 | github.com 不可达 | 设 `VIRTUALDESKTOP_GITHUB_MIRRORS`，或预置离线缓存 |
| 窗口截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制；换 `bitblt` 试试，或确认窗口有内容 |
| 桌面截图少了右下角 | 进程不感知 DPI，矩形按逻辑坐标算 | 先调 `CaptureEngine().ensure_dpi_aware()` |
| 桌面截图拍不到工作区窗口 | 用了 `offscreen`，窗口在所有显示器之外 | 改用 `hide_mode="virtual_screen"` |
| `capture_desktop()` 报"workspace is not open" | offscreen 模式下没有绑定显示器 | 改用 `capture_all_screens()`，或用 `virtual_screen` |
| 真实鼠标点击无效 | 窗口在屏幕之外，或拿不到前台焦点 | 用 `virtual_screen`，或改用消息驱动 |

**离线 / 内网部署**：把安装包按清单里的文件名（`VirtualDesktop/driver_manifest.json` 的
`artifact.filename`）放进 `VIRTUALDESKTOP_CACHE_DIR` 即可，不会联网；也可用
`VIRTUALDESKTOP_MANIFEST` 指向自建镜像的清单（含自算摘要）。

## 已知限制

- **私有桌面不能截图**：程序窗口截图和桌面截图都不行。窗口没有被系统合成、没有像素可读；
  而该桌面的屏幕 DC 也拿不到（`BitBlt` 返回拒绝访问）。用 `agent.info()` / `agent.text()` 读状态，
  需要像素请改用虚拟显示器。
- **私有桌面不能用真实鼠标 / 滚轮 / 真实键盘**：那里没有活动指针和键盘焦点；消息驱动可用。
- **窗口无法在私有桌面之间搬运**：Windows 不允许跨桌面移动窗口。
- 部分应用（UWP、硬件叠加层、DRM 受保护内容）任何离屏方式都抓不到——系统限制。
- 自绘界面的应用（如微信 3.9.x）没有子窗口，UI Automation 只暴露顶层 `Pane`，组件粒度只能到
  窗口级；控件级定位需要靠截图像素。Electron/浏览器类应用的 UIA 树是完整的。
- ARM64 自动选用清单里的 ARM64 驱动变体；x64 / x86 共用同一份驱动包。

---

# 安全说明

## 驱动来源与校验

虚拟显示器驱动使用
[VirtualDrivers/Virtual-Display-Driver](https://github.com/VirtualDrivers/Virtual-Display-Driver)
（`MttVDD`，IddCx 间接显示驱动，SignPath Foundation 签名）。

驱动与工具二进制**不打进 wheel**，首次使用时按需下载。下载链路三道校验，任一不过即中止：

1. **SHA-256 摘要**：与随包发布的清单 `driver_manifest.json` 中固定的摘要逐字节比对。不匹配的
   文件会被隔离（`cache/quarantine`）而不是使用。
2. **Authenticode 验签**：比对签名者证书指纹与清单中固定的允许值。
3. **系统安装校验**：文件最后交给 Windows 安装，由系统再验一次驱动签名。

## 国内下载加速是安全的

`github.com` 在国内经常不可达，因此清单为每个安装包列了多个加速镜像，主 URL 失败时按顺序重试。
**加镜像不降低安全性**：镜像只影响"从哪里拿字节"，摘要校验照常执行——镜像要么给出完全相同的
文件，要么被第 1 道校验拒绝。它无法让不同的内容通过。

需要自建或更换镜像：

```bash
set VIRTUALDESKTOP_GITHUB_MIRRORS=https://gh-proxy.com/,https://internal.example/mirror/
```

同样只接受 `https://`，一样过摘要校验。

## 权限与提权

安装/卸载驱动需要管理员权限，会弹**一次 UAC**，由你确认。除此之外库以当前用户权限运行。

**完全不提权**的方式：私有桌面（`VirtualWorkspace("name")` 或 `VirtualDesktop`），以及
`hide_mode="offscreen"`。

## 桌面隔离

- **私有桌面**：`CreateDesktopW` 建的桌面只属于当前用户会话，默认 DACL 已限定创建者（和管理员）
  访问，其他用户无法打开。库另外会主动检测同名桌面并改用唯一名字，避免两个实例静默共享同一个
  命名空间。
- **虚拟显示器 / offscreen**：窗口留在交互桌面上，**任何以你的身份运行的程序都能找到并操作它们**。
  这既是它支持外部自动化的原因，也意味着它**不提供**针对同一台电脑上其他程序的隔离。需要隔离
  请用私有桌面。

## 运行期行为

- **不改动你的桌面**：窗口只被移动位置，退出（含异常退出）时还原到原位与原有样式。
- **不注入目标进程**：不写目标进程内存、不挂钩子。窗口操作走公开的 Win32 API，截图走
  `PrintWindow` 等系统接口。
- **网络访问仅限下载安装包**：没有遥测、没有上传，运行期不与任何服务器通信。
- **文件写入仅在缓存目录**：默认 `%LOCALAPPDATA%\VirtualDesktop\cache`，可用
  `VIRTUALDESKTOP_CACHE_DIR` 更改。

---

# 许可证

MIT，见 [LICENSE](LICENSE)。
