VirtualDesktop
在 Windows 上把程序的窗口放到你看不到的地方去运行,并读取、操作它。窗口不出现在你的屏幕上, 退出时自动还原。
from VirtualDesktop import VirtualWorkspace
with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
space.launch(["charmap.exe"])
print(space.describe()["count"])
# 退出时窗口还原、进程结束
本库提供两套互不相通的方案。它们的隔离强度、所需权限、以及"外部代码能对里面的窗口做什么" 都不一样,请按需要选一套,不要混用。
安装
pip install VirtualDesktop # pip
uv add VirtualDesktop # uv(写入 pyproject.toml 并锁定)
运行时依赖只有 Pillow。若国内镜像同步滞后导致找不到最新版,加
--default-index https://pypi.org/simple(uv)或 -i https://pypi.org/simple(pip)。
| 私有桌面 | 虚拟显示器 | |
|---|---|---|
| 一句话 | 另开一个桌面,窗口与你的桌面完全隔离 | 多一块屏幕,窗口仍在你的桌面上、只是画在看不见的地方 |
| 隔离强度 | 强:独立窗口命名空间,外部进程看不到、碰不到 | 弱:窗口是普通窗口,任何程序都能找到并操作它 |
| 需要权限 | 零:不需要驱动、不弹 UAC | 装一次驱动,弹一次 UAC |
| 能截图吗 | 不能 | 能 |
| 外部自动化库能用吗 | 不能(要在桌面内跑代理) | 能(pywinauto / UIA / pyautogui 直接用) |
| 适合 | 多实例并行、强隔离、不要截图 | UI 自动化、截图 OCR、无人值守抓界面 |
只想跑一个窗口、要截图 → 用虚拟显示器侧的
OffscreenSession,最省事。 要多个实例互不干扰、不需要截图 → 用私有桌面。
第一部分:私有桌面
用 CreateDesktopW 建一个独立桌面。它有自己的窗口命名空间:你的桌面上看不到它的窗口,它上面的
程序也看不到你的窗口。不需要任何驱动、不需要管理员权限、不弹 UAC。
环境要求
| 项目 | 要求 |
|---|---|
| 系统 | Windows 10 / 11(无 IddCx 版本要求) |
| 权限 | 当前用户即可,不提权 |
| 驱动 | 不需要 |
| 依赖 | 仅 Pillow |
基本用法
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)
# 退出时桌面关闭,其上进程一并结束
工作区形式,一个桌面装多个窗口:
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 可用的常驻代理:
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())
隔离工作区同样可用:
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.click(window, text=/index=) |
点击控件(走窗口消息,见下) |
agent.keys(window, "...") |
送入字符(走窗口消息) |
agent.windows() |
该桌面上的窗口清单 |
agent.exec("python 代码") |
在桌面内跑任意 Python,返回打印内容 |
exec 是逃生舱:里面的代码能 import VirtualDesktop、能用 list_windows()、CaptureEngine,
也能用你自己装的任何库。代理本身只依赖 ctypes,不需要额外安装。
能力边界
私有桌面的窗口不在交互桌面上,所以按输入机制分,能做的事如下:
| 想做的事 | 私有桌面 | 原因 |
|---|---|---|
| 找到窗口、读标题 / 类名 / 矩形 | 能 | 读窗口属性,跨桌面可用 |
| 枚举子控件、读控件文字 | 能 | 同上 |
点击控件(BM_CLICK 等窗口消息) |
能 | 消息直接投递给窗口,不需要指针 |
送入字符(WM_CHAR 消息) |
能 | 同上 |
| 截图 | 不能 | 桌面从不被系统合成,没有像素可读 |
| 移动真实鼠标来点击 | 不能 | 该桌面不是活动桌面,没有指针 |
| 滚轮(真实滚轮事件) | 不能 | 同"移动真实鼠标" |
发送真实键盘输入(SendInput) |
不能 | 键盘只发给活动桌面 |
pywinauto 的 click_input() / type_keys() |
不能 | 依赖真实输入与前台焦点 |
| 用外部进程的 pywinauto 直接连上它 | 不能 | 跨桌面拿不到窗口 |
要点:能用消息驱动的都能用,需要真实输入设备的都不能用。需要截图或需要真实鼠标/滚轮的应用, 请改用下面的虚拟显示器方案。
第二部分:虚拟显示器
装一个 IddCx 间接显示驱动,系统就多出一块屏幕(\\.\DISPLAYn)。把窗口搬到那块屏上:
窗口仍是普通窗口,仍留在你的交互桌面上,只是画在你看不到的地方。因此外部程序能正常找到并
操作它,也能正常截图。
环境要求
| 项目 | 要求 |
|---|---|
| 系统 | Windows 10 1809 (build 17763) 或更高(更低版本没有 IddCx 框架) |
| 权限 | 安装/卸载驱动需要管理员,弹一次 UAC |
| 驱动 | 首次使用时自动下载安装(也可预先装好) |
| 依赖 | 仅 Pillow |
装驱动
VirtualDesktop install-driver --dry-run # 先看将要执行的特权命令,不安装
VirtualDesktop install-driver # 安装(弹一次 UAC)
或在代码里让它按需装:VirtualWorkspace(..., install_driver=True)(默认)。
默认不自动卸载:否则下一次使用又要弹一次 UAC。只在"用完即还机器"的场景开启:
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。
基本用法
from VirtualDesktop import VirtualWorkspace
# 共享模式:窗口可被外部自动化库直接控制
with VirtualWorkspace(VirtualWorkspace.SHARED) as space:
space.launch(["charmap.exe"])
space.launch(["charmap.exe"]) # 运行中可随时继续新增
print(space.describe()["count"]) # 2
desktop 是必填参数,因为它决定窗口能不能被外部进程控制,这不该由库替你猜:
VirtualWorkspace(VirtualWorkspace.SHARED) # 共享:窗口留在交互桌面,外部可控
# VirtualWorkspace() # TypeError:不猜
# VirtualWorkspace(None) # ConfigError:并提示该传什么
单窗口会话
只处理一个窗口时最省事:
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() 幂等;异常退出同样会还原。
指定窗口放到哪块屏
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="virtual") # 第一块虚拟显示器(默认)
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="\\\\.\\DISPLAY2") # 指定设备
VirtualWorkspace(VirtualWorkspace.SHARED, monitor="primary") # 主屏(不装驱动也能跑)
截图
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)
用自动化库控制窗口
窗口是普通窗口,直接从你的进程用即可:
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 ...
把窗口移到别的屏幕
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
默认与当前主屏一致:
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)
能力边界
| 想做的事 | 虚拟显示器 | offscreen |
|---|---|---|
| 找到窗口、读标题 / 类名 / 矩形 | 能 | 能 |
| 枚举子控件、读控件文字 | 能 | 能 |
| 点击控件(窗口消息) | 能 | 能 |
| 送入字符(窗口消息) | 能 | 能 |
| 截图 | 能 | 能 |
| 外部 pywinauto / UIA 直接连上 | 能 | 能 |
| 移动真实鼠标来点击 | 能 | 不能(窗口在屏幕之外) |
| 滚轮 | 能 | 不能 |
真实键盘(SendInput) |
能 | 不能 |
用真实输入时,窗口所在显示器必须是活动桌面的一部分;
pyautogui这类库还需要窗口能取得前台 焦点。若目标窗口拿不到焦点,请改用消息驱动(agent.click()/WM_CHAR)。
通用功能
命令行
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 |
当前线程已有窗口,无法再绑定桌面 | 在创建窗口之前 bind_thread(),或用 agent() |
| 安装后没出现新的虚拟屏 | 驱动已注册但显示器未启用 | 设备管理器启用;report.requires_reboot 为真时重启 |
| 驱动下载失败或极慢 | github.com 不可达 | 设 VIRTUALDESKTOP_GITHUB_MIRRORS,或预置离线缓存 |
| 截图是黑帧 | 应用为硬件加速 / 受保护内容 | 系统限制;换 bitblt 试试,或确认窗口有内容 |
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 virtual_screen,或改用消息驱动 |
| 某程序在私有桌面上找不到窗口 | 控制台类(cmd)等窗口不参与常规枚举 | 换程序,或用 agent.exec() 在桌面内直接操作 |
离线 / 内网部署:把安装包按清单里的文件名(VirtualDesktop/driver_manifest.json 的
artifact.filename)放进 VIRTUALDESKTOP_CACHE_DIR 即可,不会联网;也可用
VIRTUALDESKTOP_MANIFEST 指向自建镜像的清单(含自算摘要)。
已知限制
- 私有桌面不能截图:它的窗口没有被系统合成,没有任何后端能读到像素;
agent.screenshot()会明确报错。 - 私有桌面不能用真实鼠标 / 滚轮 / 真实键盘:那里没有活动指针和键盘焦点;消息驱动可用。
- 窗口无法在私有桌面之间搬运:Windows 不允许跨桌面移动窗口。
- 部分应用(UWP、硬件叠加层、DRM 受保护内容)任何离屏方式都抓不到——系统限制。
- 自绘界面的应用(如微信 3.9.x)没有子窗口,UI Automation 只暴露顶层
Pane,组件粒度只能到 窗口级;控件级定位需要靠截图像素。Electron/浏览器类应用的 UIA 树是完整的。 - ARM64 自动选用清单里的 ARM64 驱动变体;x64 / x86 共用同一份驱动包。
安全说明
驱动来源与校验
虚拟显示器驱动使用
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 已限定创建者(和管理员) 访问,其他用户无法打开。库另外会主动检测同名桌面并改用唯一名字,避免两个实例静默共享同一个 命名空间。 - 虚拟显示器 / offscreen:窗口留在交互桌面上,任何以你的身份运行的程序都能找到并操作它们。 这既是它支持外部自动化的原因,也意味着它不提供针对同一台电脑上其他程序的隔离。需要隔离 请用私有桌面。
运行期行为
- 不改动你的桌面:窗口只被移动位置,退出(含异常退出)时还原到原位与原有样式。
- 不注入目标进程:不写目标进程内存、不挂钩子。窗口操作走公开的 Win32 API,截图走
PrintWindow等系统接口。 - 网络访问仅限下载安装包:没有遥测、没有上传,运行期不与任何服务器通信。
- 文件写入仅在缓存目录:默认
%LOCALAPPDATA%\VirtualDesktop\cache,可用VIRTUALDESKTOP_CACHE_DIR更改。
许可证
MIT,见 LICENSE。
Metadata
Release files for VirtualDesktop 1.3.0
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.3.0.tar.gz | 210.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| virtualdesktop-1.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 368.6 kB
Release files / virtualdesktop-1.3.0.tar.gz
| Download URL | virtualdesktop-1.3.0.tar.gz |
|---|---|
| Size | 210.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3e94cae64a428985614f4e0b47ec1af0c770f07369039d3d7c0b3b693e24c9ee
|
|
BLAKE2b-256 checksum How to use checksums |
58e05799e5e853847edc8a1676e00ecbd0af8e9c6102aedcf59047f8ebb21b17
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.23
|
Release files / virtualdesktop-1.3.0-py3-none-any.whl
| Download URL | virtualdesktop-1.3.0-py3-none-any.whl |
|---|---|
| Size | 158.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
75462a7dfdfb49917734631a45e3c9e6b6bbef4e703040bb3c84d9a9156a5c5d
|
|
BLAKE2b-256 checksum How to use checksums |
4c84d37fcbd1518f0ed2609f74b3fe5d4290659bc152ca002bbbd7254123dec4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.8.23
|