remotedesktop 0.13.0__tar.gz → 0.16.0__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.
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/CLAUDE.md +2 -2
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/PKG-INFO +1 -1
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/__init__.py +1 -1
- remotedesktop-0.16.0/src/remotedesktop/dxgi.py +334 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/performance.py +138 -19
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/server.py +94 -3
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/sharing.py +101 -10
- remotedesktop-0.16.0/tests/test_dxgi.py +78 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_performance.py +119 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_server_window.py +71 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/.gitignore +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/LICENSE +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/pyproject.toml +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/readme.md +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/run_claude.bat +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/run_client.bat +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/run_server.bat +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/conftest.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_client_window.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_config.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_input.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/tests/test_window_state.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.16.0}/uv.lock +0 -0
|
@@ -39,7 +39,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
39
39
|
- The client-side remote desktop view is a widget, `ViewerWidget` in `src/remotedesktop/viewer.py`, hosted as `ClientWindow`'s central widget. Screen display and keyboard/mouse/clipboard forwarding belong in this widget, not in the window.
|
|
40
40
|
- **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}`; datagrams with the wrong magic/version/type are dropped. The server runs a `DiscoveryResponder` thread while its window is open; the client's `DiscoveryPanel` (dock in `ClientWindow`) calls the blocking `discover_servers()` on a worker thread and delivers results to the GUI via a queued signal. `DEFAULT_CONNECT_PORT` (48654) is reserved for the future desktop connection.
|
|
41
41
|
- Discovery tests run over loopback with ephemeral ports (`bind_host`/`discovery_port`/`broadcast_hosts` parameters exist for this), so they never touch the real LAN or fixed ports.
|
|
42
|
-
- **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). The capability is negotiated via `"delta": true` in the client hello; clients without it (0.5.0) get full JPEG frames every tick because they force-decode frames as JPEG — never send them PNG. Each variant (legacy JPEG / keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
|
|
42
|
+
- **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. Capture is DXGI desktop duplication (`dxgi.py`, hand-rolled ctypes COM — ~10 ms per changed 4K frame, ~0 when idle, and "unchanged" is signalled by returning the same QImage object so the diff is skipped) with automatic fallback to `QScreen.grabWindow` (~96 ms) whenever duplication is unavailable or lost (secure desktop, RDP, display-mode change, CI); unit tests fake the duplication (`FakeDuplication` in `tests/test_dxgi.py`) and the real-hardware test skips where DXGI is unavailable. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). The capability is negotiated via `"delta": true` in the client hello; clients without it (0.5.0) get full JPEG frames every tick because they force-decode frames as JPEG — never send them PNG. Each variant (legacy JPEG / keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
|
|
43
43
|
- **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the GUIs open one connection per window and share it. Both window classes take a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_client_window.py` / `test_server_window.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files for the server and client** since they model different machines.
|
|
44
44
|
- **Trust model plumbing** (`config.py`): the client has a stable UUID identity (`load_client_identity`, in the `settings` table) and the server maps each approved client-id to a token (`PairedClients`). `ShareServer` takes an `approve_client(client_id, name) -> bool` callback; `ServerWindow` implements it as a modal QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
|
|
45
45
|
- **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, and `DiscoveryPanel` emit human-readable `status` signals for every connection phase, and both windows show them in a timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the panes show is also written to a rotating debug log file (`logs.py`; `logs.init_logging("client"/"server")` is called only by each app's `main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\{client,server}.log` on Windows). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops.
|
|
@@ -51,7 +51,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
51
51
|
- **Log exchange**: either side can fetch the peer's debug log over the connection (`{"type": "log_request"}` → `{"type": "log", "text": ...}`, admitted streams only). `ShareServer`/`ShareClient` take a `log_provider=` callable (the windows pass `logs.read_log_tail("server"/"client")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. Both windows have a "Get client log"/"Get server log" button in the Connection log tab; the reply opens a `logs.PeerLogDialog`. The server requests from the most recently admitted stream.
|
|
52
52
|
- **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake — **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
|
|
53
53
|
- **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** — a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
|
|
54
|
-
- **Performance monitoring** (`performance.py` + `preferences.py`): `PerformanceMonitor` is an opt-in collaborator (`performance=` on `ShareServer`/`ShareClient`, the `clipboard=` pattern — plain sharing tests leave it off). While admitted streams are attached, a 1 s QTimer samples `MessageStream.bytes_sent/bytes_received` counters into rolling `MetricSeries` (bandwidth, aggregated across streams via per-stream baselines) and pings the most recently admitted stream: `{"type": "ping", "id", "rtt"}` / `{"type": "pong", "id"}` — each side measures its own RTT and piggybacks it on its next ping so both ends graph both directions; peers without the feature ignore pings harmlessly (RTT graphs show "no data"). Ping/pong is admitted-streams-only on the server, like input/clipboard. Both windows have a "Performance" tab (`PerformanceTab`, custom QPainter graphs) that schedules **no paint work while hidden** (gated on `isVisible()`, repaint on `showEvent`) and a "Preferences" tab whose history-window setting persists via the `Settings` store (`performance_window_seconds`, default 120). The monitor's timer runs only while streams are attached; all timing is `time.monotonic()` with an injectable `clock=` for tests — never monkeypatch `time.monotonic` (the test `pump()` helper depends on it).
|
|
54
|
+
- **Performance monitoring** (`performance.py` + `preferences.py`): `PerformanceMonitor` is an opt-in collaborator (`performance=` on `ShareServer`/`ShareClient`, the `clipboard=` pattern — plain sharing tests leave it off). While admitted streams are attached, a 1 s QTimer samples `MessageStream.bytes_sent/bytes_received` counters into rolling `MetricSeries` (bandwidth, aggregated across streams via per-stream baselines) and pings **every** attached stream (each ping id maps to its stream; a pong is honored only on the stream it was sent to). The graphs' RTT series follow only the *active* (most recently admitted) stream; per-stream send/recv/RTT snapshots come from `metrics_for(stream)` and feed the server's Status-tab `ViewersTable` (`server.py`), which joins them with `ShareServer.viewers()` — identity fields (`user`/`host`/`os`) that the client hello reports (`_client_details()`; missing on old clients, shown as dashes). The ping is: `{"type": "ping", "id", "rtt"}` / `{"type": "pong", "id"}` — each side measures its own RTT and piggybacks it on its next ping so both ends graph both directions; peers without the feature ignore pings harmlessly (RTT graphs show "no data"). Ping/pong is admitted-streams-only on the server, like input/clipboard. Both windows have a "Performance" tab (`PerformanceTab`, custom QPainter graphs) that schedules **no paint work while hidden** (gated on `isVisible()`, repaint on `showEvent`) and a "Preferences" tab whose history-window setting persists via the `Settings` store (`performance_window_seconds`, default 120). The monitor's timer runs only while streams are attached; all timing is `time.monotonic()` with an injectable `clock=` for tests — never monkeypatch `time.monotonic` (the test `pump()` helper depends on it).
|
|
55
55
|
- **Clipboard sync is bidirectional** (`clipboard.py` `ClipboardSync`): client copy → server and server copy → all clients, as `{"type": "clipboard", "text"/"image_png"}` messages. It's opt-in — `ShareServer`/`ShareClient` take a `clipboard=` param and do nothing with clipboard if it's None, so only the GUIs (and clipboard tests) enable it; **plain sharing/input tests must leave it off to avoid touching the real OS clipboard.** Echo/loop prevention is by *content signature*, not just a guard flag, because Windows fires `QClipboard.dataChanged` asynchronously (after a flag would be cleared); image signatures hash canonical RGBA pixels so a re-encoded PNG round trip doesn't loop. Server only accepts clipboard from streams past the approval handshake. Tests use a `FakeClipboard(QObject)` with a `changed` signal and recording `apply()` so two independent clipboards exist in one process.
|
|
56
56
|
- The package version lives only in `src/remotedesktop/__init__.py` (`__version__`); hatchling reads it from there (`[tool.hatch.version]`), so bump it in that one place.
|
|
57
57
|
- Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
4
4
|
Summary: Remote desktop client/server for Windows computers on the same LAN, with autodiscovery. Provides screen, keyboard, mouse, and clipboard sharing without RDP or Microsoft authentication.
|
|
5
5
|
Author-email: James Abel <j@abel.co>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Fast screen capture via DXGI Desktop Duplication (Windows), with ctypes.
|
|
2
|
+
|
|
3
|
+
QScreen.grabWindow costs ~96 ms per 4K frame (GDI BitBlt + conversion) and
|
|
4
|
+
runs on the GUI thread every capture tick, capping the effective frame rate
|
|
5
|
+
near 10 fps and delaying everything else on the event loop. The DXGI
|
|
6
|
+
duplication API instead hands us the desktop as a GPU texture only when
|
|
7
|
+
something actually changed: a grab is a GPU copy plus one CPU memcpy
|
|
8
|
+
(~10 ms at 4K), and an unchanged screen costs ~nothing.
|
|
9
|
+
|
|
10
|
+
`DesktopDuplication.create()` returns None wherever duplication is not
|
|
11
|
+
available (non-Windows, no interactive desktop, RDP sessions, rotated
|
|
12
|
+
displays) — the caller falls back to grabWindow. `grab()` returns the
|
|
13
|
+
current desktop as a QImage; when nothing changed since the last grab it
|
|
14
|
+
returns the *same QImage object* (callers can use an identity check to skip
|
|
15
|
+
diffing). It returns None when the duplication was lost (secure desktop,
|
|
16
|
+
display-mode change) — the caller should close() and re-create() later.
|
|
17
|
+
|
|
18
|
+
All COM calls are hand-rolled vtable dispatch; only the handful of methods
|
|
19
|
+
used here are declared, by their fixed vtable slots.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import ctypes
|
|
23
|
+
import logging
|
|
24
|
+
import sys
|
|
25
|
+
from ctypes import wintypes
|
|
26
|
+
|
|
27
|
+
from PySide6.QtGui import QImage
|
|
28
|
+
|
|
29
|
+
_log = logging.getLogger("remotedesktop.dxgi")
|
|
30
|
+
|
|
31
|
+
_UINT = ctypes.c_uint
|
|
32
|
+
_HRESULT = ctypes.c_int32 # signed, checked manually (HRESULT restype would raise)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _signed(code: int) -> int:
|
|
36
|
+
return ctypes.c_int32(code).value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DXGI_ERROR_WAIT_TIMEOUT = _signed(0x887A0027)
|
|
40
|
+
DXGI_ERROR_ACCESS_LOST = _signed(0x887A0026)
|
|
41
|
+
|
|
42
|
+
_D3D_DRIVER_TYPE_HARDWARE = 1
|
|
43
|
+
_D3D11_SDK_VERSION = 7
|
|
44
|
+
_D3D11_USAGE_STAGING = 3
|
|
45
|
+
_D3D11_CPU_ACCESS_READ = 0x20000
|
|
46
|
+
_D3D11_MAP_READ = 1
|
|
47
|
+
_DXGI_MODE_ROTATION_UNSPECIFIED = 0
|
|
48
|
+
_DXGI_MODE_ROTATION_IDENTITY = 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _GUID(ctypes.Structure):
|
|
52
|
+
_fields_ = [
|
|
53
|
+
("Data1", ctypes.c_uint32),
|
|
54
|
+
("Data2", ctypes.c_uint16),
|
|
55
|
+
("Data3", ctypes.c_uint16),
|
|
56
|
+
("Data4", ctypes.c_ubyte * 8),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def of(cls, d1, d2, d3, *d4):
|
|
61
|
+
return cls(d1, d2, d3, (ctypes.c_ubyte * 8)(*d4))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_IID_IDXGIDevice = _GUID.of(0x54EC77FA, 0x1377, 0x44E6, 0x8C, 0x32, 0x88, 0xFD, 0x5F, 0x44, 0xC8, 0x4C)
|
|
65
|
+
_IID_IDXGIOutput1 = _GUID.of(0x00CDDEA8, 0x939B, 0x4B83, 0xA3, 0x40, 0xA6, 0x85, 0x22, 0x66, 0x66, 0xCC)
|
|
66
|
+
_IID_ID3D11Texture2D = _GUID.of(0x6F15AAF2, 0xD208, 0x4E89, 0x9A, 0xB4, 0x48, 0x95, 0x35, 0xD3, 0x4F, 0x9C)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _DXGI_OUTPUT_DESC(ctypes.Structure):
|
|
70
|
+
_fields_ = [
|
|
71
|
+
("DeviceName", ctypes.c_wchar * 32),
|
|
72
|
+
("DesktopCoordinates", wintypes.RECT),
|
|
73
|
+
("AttachedToDesktop", wintypes.BOOL),
|
|
74
|
+
("Rotation", _UINT),
|
|
75
|
+
("Monitor", ctypes.c_void_p),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _DXGI_OUTDUPL_POINTER_POSITION(ctypes.Structure):
|
|
80
|
+
_fields_ = [("Position", wintypes.POINT), ("Visible", wintypes.BOOL)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _DXGI_OUTDUPL_FRAME_INFO(ctypes.Structure):
|
|
84
|
+
_fields_ = [
|
|
85
|
+
("LastPresentTime", ctypes.c_int64),
|
|
86
|
+
("LastMouseUpdateTime", ctypes.c_int64),
|
|
87
|
+
("AccumulatedFrames", _UINT),
|
|
88
|
+
("RectsCoalesced", wintypes.BOOL),
|
|
89
|
+
("ProtectedContentMaskedOut", wintypes.BOOL),
|
|
90
|
+
("PointerPosition", _DXGI_OUTDUPL_POINTER_POSITION),
|
|
91
|
+
("TotalMetadataBufferSize", _UINT),
|
|
92
|
+
("PointerShapeBufferSize", _UINT),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _DXGI_SAMPLE_DESC(ctypes.Structure):
|
|
97
|
+
_fields_ = [("Count", _UINT), ("Quality", _UINT)]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _D3D11_TEXTURE2D_DESC(ctypes.Structure):
|
|
101
|
+
_fields_ = [
|
|
102
|
+
("Width", _UINT),
|
|
103
|
+
("Height", _UINT),
|
|
104
|
+
("MipLevels", _UINT),
|
|
105
|
+
("ArraySize", _UINT),
|
|
106
|
+
("Format", _UINT),
|
|
107
|
+
("SampleDesc", _DXGI_SAMPLE_DESC),
|
|
108
|
+
("Usage", _UINT),
|
|
109
|
+
("BindFlags", _UINT),
|
|
110
|
+
("CPUAccessFlags", _UINT),
|
|
111
|
+
("MiscFlags", _UINT),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _D3D11_MAPPED_SUBRESOURCE(ctypes.Structure):
|
|
116
|
+
_fields_ = [("pData", ctypes.c_void_p), ("RowPitch", _UINT), ("DepthPitch", _UINT)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _method(com_ptr, slot: int, *argtypes, restype=_HRESULT):
|
|
120
|
+
"""Bind vtable slot `slot` of a COM interface pointer as a callable."""
|
|
121
|
+
vtable = ctypes.cast(com_ptr, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents
|
|
122
|
+
proto = ctypes.WINFUNCTYPE(restype, ctypes.c_void_p, *argtypes)
|
|
123
|
+
return proto(vtable[slot])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _release(com_ptr) -> None:
|
|
127
|
+
if com_ptr:
|
|
128
|
+
_method(com_ptr, 2, restype=ctypes.c_ulong)(com_ptr) # IUnknown::Release
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _query_interface(com_ptr, iid: _GUID):
|
|
132
|
+
out = ctypes.c_void_p()
|
|
133
|
+
hr = _method(com_ptr, 0, ctypes.POINTER(_GUID), ctypes.POINTER(ctypes.c_void_p))(
|
|
134
|
+
com_ptr, ctypes.byref(iid), ctypes.byref(out)
|
|
135
|
+
)
|
|
136
|
+
if hr < 0:
|
|
137
|
+
raise OSError(f"QueryInterface failed: {hr:#x}")
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Vtable slots (fixed by the header declaration order):
|
|
142
|
+
# IDXGIObject: 0-2 IUnknown, 3-5 private data, 6 GetParent.
|
|
143
|
+
_SLOT_GETPARENT = 6
|
|
144
|
+
# IDXGIDevice: 7 GetAdapter.
|
|
145
|
+
_SLOT_DEVICE_GETADAPTER = 7
|
|
146
|
+
# IDXGIAdapter: 7 EnumOutputs.
|
|
147
|
+
_SLOT_ADAPTER_ENUMOUTPUTS = 7
|
|
148
|
+
# IDXGIOutput: 7 GetDesc (then 8-18); IDXGIOutput1 adds 19-21, 22 DuplicateOutput.
|
|
149
|
+
_SLOT_OUTPUT_GETDESC = 7
|
|
150
|
+
_SLOT_OUTPUT1_DUPLICATEOUTPUT = 22
|
|
151
|
+
# IDXGIOutputDuplication: 8 AcquireNextFrame, 14 ReleaseFrame.
|
|
152
|
+
_SLOT_DUPL_ACQUIRENEXTFRAME = 8
|
|
153
|
+
_SLOT_DUPL_RELEASEFRAME = 14
|
|
154
|
+
# ID3D11Device: 5 CreateTexture2D.
|
|
155
|
+
_SLOT_D3D_CREATETEXTURE2D = 5
|
|
156
|
+
# ID3D11DeviceContext (after 0-6 ID3D11DeviceChild): 14 Map, 15 Unmap, 47 CopyResource.
|
|
157
|
+
_SLOT_CTX_MAP = 14
|
|
158
|
+
_SLOT_CTX_UNMAP = 15
|
|
159
|
+
_SLOT_CTX_COPYRESOURCE = 47
|
|
160
|
+
# ID3D11Texture2D: 10 GetDesc (0-2 IUnknown, 3-6 DeviceChild, 7-9 Resource).
|
|
161
|
+
_SLOT_TEX_GETDESC = 10
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class DesktopDuplication:
|
|
165
|
+
"""One duplicated output (the primary monitor), grabbed on demand."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, device, context, duplication, staging, width: int, height: int) -> None:
|
|
168
|
+
self._device = device
|
|
169
|
+
self._context = context
|
|
170
|
+
self._duplication = duplication
|
|
171
|
+
self._staging = staging
|
|
172
|
+
self._width = width
|
|
173
|
+
self._height = height
|
|
174
|
+
self._last_image: QImage | None = None
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def create(cls) -> "DesktopDuplication | None":
|
|
178
|
+
if sys.platform != "win32":
|
|
179
|
+
return None
|
|
180
|
+
device = context = dxgi_device = adapter = output = duplication = None
|
|
181
|
+
try:
|
|
182
|
+
d3d11 = ctypes.windll.d3d11
|
|
183
|
+
device = ctypes.c_void_p()
|
|
184
|
+
context = ctypes.c_void_p()
|
|
185
|
+
hr = d3d11.D3D11CreateDevice(
|
|
186
|
+
None, _D3D_DRIVER_TYPE_HARDWARE, None, 0, None, 0,
|
|
187
|
+
_D3D11_SDK_VERSION, ctypes.byref(device), None, ctypes.byref(context),
|
|
188
|
+
)
|
|
189
|
+
if hr < 0:
|
|
190
|
+
raise OSError(f"D3D11CreateDevice failed: {hr:#x}")
|
|
191
|
+
dxgi_device = _query_interface(device, _IID_IDXGIDevice)
|
|
192
|
+
adapter = ctypes.c_void_p()
|
|
193
|
+
hr = _method(dxgi_device, _SLOT_DEVICE_GETADAPTER, ctypes.POINTER(ctypes.c_void_p))(
|
|
194
|
+
dxgi_device, ctypes.byref(adapter)
|
|
195
|
+
)
|
|
196
|
+
if hr < 0:
|
|
197
|
+
raise OSError(f"GetAdapter failed: {hr:#x}")
|
|
198
|
+
output, desc = cls._primary_output(adapter)
|
|
199
|
+
if desc.Rotation not in (_DXGI_MODE_ROTATION_UNSPECIFIED, _DXGI_MODE_ROTATION_IDENTITY):
|
|
200
|
+
raise OSError(f"display rotation {desc.Rotation} not supported")
|
|
201
|
+
output1 = _query_interface(output, _IID_IDXGIOutput1)
|
|
202
|
+
try:
|
|
203
|
+
duplication = ctypes.c_void_p()
|
|
204
|
+
hr = _method(
|
|
205
|
+
output1, _SLOT_OUTPUT1_DUPLICATEOUTPUT,
|
|
206
|
+
ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p),
|
|
207
|
+
)(output1, device, ctypes.byref(duplication))
|
|
208
|
+
if hr < 0:
|
|
209
|
+
raise OSError(f"DuplicateOutput failed: {hr:#x}")
|
|
210
|
+
finally:
|
|
211
|
+
_release(output1)
|
|
212
|
+
width = desc.DesktopCoordinates.right - desc.DesktopCoordinates.left
|
|
213
|
+
height = desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top
|
|
214
|
+
staging = cls._create_staging(device, width, height)
|
|
215
|
+
_log.info("DXGI desktop duplication active (%dx%d)", width, height)
|
|
216
|
+
return cls(device, context, duplication, staging, width, height)
|
|
217
|
+
except OSError as error:
|
|
218
|
+
_log.info("DXGI desktop duplication unavailable (%s) — using grabWindow", error)
|
|
219
|
+
for com_ptr in (duplication, output, adapter, dxgi_device, context, device):
|
|
220
|
+
_release(com_ptr)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _primary_output(adapter):
|
|
225
|
+
"""The output whose desktop rect starts at (0, 0) — the primary —
|
|
226
|
+
or the first attached output if none does."""
|
|
227
|
+
first = None
|
|
228
|
+
index = 0
|
|
229
|
+
while True:
|
|
230
|
+
output = ctypes.c_void_p()
|
|
231
|
+
hr = _method(
|
|
232
|
+
adapter, _SLOT_ADAPTER_ENUMOUTPUTS, _UINT, ctypes.POINTER(ctypes.c_void_p)
|
|
233
|
+
)(adapter, index, ctypes.byref(output))
|
|
234
|
+
if hr < 0 or not output:
|
|
235
|
+
break
|
|
236
|
+
desc = _DXGI_OUTPUT_DESC()
|
|
237
|
+
hr = _method(output, _SLOT_OUTPUT_GETDESC, ctypes.POINTER(_DXGI_OUTPUT_DESC))(
|
|
238
|
+
output, ctypes.byref(desc)
|
|
239
|
+
)
|
|
240
|
+
if hr >= 0 and desc.AttachedToDesktop:
|
|
241
|
+
if desc.DesktopCoordinates.left == 0 and desc.DesktopCoordinates.top == 0:
|
|
242
|
+
if first is not None:
|
|
243
|
+
_release(first[0])
|
|
244
|
+
return output, desc
|
|
245
|
+
if first is None:
|
|
246
|
+
first = (output, desc)
|
|
247
|
+
index += 1
|
|
248
|
+
continue
|
|
249
|
+
_release(output)
|
|
250
|
+
index += 1
|
|
251
|
+
if first is None:
|
|
252
|
+
raise OSError("no attached DXGI output")
|
|
253
|
+
return first
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _create_staging(device, width: int, height: int):
|
|
257
|
+
desc = _D3D11_TEXTURE2D_DESC()
|
|
258
|
+
desc.Width = width
|
|
259
|
+
desc.Height = height
|
|
260
|
+
desc.MipLevels = 1
|
|
261
|
+
desc.ArraySize = 1
|
|
262
|
+
desc.Format = 87 # DXGI_FORMAT_B8G8R8A8_UNORM: the duplication format
|
|
263
|
+
desc.SampleDesc.Count = 1
|
|
264
|
+
desc.Usage = _D3D11_USAGE_STAGING
|
|
265
|
+
desc.CPUAccessFlags = _D3D11_CPU_ACCESS_READ
|
|
266
|
+
staging = ctypes.c_void_p()
|
|
267
|
+
hr = _method(
|
|
268
|
+
device, _SLOT_D3D_CREATETEXTURE2D,
|
|
269
|
+
ctypes.POINTER(_D3D11_TEXTURE2D_DESC), ctypes.c_void_p,
|
|
270
|
+
ctypes.POINTER(ctypes.c_void_p),
|
|
271
|
+
)(device, ctypes.byref(desc), None, ctypes.byref(staging))
|
|
272
|
+
if hr < 0:
|
|
273
|
+
raise OSError(f"CreateTexture2D (staging) failed: {hr:#x}")
|
|
274
|
+
return staging
|
|
275
|
+
|
|
276
|
+
def grab(self) -> QImage | None:
|
|
277
|
+
"""The current desktop; the previous QImage object if unchanged;
|
|
278
|
+
None if the duplication is lost (close() and re-create() later)."""
|
|
279
|
+
frame_info = _DXGI_OUTDUPL_FRAME_INFO()
|
|
280
|
+
resource = ctypes.c_void_p()
|
|
281
|
+
hr = _method(
|
|
282
|
+
self._duplication, _SLOT_DUPL_ACQUIRENEXTFRAME,
|
|
283
|
+
_UINT, ctypes.POINTER(_DXGI_OUTDUPL_FRAME_INFO), ctypes.POINTER(ctypes.c_void_p),
|
|
284
|
+
)(self._duplication, 0, ctypes.byref(frame_info), ctypes.byref(resource))
|
|
285
|
+
if hr == DXGI_ERROR_WAIT_TIMEOUT:
|
|
286
|
+
# Nothing changed since the last acquired frame.
|
|
287
|
+
return self._last_image if self._last_image is not None else None
|
|
288
|
+
if hr < 0:
|
|
289
|
+
if hr != DXGI_ERROR_ACCESS_LOST:
|
|
290
|
+
_log.warning("AcquireNextFrame failed: %#x — dropping DXGI capture", hr & 0xFFFFFFFF)
|
|
291
|
+
return None
|
|
292
|
+
try:
|
|
293
|
+
texture = _query_interface(resource, _IID_ID3D11Texture2D)
|
|
294
|
+
try:
|
|
295
|
+
_method(
|
|
296
|
+
self._context, _SLOT_CTX_COPYRESOURCE,
|
|
297
|
+
ctypes.c_void_p, ctypes.c_void_p, restype=None,
|
|
298
|
+
)(self._context, self._staging, texture)
|
|
299
|
+
finally:
|
|
300
|
+
_release(texture)
|
|
301
|
+
except OSError:
|
|
302
|
+
_release(resource)
|
|
303
|
+
_method(self._duplication, _SLOT_DUPL_RELEASEFRAME)(self._duplication)
|
|
304
|
+
return None
|
|
305
|
+
_release(resource)
|
|
306
|
+
_method(self._duplication, _SLOT_DUPL_RELEASEFRAME)(self._duplication)
|
|
307
|
+
|
|
308
|
+
mapped = _D3D11_MAPPED_SUBRESOURCE()
|
|
309
|
+
hr = _method(
|
|
310
|
+
self._context, _SLOT_CTX_MAP,
|
|
311
|
+
ctypes.c_void_p, _UINT, _UINT, _UINT, ctypes.POINTER(_D3D11_MAPPED_SUBRESOURCE),
|
|
312
|
+
)(self._context, self._staging, 0, _D3D11_MAP_READ, 0, ctypes.byref(mapped))
|
|
313
|
+
if hr < 0 or not mapped.pData:
|
|
314
|
+
_log.warning("Map (staging) failed: %#x — dropping DXGI capture", hr & 0xFFFFFFFF)
|
|
315
|
+
return None
|
|
316
|
+
try:
|
|
317
|
+
buffer = (ctypes.c_char * (mapped.RowPitch * self._height)).from_address(mapped.pData)
|
|
318
|
+
# .copy() both detaches from the mapped memory and tightens the
|
|
319
|
+
# row stride — the one CPU memcpy of the whole path.
|
|
320
|
+
image = QImage(
|
|
321
|
+
buffer, self._width, self._height, mapped.RowPitch, QImage.Format.Format_RGB32
|
|
322
|
+
).copy()
|
|
323
|
+
finally:
|
|
324
|
+
_method(self._context, _SLOT_CTX_UNMAP, ctypes.c_void_p, _UINT, restype=None)(
|
|
325
|
+
self._context, self._staging, 0
|
|
326
|
+
)
|
|
327
|
+
self._last_image = image
|
|
328
|
+
return image
|
|
329
|
+
|
|
330
|
+
def close(self) -> None:
|
|
331
|
+
for attribute in ("_staging", "_duplication", "_context", "_device"):
|
|
332
|
+
_release(getattr(self, attribute))
|
|
333
|
+
setattr(self, attribute, None)
|
|
334
|
+
self._last_image = None
|