VirtualDesktop
在 Windows 上把程序的窗口放到你看不到的地方去运行并读取它:放到虚拟显示器、或放到独立的 私有桌面。窗口不会出现在你的屏幕上,退出时自动还原。
from VirtualDesktop import VirtualWorkspace
with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
space.launch(["notepad.exe"]) # 启动到"看不见的地方"
print(space.describe()["count"]) # 1
# 退出时窗口还原、进程结束
适用于:UI 自动化、不在前台打扰用户的截图/OCR、无人值守环境抓取应用界面、以及把多个程序 隔离开并行运行。
一、先理解两个概念
整个库只建立在 Windows 的两套机制上。三个类只是它们的不同封装,搞清这点其余都好懂。
| 机制 | 是什么 | 需要什么 |
|---|---|---|
| 留在交互桌面 | 窗口仍是普通窗口,只把位置挪到虚拟显示器上(\\.\DISPLAYn),或挪到所有显示器之外的坐标区 |
虚拟显示器要装一次驱动(1 次 UAC);挪出坐标区什么都不需要 |
| 私有桌面 | 用 CreateDesktopW 建的独立窗口命名空间。与你的桌面完全隔离 |
什么都不需要 |
关键区别只有一条:窗口是否还留在交互桌面上。由此决定两件事——外部进程能不能直接控制它、能不能截图。
| 模式 | 窗口在交互桌面 | 外部库能直接控制 | 能截图 | 需要驱动 |
|---|---|---|---|---|
VirtualWorkspace(SHARED) + virtual_screen |
是 | 能 | 能 | 是 |
VirtualWorkspace(SHARED) + offscreen |
是 | 能 | 能 | 否 |
OffscreenSession + virtual_screen |
是 | 能 | 能 | 是 |
OffscreenSession + offscreen |
是 | 能 | 能 | 否 |
VirtualWorkspace("名字") |
否 | 不能 | 不能 | 否 |
VirtualDesktop |
否 | 不能 | 不能 | 否 |
也就是说,两种隐藏方式(virtual_screen / offscreen)都能截图,只是把窗口分别放到"另一块屏"
和"屏幕之外的坐标区"。唯一截不到图的是私有桌面——那里的窗口没有被系统合成,没有任何后端能
读到像素(详见「已知限制」)。
三个类与机制的关系:
| 类 | 用途 | 底层机制 |
|---|---|---|
VirtualWorkspace(SHARED) |
多窗口工作区,外部可自动化 | 留在交互桌面 |
VirtualWorkspace("名字") |
多窗口工作区,强隔离 | 私有桌面 |
OffscreenSession |
单窗口:挂一个窗口、截图、还原 | 留在交互桌面 |
VirtualDesktop |
私有桌面原语(VirtualWorkspace("名字") 内部就用它) |
私有桌面 |
configure_virtual_display() |
设置虚拟显示器的分辨率 / DPI | (作用于显示器本身) |
该用哪个:
只处理一个窗口、要截图 → OffscreenSession
要同时跑多个程序、外部要控制、要截图 → VirtualWorkspace(SHARED)
要多实例强隔离、不需要截图 → VirtualWorkspace("名字")
要自己管理桌面、不需要工作区封装 → VirtualDesktop
二、使用说明
安装
pip install VirtualDesktop # pip
uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
运行时依赖只有 Pillow。需要 Windows 10 1809 (build 17763) 或更高(更低版本没有 IddCx 框架,装不了虚拟显示器)。不装驱动也能用私有桌面模式。
国内镜像(如清华源)同步新版本可能滞后;若
uv add/pip install找不到最新版,加--default-index https://pypi.org/simple(uv)或-i https://pypi.org/simple(pip)即可。
多窗口工作区
desktop 是必填参数,因为它决定窗口能不能被外部进程控制,这不该由库替你猜:
from VirtualDesktop import VirtualWorkspace
# 共享模式:窗口可被外部自动化库直接控制
with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
space.launch(["charmap.exe"])
space.launch(["charmap.exe"]) # 运行中可随时继续新增
print(space.describe()["count"]) # 2
print([w.hwnd_hex for w in space.windows()])
# 隔离模式:每个工作区一个独立桌面,互不干扰
with VirtualWorkspace("lab-a") as a, VirtualWorkspace("lab-b") as b:
a.launch(["charmap.exe"])
b.launch(["charmap.exe"])
print([w.hwnd_hex for w in a.windows()]) # 只看到自己的
print([w.hwnd_hex for w in b.windows()]) # 只看到自己的
VirtualWorkspace("my-lab") # 隔离:独立私有桌面
VirtualWorkspace(VirtualWorkspace.SHARED) # 共享:窗口留在交互桌面,外部可控
# VirtualWorkspace() # TypeError:不猜
# VirtualWorkspace(None) # ConfigError:并提示该传什么
工作区名字不会撞车:若该名字已存在(包括别的进程建的),本实例自动改用带随机后缀的名字。
CreateDesktopW 对同名桌面会复用同一对象,直接复用会让两个实例共享窗口命名空间——所以默认去重。
确实要共享同一个桌面时才用 VirtualDesktop("name", unique=False)。
指定窗口放到哪块屏
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="virtual") # 第一块虚拟显示器(默认)
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="\\\\.\\DISPLAY2") # 指定设备
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="primary") # 主屏(不装驱动也能跑)
把窗口移到别的桌面
with VirtualWorkspace(VirtualWorkspace.SHARED) as src, VirtualWorkspace("lab-b") as dst:
w = src.launch(["charmap.exe"])
src.move_to_primary() # 还给用户:还原到主屏、恢复任务栏
src.move_to(dst) # 交给另一个工作区
src.move_to("\\\\.\\DISPLAY2") # 移到指定的扩展屏 / 虚拟屏
src.move_all_to("primary") # 全部移走
src.move_to_primary(windows=[w.hwnd]) # 只移指定窗口(也接受标题)
交接后归属权一并转移:目标工作区负责后续还原,源工作区退出时不会再动它、也不会结束它的进程。
窗口无法在私有桌面之间搬运——Windows 不允许跨桌面移动窗口。这种模式请直接
launch到目标 工作区;move_to会明确报错说明原因。
退出时自动卸载驱动
with VirtualWorkspace(VirtualWorkspace.SHARED, uninstall_on_exit=True) as space:
...
print(space.uninstall_report) # {"ok": True, "message": "device node removed"}
默认不卸载:否则下一次 with 又要弹一次 UAC。只在"用完即还机器"的场景开启。
单窗口会话
只要处理一个窗口时用这个,最省事:
from VirtualDesktop import OffscreenSession, SessionConfig
with OffscreenSession(target_window_title="微信") as session:
session.mount() # 按需装驱动 + 隐藏窗口
frame = session.capture()
frame.image.save("wechat.png")
# 退出时窗口回到原位,任务栏样式恢复
也可用 window_class= / process_name= / window_pid= 定位。两种隐藏方式:
SessionConfig(hide_mode=...) |
做法 | 需要驱动 |
|---|---|---|
virtual_screen(默认) |
移到虚拟显示器 | 是 |
offscreen |
移出所有显示器的坐标区 | 否 |
auto |
先试前者,不可用则退到后者 | 否 |
unmount() 幂等;异常退出同样会还原。
私有桌面(零权限、零安装)
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)
# 退出时桌面关闭,其上进程一并结束
它上面运行的程序对交互桌面完全不可见。要在里面读写窗口、点击控件,用下面的代理。
在私有桌面里干活:agent() 会话
私有桌面是隔离边界,外部进程拿不到它的窗口,所以操作要在里面执行。agent() 提供一个
with 可用的常驻代理:
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.click(window, index=0) # 或按下标点击
print(agent.windows()) # 该桌面上的所有窗口
print(agent.exec("from VirtualDesktop import list_windows; print(len(list_windows()))"))
隔离工作区同样可用:
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) |
标题、类名、矩形、子控件数、按钮文字 |
agent.text(window) |
窗口标题 |
agent.click(window, text=/index=) |
点击按钮 |
agent.keys(window, "...") |
送入字符(不需要键盘焦点) |
agent.windows() |
该桌面上的窗口清单 |
agent.exec("python 代码") |
在桌面内跑任意 Python,返回打印内容 |
exec 是逃生舱:里面的代码能 import VirtualDesktop、能用 list_windows()、CaptureEngine,
也能用你自己装的任何库(包括 pywinauto)。代理本身只依赖 ctypes,不需要额外安装。
代理不能截图:私有桌面的窗口没有被系统合成,
agent.screenshot()会明确报错。需要截图就用VirtualWorkspace(SHARED)。
用自动化库控制窗口
共享工作区:外部进程直接控制
窗口是普通窗口,直接从你的进程用即可:
from VirtualDesktop import SessionConfig, VirtualWorkspace
from pywinauto import Application
config = SessionConfig(hide_mode="offscreen", keep_window_size=True) # offscreen 不需驱动
with VirtualWorkspace(VirtualWorkspace.SHARED, config=config, install_driver=False) as space:
first = space.launch(["charmap.exe"], timeout=45.0)
second = 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()))
buttons = [c for c in dialog.children() if c.friendlyclassname == "Button"]
buttons[0].click_input() # 真实点击
space.capture(first.hwnd, save_to="window.png")
print(space.describe()) # count / externally_controllable ...
把 hide_mode 换成 virtual_screen 就搬到虚拟显示器上,自动化代码不用改。
私有桌面:只能用代理
agent() 会话就是为它准备的(见上一节)。下面是不用 agent 封装的等价写法,便于理解协议:
# agent.py —— 在桌面内运行
import argparse, ctypes, ctypes.wintypes as w, json
from pywinauto.controls.hwndwrapper import HwndWrapper
p = argparse.ArgumentParser(); p.add_argument("--out"); p.add_argument("--hwnd"); a = p.parse_args()
wrapper = HwndWrapper(int(a.hwnd, 16)) # 直接从句柄构造,绕开高层 API 的可见性检查
rect = wrapper.rectangle()
children = wrapper.children(visible_only=False) # 私有桌面窗口不带 WS_VISIBLE
json.dump({"title": wrapper.window_text(), "rect": [rect.left, rect.top, rect.right, rect.bottom],
"children": len(children)},
open(a.out, "w", encoding="utf-8"), ensure_ascii=False)
with VirtualWorkspace("lab-a") as space:
entry = space.launch(["charmap.exe"], timeout=45.0)
report = space.run_agent("agent.py", ["--out", "report.json", "--hwnd", hex(entry.hwnd)])
若要用 pywinauto 之类,这套写法在私有桌面内的可用范围:
| 操作 | 是否可用 | 说明 |
|---|---|---|
| 构造包装器 / 读标题 / 读矩形 | 可用 | 只是读窗口属性 |
children() |
可用,需 visible_only=False |
私有桌面窗口不带 WS_VISIBLE |
is_visible() |
返回 False |
桌面未被显示 |
click() / click_input() |
不可用 | 前者做可见性前置检查,后者需要活动桌面移动光标 |
| 点击控件 | 发 BM_CLICK 消息 |
用 agent.click() 即可 |
pyautogui 这类依赖真实鼠标/键盘的库只能用于共享工作区——私有桌面上没有可移动的指针。
命令行
VirtualDesktop check # 只读体检:系统版本 / 显示器 / 驱动 / 缓存 / 签名
VirtualDesktop list-windows # 列出可选窗口
VirtualDesktop capture "微信" --out wx.png --unmount
VirtualDesktop install-driver --dry-run # 打印将要执行的特权命令,不安装
VirtualDesktop install-driver # 安装驱动(弹一次 UAC)
VirtualDesktop uninstall-driver --purge-files
capture / mount 还支持 --hide-mode、--monitor、--methods、--virtual-size、
--virtual-dpi、--ui-tree、--desktop-shot,用 --help 查看。
虚拟显示器大小与 DPI
默认与当前主屏一致:
VirtualDesktop capture "微信" --virtual-size 1920x1080 --virtual-dpi 125%
from VirtualDesktop import configure_virtual_display, current_primary_size
configure_virtual_display(size="1920x1080", dpi="125%") # 传 None 表示跟随主屏
print(current_primary_size()) # (1920, 1080)
截图方式
capture() 依次尝试多种后端直到拿到有内容的帧,默认顺序:
printwindow(PW_RENDERFULLCONTENT)→ printwindow_plain → wm_print → bitblt
每帧都做统计判定:方差接近 0(未绘制/全黑/全白),或单色占比 ≥ 99.9% 且无结构,即判为失败并换 下一个后端。所以"偶尔拿到黑图"会变成确定性的回退,而不是静默产出坏图。
from VirtualDesktop import CaptureEngine
engine = CaptureEngine(methods=("bitblt",), client_only=False)
frame = engine.capture(hwnd, require_content=True)
print(frame.method, frame.size)
配置项
环境变量形式,便于计划任务与 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 |
想截私有桌面里的窗口 | 改用 VirtualWorkspace(SHARED) |
cannot-cross-desktop |
想在私有桌面之间搬窗口 | 直接 launch 到目标工作区 |
| 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;report.requires_reboot 为真时重启 |
| 驱动下载失败或极慢 | github.com 不可达 | 设 VIRTUALDESKTOP_GITHUB_MIRRORS,或预置离线缓存 |
| 截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制;换 bitblt 试试,或确认窗口有内容 |
离线 / 内网部署:把安装包按清单里的文件名(VirtualDesktop/driver_manifest.json 的
artifact.filename)放进 VIRTUALDESKTOP_CACHE_DIR 即可,不会联网;也可用
VIRTUALDESKTOP_MANIFEST 指向自建镜像的清单(含自算摘要)。
三、安全说明
驱动来源与校验
虚拟显示器驱动使用
VirtualDrivers/Virtual-Display-Driver
(MttVDD,IddCx 间接显示驱动,SignPath Foundation 签名)。
驱动与工具二进制不打进 wheel,首次使用时按需下载。下载链路三道校验,任一不过即中止:
- SHA-256 摘要:与随包发布的清单
driver_manifest.json中固定的摘要逐字节比对。不匹配的 文件会被隔离(cache/quarantine)而不是使用。 - Authenticode 验签:比对签名者证书指纹与清单中固定的允许值。
- 系统安装校验:文件最后交给 Windows 安装,由系统再验一次驱动签名。
国内下载加速是安全的
github.com 在国内经常不可达,因此清单为每个安装包列了多个加速镜像,主 URL 失败时按顺序重试。
加镜像不降低安全性:镜像只影响"从哪里拿字节",摘要校验照常执行——镜像要么给出完全相同的
文件,要么被第 1 道校验拒绝。它无法让不同的内容通过。
需要自建或更换镜像:
set VIRTUALDESKTOP_GITHUB_MIRRORS=https://gh-proxy.com/,https://internal.example/mirror/
同样只接受 https://,一样过摘要校验。
权限与提权
安装/卸载驱动需要管理员权限,会弹一次 UAC,由你确认。除此之外库以当前用户权限运行。
完全不提权的两种方式:私有桌面(VirtualWorkspace("name") 或 VirtualDesktop),以及
hide_mode="offscreen"。两者都不需要任何驱动。
桌面隔离
CreateDesktopW 建的桌面只属于当前用户会话:默认 DACL 已限定创建者(和管理员)访问,
其他用户无法打开。库另外会主动检测同名桌面并改用唯一名字,避免两个实例静默共享同一个命名空间。
运行期行为
- 不改动你的桌面:窗口只被移动位置,退出(含异常退出)时还原到原位与原有样式。
- 不注入目标进程:不写目标进程内存、不挂钩子。窗口操作走公开的 Win32 API,截图走
PrintWindow等系统接口。 - 网络访问仅限下载安装包:没有遥测、没有上传,运行期不与任何服务器通信。
- 文件写入仅在缓存目录:默认
%LOCALAPPDATA%\VirtualDesktop\cache,可用VIRTUALDESKTOP_CACHE_DIR更改。
已知限制
- 私有桌面里的窗口无法截图:两种隐藏方式(
virtual_screen/offscreen)都支持截图,唯独 私有桌面不行——它的窗口没有被系统合成,没有任何后端能读到像素,agent.screenshot()会明确报错。 - 窗口无法在私有桌面之间搬运:Windows 不允许跨桌面移动窗口。
- 部分应用(UWP、硬件叠加层、DRM 受保护内容)任何离屏方式都抓不到——系统限制。
- 自绘界面的应用(如微信 3.9.x)没有子窗口,UI Automation 只暴露顶层
Pane,组件粒度只能到 窗口级;控件级定位需要靠截图像素。Electron/浏览器类应用的 UIA 树是完整的。 - ARM64 自动选用清单里的 ARM64 驱动变体;x64 / x86 共用同一份驱动包。
许可证
MIT,见 LICENSE。
Metadata
Release files for VirtualDesktop 1.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| virtualdesktop-1.2.1.tar.gz | 211.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| virtualdesktop-1.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 369.8 kB
Release files / virtualdesktop-1.2.1.tar.gz
| Download URL | virtualdesktop-1.2.1.tar.gz |
|---|---|
| Size | 211.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f7127f891ee79544e54fd5c4b44392e6e6e34ac49b56a64668a8bda4831832f9
|
|
BLAKE2b-256 checksum How to use checksums |
cbadd72528e554d9eca96c9e4a423da5d55e01dc29e243fc011bdff771662e02
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.23
|
Release files / virtualdesktop-1.2.1-py3-none-any.whl
| Download URL | virtualdesktop-1.2.1-py3-none-any.whl |
|---|---|
| Size | 158.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
14b92a23a90d465d7b7fd2e268fd7729ca1d163305deac94f4f1cd43b7efe24f
|
|
BLAKE2b-256 checksum How to use checksums |
973a37bd521e10161742b2ef4c8eec67dd89e16989b61b5e18dda20f2905fa40
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.23
|