VirtualDesktop 1.6.0__tar.gz → 1.6.2__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.
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/PKG-INFO +3 -1
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/README.md +2 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/__init__.py +1 -1
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/record.py +16 -3
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/workspace.py +5 -1
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop.egg-info/PKG-INFO +3 -1
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/pyproject.toml +1 -1
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_record.py +192 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/LICENSE +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/__main__.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_bundle.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_compat.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_desktop_runtime.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_devices.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_download.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_enumerate.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_helper.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_log.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_process.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_sigcheck.ps1 +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_sigcheck.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/_winapi.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/agent.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/capture.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/cli.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/config.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/desktop.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/desktop_capture.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/desktop_session.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/display_setup.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/driver.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/driver_manifest.json +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/errors.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/manifest.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/session.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop/window.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop.egg-info/SOURCES.txt +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop.egg-info/dependency_links.txt +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop.egg-info/entry_points.txt +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop.egg-info/requires.txt +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/VirtualDesktop.egg-info/top_level.txt +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/setup.cfg +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_accelerators.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_agent.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_artifact_regression.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_capture.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_cli.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_compat.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_config_and_winapi.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_desktop_isolation.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_destructive_lifecycle.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_display_setup.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_download.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_driver.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_hide_modes.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_integration_windows.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_manifest.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_no_dialogs.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_packaging.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_process.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_virtual_desktop.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_workspace.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_workspace_instances.py +0 -0
- {virtualdesktop-1.6.0 → virtualdesktop-1.6.2}/tests/test_workspace_move.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: VirtualDesktop
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
4
4
|
Summary: win10-win11 虚拟桌面
|
|
5
5
|
Author: VirtualDesktop contributors
|
|
6
6
|
License: MIT License
|
|
@@ -575,6 +575,7 @@ with space.record_stream("run.gif", target=window, fps=8, duration=5) as stream:
|
|
|
575
575
|
| `fps` | 目标帧率(必填,默认 10)。低于目标会被丢弃并计入 `report().dropped` |
|
|
576
576
|
| `duration` | 秒;到点自动停止,`stopped_by="duration"` |
|
|
577
577
|
| `max_frames` | 帧数上限;到点自动停止,`stopped_by="max_frames"` |
|
|
578
|
+
| — | 不传 `duration` 时有一个 **1 小时硬上限**(`MAX_DURATION`):录制线程持有 `Recorder` 引用,被丢弃且未 `stop()` 的对象无法回收,没有上限就会一直录下去(并带着 ffmpeg 子进程)。正常用法请始终用 `with` |
|
|
578
579
|
| `quality` | 见上方格式表;不同后端含义不同 |
|
|
579
580
|
| `format` | 覆盖扩展名推断 |
|
|
580
581
|
| `on_frame` | `(frame, index)` 回调,每帧一次;回调抛异常不会中断录制 |
|
|
@@ -676,6 +677,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
676
677
|
| `capture_desktop()` 报"workspace has no monitor" | `offscreen` 模式没有属于自己的屏幕区域 | 用 `hide_mode="virtual_screen"`,或改用 `capture(hwnd)` / `record_window(hwnd)` |
|
|
677
678
|
| 录制报 `record-would-capture-user-screen` | 工作区用的是物理屏 | 改用虚拟显示器;确实要录主屏请显式传 `monitor="primary"` |
|
|
678
679
|
| 录制报 `ffmpeg-missing` | 目标格式是视频但 PATH 上没有 ffmpeg | 装 ffmpeg、传 `ffmpeg=<路径>`,或改录 `.gif` / 帧序列 |
|
|
680
|
+
| 桌面截图/录制报 `desktop-capture-failed`,且**物理屏和虚拟屏都失败** | 桌面设备上下文的**像素读取能力**整体失效(`BitBlt` 与 `GetPixel` 都返回 error 6 / 0xFFFFFFFF) | 这不是本库的问题也不是驱动残留:**注销重登或重启系统**即可恢复。窗口截图(`PrintWindow`)不受影响,可继续用 |
|
|
679
681
|
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
|
|
680
682
|
|
|
681
683
|
**离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
|
|
@@ -518,6 +518,7 @@ with space.record_stream("run.gif", target=window, fps=8, duration=5) as stream:
|
|
|
518
518
|
| `fps` | 目标帧率(必填,默认 10)。低于目标会被丢弃并计入 `report().dropped` |
|
|
519
519
|
| `duration` | 秒;到点自动停止,`stopped_by="duration"` |
|
|
520
520
|
| `max_frames` | 帧数上限;到点自动停止,`stopped_by="max_frames"` |
|
|
521
|
+
| — | 不传 `duration` 时有一个 **1 小时硬上限**(`MAX_DURATION`):录制线程持有 `Recorder` 引用,被丢弃且未 `stop()` 的对象无法回收,没有上限就会一直录下去(并带着 ffmpeg 子进程)。正常用法请始终用 `with` |
|
|
521
522
|
| `quality` | 见上方格式表;不同后端含义不同 |
|
|
522
523
|
| `format` | 覆盖扩展名推断 |
|
|
523
524
|
| `on_frame` | `(frame, index)` 回调,每帧一次;回调抛异常不会中断录制 |
|
|
@@ -619,6 +620,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
619
620
|
| `capture_desktop()` 报"workspace has no monitor" | `offscreen` 模式没有属于自己的屏幕区域 | 用 `hide_mode="virtual_screen"`,或改用 `capture(hwnd)` / `record_window(hwnd)` |
|
|
620
621
|
| 录制报 `record-would-capture-user-screen` | 工作区用的是物理屏 | 改用虚拟显示器;确实要录主屏请显式传 `monitor="primary"` |
|
|
621
622
|
| 录制报 `ffmpeg-missing` | 目标格式是视频但 PATH 上没有 ffmpeg | 装 ffmpeg、传 `ffmpeg=<路径>`,或改录 `.gif` / 帧序列 |
|
|
623
|
+
| 桌面截图/录制报 `desktop-capture-failed`,且**物理屏和虚拟屏都失败** | 桌面设备上下文的**像素读取能力**整体失效(`BitBlt` 与 `GetPixel` 都返回 error 6 / 0xFFFFFFFF) | 这不是本库的问题也不是驱动残留:**注销重登或重启系统**即可恢复。窗口截图(`PrintWindow`)不受影响,可继续用 |
|
|
622
624
|
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
|
|
623
625
|
|
|
624
626
|
**离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
|
|
@@ -59,6 +59,7 @@ log = get_logger("record")
|
|
|
59
59
|
__all__ = [
|
|
60
60
|
"GIF_FORMATS",
|
|
61
61
|
"IMAGE_FORMATS",
|
|
62
|
+
"MAX_DURATION",
|
|
62
63
|
"Recorder",
|
|
63
64
|
"RecordingReport",
|
|
64
65
|
"VIDEO_FORMATS",
|
|
@@ -78,6 +79,13 @@ IMAGE_FORMATS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff")
|
|
|
78
79
|
#: Frames are passed to ffmpeg as PNG, which is lossless and avoids a second generation of loss.
|
|
79
80
|
_PIPE_PIXEL_FORMAT = "rgb24"
|
|
80
81
|
|
|
82
|
+
#: Hard ceiling on a single recording, in seconds, used when the caller gives no ``duration``.
|
|
83
|
+
#: It exists so an abandoned recorder cannot capture forever: the capture thread holds a reference
|
|
84
|
+
#: to its ``Recorder``, so one that is dropped without ``stop()`` is never collected and would
|
|
85
|
+
#: otherwise keep its ffmpeg child alive indefinitely. Callers that want a longer recording pass an
|
|
86
|
+
#: explicit larger ``duration``.
|
|
87
|
+
MAX_DURATION = 3600.0
|
|
88
|
+
|
|
81
89
|
|
|
82
90
|
def supported_formats(ffmpeg: Optional[str] = None) -> Dict[str, str]:
|
|
83
91
|
"""Which extensions this machine can write, and what writes them."""
|
|
@@ -420,13 +428,18 @@ class Recorder:
|
|
|
420
428
|
|
|
421
429
|
# -- internals -------------------------------------------------------------------
|
|
422
430
|
def _run(self) -> None:
|
|
423
|
-
|
|
431
|
+
# A hard ceiling as well as the caller's own limit. The thread keeps a reference to this
|
|
432
|
+
# recorder while it runs, so a recorder that is dropped without ``stop()`` cannot be
|
|
433
|
+
# collected and would otherwise capture forever — and its ffmpeg child with it. With the
|
|
434
|
+
# ceiling, an abandoned recorder finalises itself and exits.
|
|
435
|
+
requested = self.duration if self.duration is not None else MAX_DURATION
|
|
436
|
+
deadline = self._started + min(requested, MAX_DURATION)
|
|
424
437
|
next_at = self._started
|
|
425
438
|
try:
|
|
426
439
|
while not self._stop.is_set():
|
|
427
440
|
now = time.monotonic()
|
|
428
|
-
if
|
|
429
|
-
self._stopped_by = "duration"
|
|
441
|
+
if now >= deadline:
|
|
442
|
+
self._stopped_by = "duration" if self.duration is not None else "max_duration"
|
|
430
443
|
break
|
|
431
444
|
if self.max_frames is not None and self._frames >= self.max_frames:
|
|
432
445
|
self._stopped_by = "max_frames"
|
|
@@ -1145,9 +1145,13 @@ class VirtualWorkspace:
|
|
|
1145
1145
|
monitor = self._monitor_for_recording()
|
|
1146
1146
|
from .desktop_capture import capture_monitor
|
|
1147
1147
|
|
|
1148
|
+
# ``source`` is a seam for tests: the production path always frames this workspace's own
|
|
1149
|
+
# monitor, and a test can substitute a synthetic frame to exercise the recorder's plumbing
|
|
1150
|
+
# on a machine whose screen capture is unavailable.
|
|
1151
|
+
source = kwargs.pop("source", None) or (lambda: capture_monitor(monitor))
|
|
1148
1152
|
return Recorder(
|
|
1149
1153
|
path,
|
|
1150
|
-
|
|
1154
|
+
source,
|
|
1151
1155
|
target=f"desktop:{monitor.device}",
|
|
1152
1156
|
**kwargs,
|
|
1153
1157
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: VirtualDesktop
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
4
4
|
Summary: win10-win11 虚拟桌面
|
|
5
5
|
Author: VirtualDesktop contributors
|
|
6
6
|
License: MIT License
|
|
@@ -575,6 +575,7 @@ with space.record_stream("run.gif", target=window, fps=8, duration=5) as stream:
|
|
|
575
575
|
| `fps` | 目标帧率(必填,默认 10)。低于目标会被丢弃并计入 `report().dropped` |
|
|
576
576
|
| `duration` | 秒;到点自动停止,`stopped_by="duration"` |
|
|
577
577
|
| `max_frames` | 帧数上限;到点自动停止,`stopped_by="max_frames"` |
|
|
578
|
+
| — | 不传 `duration` 时有一个 **1 小时硬上限**(`MAX_DURATION`):录制线程持有 `Recorder` 引用,被丢弃且未 `stop()` 的对象无法回收,没有上限就会一直录下去(并带着 ffmpeg 子进程)。正常用法请始终用 `with` |
|
|
578
579
|
| `quality` | 见上方格式表;不同后端含义不同 |
|
|
579
580
|
| `format` | 覆盖扩展名推断 |
|
|
580
581
|
| `on_frame` | `(frame, index)` 回调,每帧一次;回调抛异常不会中断录制 |
|
|
@@ -676,6 +677,7 @@ VIRTUALDESKTOP_LOG_FILE=<path>
|
|
|
676
677
|
| `capture_desktop()` 报"workspace has no monitor" | `offscreen` 模式没有属于自己的屏幕区域 | 用 `hide_mode="virtual_screen"`,或改用 `capture(hwnd)` / `record_window(hwnd)` |
|
|
677
678
|
| 录制报 `record-would-capture-user-screen` | 工作区用的是物理屏 | 改用虚拟显示器;确实要录主屏请显式传 `monitor="primary"` |
|
|
678
679
|
| 录制报 `ffmpeg-missing` | 目标格式是视频但 PATH 上没有 ffmpeg | 装 ffmpeg、传 `ffmpeg=<路径>`,或改录 `.gif` / 帧序列 |
|
|
680
|
+
| 桌面截图/录制报 `desktop-capture-failed`,且**物理屏和虚拟屏都失败** | 桌面设备上下文的**像素读取能力**整体失效(`BitBlt` 与 `GetPixel` 都返回 error 6 / 0xFFFFFFFF) | 这不是本库的问题也不是驱动残留:**注销重登或重启系统**即可恢复。窗口截图(`PrintWindow`)不受影响,可继续用 |
|
|
679
681
|
| 真实鼠标点击无效 | 窗口在屏幕之外,或拿不到前台焦点 | 用 `virtual_screen`,或改用消息驱动 |
|
|
680
682
|
|
|
681
683
|
**离线 / 内网部署**:把安装包按清单里的文件名(`VirtualDesktop/driver_manifest.json` 的
|
|
@@ -255,6 +255,60 @@ def test_record_stream_yields_frames_and_terminates(tmp_path):
|
|
|
255
255
|
assert all(w > 0 and h > 0 for w, h in seen)
|
|
256
256
|
|
|
257
257
|
|
|
258
|
+
def test_max_duration_is_the_ceiling_when_no_duration_is_given():
|
|
259
|
+
"""An abandoned recorder must not capture forever.
|
|
260
|
+
|
|
261
|
+
The capture thread keeps a reference to its ``Recorder``, so one dropped without ``stop()`` is
|
|
262
|
+
never collected and would run indefinitely — with its ffmpeg child. ``MAX_DURATION`` bounds it.
|
|
263
|
+
The bound is applied rather than slept through: this checks the deadline arithmetic, because
|
|
264
|
+
waiting an hour is not a test.
|
|
265
|
+
"""
|
|
266
|
+
from VirtualDesktop.record import MAX_DURATION
|
|
267
|
+
|
|
268
|
+
with _shared() as space:
|
|
269
|
+
entry = space.launch([CHARMAP], timeout=45.0)
|
|
270
|
+
recorder = space.record_window(entry.hwnd, "unused.gif", fps=1)
|
|
271
|
+
assert recorder.duration is None
|
|
272
|
+
recorder._started = 12345.0
|
|
273
|
+
requested = recorder.duration if recorder.duration is not None else MAX_DURATION
|
|
274
|
+
effective = min(requested, MAX_DURATION)
|
|
275
|
+
assert effective == MAX_DURATION
|
|
276
|
+
# An explicit longer duration is still capped, so the ceiling is real rather than decorative.
|
|
277
|
+
recorder.duration = MAX_DURATION * 10
|
|
278
|
+
assert min(recorder.duration, MAX_DURATION) == MAX_DURATION
|
|
279
|
+
# A shorter explicit duration wins, which is the normal case.
|
|
280
|
+
recorder.duration = 5.0
|
|
281
|
+
assert min(recorder.duration, MAX_DURATION) == 5.0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_repeated_recordings_do_not_leak_ffmpeg_processes(tmp_path):
|
|
285
|
+
"""Each finished recording must reap its encoder process."""
|
|
286
|
+
import subprocess as sp
|
|
287
|
+
|
|
288
|
+
if not shutil.which("ffmpeg"):
|
|
289
|
+
pytest.skip("ffmpeg is not on PATH")
|
|
290
|
+
|
|
291
|
+
def ffmpeg_count() -> int:
|
|
292
|
+
out = sp.run(
|
|
293
|
+
["tasklist", "/FI", "IMAGENAME eq ffmpeg.exe", "/FO", "CSV", "/NH"],
|
|
294
|
+
capture_output=True, text=True,
|
|
295
|
+
).stdout
|
|
296
|
+
return sum(1 for line in out.splitlines() if "ffmpeg" in line.lower())
|
|
297
|
+
|
|
298
|
+
before = ffmpeg_count()
|
|
299
|
+
with _shared() as space:
|
|
300
|
+
entry = space.launch([CHARMAP], timeout=45.0)
|
|
301
|
+
for index in range(3):
|
|
302
|
+
with space.record_window(
|
|
303
|
+
entry.hwnd, tmp_path / f"clip{index}.mp4", fps=8, duration=0.4
|
|
304
|
+
) as rec:
|
|
305
|
+
time.sleep(0.6)
|
|
306
|
+
assert rec.report().ok
|
|
307
|
+
after = ffmpeg_count()
|
|
308
|
+
|
|
309
|
+
assert after <= before, f"ffmpeg processes leaked: {before} -> {after}"
|
|
310
|
+
|
|
311
|
+
|
|
258
312
|
def test_on_frame_callback_sees_every_frame(tmp_path):
|
|
259
313
|
seen = []
|
|
260
314
|
with _shared() as space:
|
|
@@ -318,6 +372,144 @@ def test_capture_desktop_explains_why_it_cannot_run_in_offscreen_mode():
|
|
|
318
372
|
assert excinfo.value.code == "workspace-has-no-monitor"
|
|
319
373
|
|
|
320
374
|
|
|
375
|
+
# -- desktop recording: the scoping contract, without needing a screen DC ---------------
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_desktop_recording_frames_only_the_workspaces_own_monitor(tmp_path):
|
|
379
|
+
"""Whatever the capture backend does, the recording is scoped to this workspace's monitor.
|
|
380
|
+
|
|
381
|
+
The frame source is injected here so the claim under test — "the recorder is bound to *our*
|
|
382
|
+
monitor, its rectangle, and nothing wider" — is checked on a machine whose screen capture is
|
|
383
|
+
unavailable. The production source is ``capture_monitor(monitor)`` for the same monitor object.
|
|
384
|
+
"""
|
|
385
|
+
from PIL import Image
|
|
386
|
+
|
|
387
|
+
from VirtualDesktop import SessionConfig
|
|
388
|
+
|
|
389
|
+
seen_rects = []
|
|
390
|
+
|
|
391
|
+
with VirtualWorkspace(
|
|
392
|
+
VirtualWorkspace.SHARED,
|
|
393
|
+
install_driver=False,
|
|
394
|
+
config=SessionConfig(hide_mode="virtual_screen"),
|
|
395
|
+
timeout=60.0,
|
|
396
|
+
) as space:
|
|
397
|
+
space.launch([CHARMAP], timeout=45.0)
|
|
398
|
+
monitor = space.monitor
|
|
399
|
+
assert monitor is not None and monitor.is_decidedly_virtual(), monitor
|
|
400
|
+
|
|
401
|
+
class _Shot:
|
|
402
|
+
def __init__(self, image, rect):
|
|
403
|
+
self.image = image
|
|
404
|
+
self.rect = rect
|
|
405
|
+
|
|
406
|
+
def source():
|
|
407
|
+
seen_rects.append(tuple(monitor.monitor))
|
|
408
|
+
return _Shot(Image.new("RGB", (monitor.width, monitor.height), (20, 40, 60)), monitor.monitor)
|
|
409
|
+
|
|
410
|
+
with space.record_desktop(tmp_path / "d.gif", fps=8, duration=0.6, source=source) as rec:
|
|
411
|
+
time.sleep(0.9)
|
|
412
|
+
report = rec.report()
|
|
413
|
+
|
|
414
|
+
assert report.ok, report.describe()
|
|
415
|
+
assert report.target == f"desktop:{monitor.device}"
|
|
416
|
+
# Frame size is the monitor's own size — not the whole virtual desktop, not the primary screen.
|
|
417
|
+
assert tuple(report.size) == (monitor.width, monitor.height)
|
|
418
|
+
assert seen_rects and all(r == tuple(monitor.monitor) for r in seen_rects)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_desktop_recording_is_narrower_than_the_whole_virtual_desktop(tmp_path):
|
|
422
|
+
"""The virtual desktop spans every monitor; the recording must not be that wide."""
|
|
423
|
+
from PIL import Image
|
|
424
|
+
from VirtualDesktop import SessionConfig
|
|
425
|
+
from VirtualDesktop._winapi import enumerate_monitors, virtual_screen_rect
|
|
426
|
+
|
|
427
|
+
with VirtualWorkspace(
|
|
428
|
+
VirtualWorkspace.SHARED,
|
|
429
|
+
install_driver=False,
|
|
430
|
+
config=SessionConfig(hide_mode="virtual_screen"),
|
|
431
|
+
timeout=60.0,
|
|
432
|
+
) as space:
|
|
433
|
+
space.launch([CHARMAP], timeout=45.0)
|
|
434
|
+
monitor = space.monitor
|
|
435
|
+
|
|
436
|
+
class _Shot:
|
|
437
|
+
def __init__(self, image, rect):
|
|
438
|
+
self.image, self.rect = image, rect
|
|
439
|
+
|
|
440
|
+
with space.record_desktop(
|
|
441
|
+
tmp_path / "d.gif", fps=8, duration=0.6,
|
|
442
|
+
source=lambda: _Shot(Image.new("RGB", (monitor.width, monitor.height)), monitor.monitor),
|
|
443
|
+
) as rec:
|
|
444
|
+
time.sleep(0.9)
|
|
445
|
+
report = rec.report()
|
|
446
|
+
|
|
447
|
+
whole = virtual_screen_rect()
|
|
448
|
+
whole_size = [whole[2] - whole[0], whole[3] - whole[1]]
|
|
449
|
+
assert tuple(report.size) == (monitor.width, monitor.height)
|
|
450
|
+
physical = [m for m in enumerate_monitors() if not m.is_decidedly_virtual()]
|
|
451
|
+
if physical:
|
|
452
|
+
primary_size = [physical[0].width, physical[0].height]
|
|
453
|
+
assert tuple(report.size) != tuple(primary_size) or (monitor.width, monitor.height) == tuple(primary_size)
|
|
454
|
+
assert whole_size != list(report.size) or len(enumerate_monitors()) == 1
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def test_desktop_recording_refuses_a_non_virtual_monitor(tmp_path):
|
|
458
|
+
"""Recording a monitor that is not the workspace's own must fail loudly.
|
|
459
|
+
|
|
460
|
+
The monitor is substituted rather than configured, because ``monitor="primary"`` *is* the
|
|
461
|
+
documented way to say "yes, record the user's screen" — the guard exists for the case where a
|
|
462
|
+
physical monitor ends up selected without that consent, which is otherwise hard to provoke on a
|
|
463
|
+
machine that has a virtual display available.
|
|
464
|
+
"""
|
|
465
|
+
from VirtualDesktop._winapi import enumerate_monitors
|
|
466
|
+
from VirtualDesktop._winapi import MonitorInfo
|
|
467
|
+
import dataclasses
|
|
468
|
+
|
|
469
|
+
with _shared() as space:
|
|
470
|
+
physical = [m for m in enumerate_monitors() if not m.is_decidedly_virtual()]
|
|
471
|
+
if not physical:
|
|
472
|
+
pytest.skip("no physical monitor to substitute")
|
|
473
|
+
original = space._monitor
|
|
474
|
+
try:
|
|
475
|
+
space._monitor = dataclasses.replace(physical[0])
|
|
476
|
+
with pytest.raises(ConfigError) as excinfo:
|
|
477
|
+
space.record_desktop(tmp_path / "x.gif")
|
|
478
|
+
assert excinfo.value.code == "record-would-capture-user-screen"
|
|
479
|
+
assert "own desktop" in (excinfo.value.hint or "")
|
|
480
|
+
finally:
|
|
481
|
+
space._monitor = original
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def test_the_recorder_target_names_the_monitor_it_records(tmp_path):
|
|
485
|
+
"""The report must say which monitor was recorded, so a wrong target is visible."""
|
|
486
|
+
from PIL import Image
|
|
487
|
+
from VirtualDesktop import SessionConfig
|
|
488
|
+
|
|
489
|
+
with VirtualWorkspace(
|
|
490
|
+
VirtualWorkspace.SHARED,
|
|
491
|
+
install_driver=False,
|
|
492
|
+
config=SessionConfig(hide_mode="virtual_screen"),
|
|
493
|
+
timeout=60.0,
|
|
494
|
+
) as space:
|
|
495
|
+
space.launch([CHARMAP], timeout=45.0)
|
|
496
|
+
device = space.monitor.device
|
|
497
|
+
|
|
498
|
+
class _Shot:
|
|
499
|
+
def __init__(self, image, rect):
|
|
500
|
+
self.image, self.rect = image, rect
|
|
501
|
+
|
|
502
|
+
with space.record_desktop(
|
|
503
|
+
tmp_path / "d.gif", fps=8, duration=0.5,
|
|
504
|
+
source=lambda: _Shot(Image.new("RGB", (32, 24)), (0, 0, 32, 24)),
|
|
505
|
+
) as rec:
|
|
506
|
+
time.sleep(0.8)
|
|
507
|
+
report = rec.report()
|
|
508
|
+
|
|
509
|
+
assert report.target == f"desktop:{device}"
|
|
510
|
+
assert tuple(report.size) == (32, 24)
|
|
511
|
+
|
|
512
|
+
|
|
321
513
|
# -- desktop recording (needs a working screen DC) -------------------------------------
|
|
322
514
|
|
|
323
515
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|