VirtualDesktop
在 Windows 上把窗口挂到虚拟显示器上截图:用户看不到、任务栏不留痕、退出自动还原。
一次 pip install,首次调用时自动完成虚拟显示器驱动安装。
from VirtualDesktop import OffscreenSession
with OffscreenSession(target_window_title="微信") as session:
session.mount() # 按需装驱动 + 隐藏窗口
frame = session.capture() # PrintWindow,自动回退到可用后端
frame.image.save("wechat.png") # PIL.Image
# 退出时窗口回到原位,任务栏样式恢复
适用于 UI 自动化、不在前台打扰用户的截图/OCR、需要在无人值守环境里抓取应用界面。
一、使用说明
安装
pip install VirtualDesktop
运行时依赖只有 Pillow。需要 Windows 10 1809 (build 17763) 或更高;更低版本没有
IddCx(间接显示驱动框架),无法承载虚拟显示器,VirtualDesktop check 会直接说明这一点。
快速开始
from VirtualDesktop import OffscreenSession
session = OffscreenSession(
window_class="WeChatLoginWndForPC", # 按类名定位(也可用标题/进程名/PID)
window_pid=1234,
)
session.mount() # 首次会弹一次 UAC 装驱动
print(session.hide_mode_used) # 实际用了哪种隐藏方式
frame = session.capture()
frame.image.save("shot.png")
session.unmount() # 幂等
用 with 语句或 try/finally 都行:异常时同样会还原窗口。
窗口定位支持四种方式,可组合使用:target_window_title、window_class、process_name、
window_pid。不确定时先用 VirtualDesktop list-windows 查。
命令行
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 查看全部。
两种隐藏方式
SessionConfig.hide_mode 三选一,默认 virtual_screen:
| 模式 | 做法 | 需要驱动 | 窗口在哪 |
|---|---|---|---|
virtual_screen(默认) |
移到虚拟显示器 | 是 | 虚拟屏上 |
offscreen |
移出所有显示器的坐标区 | 否 | 任何屏幕之外 |
auto |
先试前者,不可用则退到后者 | 否 | 视情况 |
from VirtualDesktop import OffscreenSession, SessionConfig
session = OffscreenSession(
target_window_title="微信",
config=SessionConfig(hide_mode="offscreen"),
)
session.mount(install_driver=False) # 完全不需要驱动
两种模式都会去掉任务栏按钮和 Alt+Tab 痕迹,并且都能在隐藏状态下截图;unmount() 会把
几何还原到挂载前的状态。
用
offscreen时窗口不在任何显示器上,所以桌面截图里看不到它——这是设计如此。 窗口自身的截图请用session.capture()。
虚拟显示器大小与 DPI
默认与当前主屏一致,也可以指定:
VirtualDesktop capture "微信" --virtual-size 1920x1080 --virtual-dpi 125%
from VirtualDesktop.display_setup import configure_virtual_display
result = configure_virtual_display(size="1920x1080", dpi="125%") # 传 None 表示跟随主屏
print(result.ok, result.effective_size, result.effective_dpi)
完全不安装驱动:私有桌面
VirtualDesktop 创建一个私有桌面,窗口在上面运行,零权限、零安装:
import json, tempfile
from pathlib import Path
from VirtualDesktop import VirtualDesktop
# 代理脚本在目标桌面内运行,把结果写到 --out 指定的文件
agent = Path(tempfile.mkdtemp()) / "agent.py"
agent.write_text(
"import argparse, json\n"
"from VirtualDesktop import CaptureEngine, list_windows, window_diagnostics\n"
"p = argparse.ArgumentParser(); p.add_argument('--out'); a = p.parse_args()\n"
"CaptureEngine().ensure_dpi_aware()\n"
"data = [window_diagnostics(r.hwnd) for r in list_windows() if r.title]\n"
"open(a.out, 'w', encoding='utf-8').write(json.dumps(data))\n",
encoding="utf-8",
)
with VirtualDesktop("my-workspace") as desktop:
process = desktop.launch(["charmap.exe"], show=False)
out = Path(tempfile.mkdtemp()) / "report.json"
windows = desktop.run_agent(agent, ["--out", str(out)])
# 退出时桌面关闭,其上进程一并结束
私有桌面是隔离边界:跨桌面的截图和 UI Automation 都不可用,所以枚举、截图、点击必须在目标
桌面内的进程里做。run_agent 就是为此准备的——它把你的脚本作为代理进程放进该桌面,再用
文件把结果传回来。
截图方式
session.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) # 实际生效的后端与尺寸
多个程序、多个工作区
一个工作区可以同时容纳 N 个程序,也可以同时开多个工作区。两种模式在"互不干扰"上的保证不同:
VirtualWorkspace() |
VirtualWorkspace(desktop="name") |
|
|---|---|---|
| 窗口在哪 | 虚拟显示器(或屏幕外) | 自己独立的私有桌面 |
| 实例之间 | 各自记录自己的窗口,停靠位置分槽 | 窗口命名空间完全隔离,互相看不见、抢不到焦点 |
| 外部自动化 | 直接可用(窗口是普通窗口) | 必须在该桌面内跑代理(run_agent) |
| 需要驱动 | virtual_screen 需要;offscreen 不需要 |
不需要 |
from VirtualDesktop import VirtualWorkspace
# 一个工作区放三个程序(可随时追加,不必一次性建好)
with VirtualWorkspace() 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(desktop="lab-a") as a, VirtualWorkspace(desktop="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()]) # 只看到自己的
desktop= 的名字不会撞车:如果该名字的桌面已存在(包括别的进程创建的),本实例会自动改用
带随机后缀的名字,保证隔离成立。CreateDesktopW 对同名桌面会复用同一个对象,若直接复用,
两个实例会共享窗口命名空间、互相可见——所以这里默认主动去重。确实需要共享时才用
VirtualDesktop("name", unique=False)。
指定窗口放到哪块屏
monitor= 决定工作区用哪块显示器,取值与 move_to 一致:
# 固定用第一块虚拟显示器;也可写 "\\.\DISPLAY2" 或序号 "2"
VirtualWorkspace(monitor="virtual")
VirtualWorkspace(monitor="\\\\.\\DISPLAY2")
VirtualWorkspace(monitor="primary") # 主屏(不装驱动也能跑,用于验证)
把窗口移到别的桌面
with VirtualWorkspace() as src, VirtualWorkspace(desktop="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 退出时会自动还原窗口、结束本工作区启动的程序、关闭私有桌面。若还要把虚拟显示器驱动也卸掉:
with VirtualWorkspace(uninstall_on_exit=True) as space: # 退出时自动卸载(两种模式都生效)
...
print(space.uninstall_report) # {"ok": True, "message": "device node removed"}
卸载需要管理员授权。默认不卸载:否则下一次
with又要弹一次 UAC。只在"用完即还机器" 的场景开启。
用自动化库控制工作区
模式一:共享工作区 —— 外部进程直接控制
窗口被移动虚拟显示器(或移出屏幕),但仍是交互桌面上的普通窗口,句柄是真的。所以 pywinauto / pyautogui / UIAutomation 直接从你的进程用即可,不需要任何额外机制。
from VirtualDesktop import SessionConfig, VirtualWorkspace
from pywinauto import Application
# offscreen 不需要驱动;换成 hide_mode="virtual_screen" 即移到虚拟显示器,自动化代码不变
config = SessionConfig(hide_mode="offscreen", keep_window_size=True)
with VirtualWorkspace(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": 2, "externally_controllable": True, ...}
模式二:隔离工作区 —— 在桌面内跑代理
私有桌面是隔离边界:外部进程拿不到它的窗口(UIA 报 ElementNotAvailable,PrintWindow
拿不到像素),所以自动化脚本必须在该桌面内执行。库提供 run_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()
hwnd = int(a.hwnd, 16)
wrapper = HwndWrapper(hwnd) # 直接从句柄构造,绕开高层 API 的可见性检查
rect = wrapper.rectangle()
children = wrapper.children(visible_only=False) # 私有桌面上窗口不带 WS_VISIBLE,必须关掉过滤
buttons = [c for c in children if c.friendlyclassname == "Button"]
u = ctypes.WinDLL("user32")
u.SendMessageW(w.HWND(buttons[0].handle), 0x00F5, 0, 0) # BM_CLICK:消息式点击
json.dump({"title": wrapper.window_text(), "rect": [rect.left, rect.top, rect.right, rect.bottom],
"children": len(children), "buttons": [b.window_text() for b in buttons[:5]]},
open(a.out, "w", encoding="utf-8"), ensure_ascii=False)
调用方:
from VirtualDesktop import VirtualWorkspace
with VirtualWorkspace(desktop="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)])
print(report["window"]["buttons"])
私有桌面内自动化库的可用范围
| 操作 | 是否可用 | 说明 |
|---|---|---|
HwndWrapper(hwnd)、window_text()、friendlyclassname、rectangle() |
可用 | 读取窗口属性 |
children() |
可用,需 visible_only=False |
私有桌面的窗口不带 WS_VISIBLE |
is_visible() |
返回 False |
桌面未被显示 |
click() / click_input() |
不可用 | 前者做可见性前置检查,后者需要活动桌面来移动指针 |
| 点击控件 | 发 BM_CLICK 消息 |
不需要可见窗口,也不需要活动桌面 |
需要 pyautogui 这类依赖真实鼠标/键盘的库时,只能用模式一(共享工作区)——它的窗口在 交互桌面上,有真实坐标。私有桌面上没有可移动的指针。
配置项
所有开关都可以用环境变量设置,便于计划任务和 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>
故障排查
| 现象 | 原因 | 处理 |
|---|---|---|
DriverNotFoundError |
未装虚拟显示器,且 install_driver=False 或 driver_policy=never |
去掉限制,或先跑 VirtualDesktop install-driver |
ElevationDenied |
UAC 被取消或被策略阻止 | 在管理员终端执行一次 VirtualDesktop install-driver |
unsupported-windows-build |
系统低于 Win10 1809,没有 IddCx | 换系统,或改用 VirtualDesktop / hide_mode="offscreen" |
| 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用该设备;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 道校验拒绝、随后被隔离。它无法让不同的内容通过。
镜像返回的文件大小与摘要都会核对(例如驱动包固定为 132118 字节 /
e24210692b442b39af763536330ce78b423f19342b7a7792c26de3944e418b3a)。
需要自建或更换镜像时:
# 完整 URL 或 URL 前缀(前缀以 / 或 ? 结尾,会自动拼上原始 GitHub 链接),逗号分隔
set VIRTUALDESKTOP_GITHUB_MIRRORS=https://gh-proxy.com/,https://internal.example/mirror/
同样只接受 https://,且一样要过摘要校验。
权限与提权
安装/卸载驱动需要管理员权限,会弹 一次 UAC,由你确认。除此之外,库本身以当前用户权限 运行,不需要管理员。
如果希望完全不提权,用私有桌面(VirtualDesktop)或 hide_mode="offscreen",
两者都不需要任何驱动。
运行期行为
- 不改动你的桌面:窗口只是被移动位置,退出(含异常退出)时还原到原位与原有样式。
- 不注入目标进程:不写目标进程内存、不挂钩子;窗口操作走公开的 Win32 窗口 API,
截图走
PrintWindow等系统接口。 - 网络访问仅限下载安装包:没有遥测、没有上传,运行期不与任何服务器通信。
- 文件写入仅在缓存目录:默认
%LOCALAPPDATA%\VirtualDesktop\cache,可用VIRTUALDESKTOP_CACHE_DIR更改。
已知限制
- 部分应用(UWP、硬件叠加层、DRM 受保护内容)任何离屏方式都抓不到——系统限制,非本库缺陷。
- 自绘界面的应用(如微信 3.9.x)没有子窗口,UI Automation 只暴露顶层
Pane,组件粒度只能到 窗口级;控件级定位需要靠截图像素。Electron/浏览器类应用的 UIA 树是完整的。 - ARM64 自动选用清单里的 ARM64 驱动变体;x64 / x86 共用同一份驱动包。
许可证
MIT,见 LICENSE。
Metadata
Release files for VirtualDesktop 1.1.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.1.1.tar.gz | 198.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| virtualdesktop-1.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 347.6 kB
Release files / virtualdesktop-1.1.1.tar.gz
| Download URL | virtualdesktop-1.1.1.tar.gz |
|---|---|
| Size | 198.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
47ca6b6a25c549d8f1d70e4528d76f25d9d44d57800d148b10167203a20b6c20
|
|
BLAKE2b-256 checksum How to use checksums |
59ab45dd99537595d748f13210b0bba6aa8a6354da0c4f1c8d417685e2ae3e75
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.23
|
Release files / virtualdesktop-1.1.1-py3-none-any.whl
| Download URL | virtualdesktop-1.1.1-py3-none-any.whl |
|---|---|
| Size | 148.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b1c410b52ab7055bab12ec1cfa973e24ef8d8106aee43fcbdd0a7115ee0a702e
|
|
BLAKE2b-256 checksum How to use checksums |
7a56485e839bae18d1e56e04000a073480e82d8049a0bc4ab59e0e3ddc4433ff
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.23
|