remotedesktop 0.13.0__tar.gz → 0.14.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.
Files changed (57) hide show
  1. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/CLAUDE.md +1 -1
  2. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/PKG-INFO +1 -1
  3. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/__init__.py +1 -1
  4. remotedesktop-0.14.0/src/remotedesktop/dxgi.py +334 -0
  5. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/sharing.py +51 -9
  6. remotedesktop-0.14.0/tests/test_dxgi.py +78 -0
  7. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/.github/workflows/ci.yml +0 -0
  8. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/.gitignore +0 -0
  9. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/LICENSE +0 -0
  10. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/pyproject.toml +0 -0
  11. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/readme.md +0 -0
  12. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/run_claude.bat +0 -0
  13. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/run_client.bat +0 -0
  14. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/run_server.bat +0 -0
  15. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/scripts/make_coverage_badge.py +0 -0
  16. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/scripts/make_venv.bat +0 -0
  17. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/autostart.py +0 -0
  18. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/client.py +0 -0
  19. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/clipboard.py +0 -0
  20. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/config.py +0 -0
  21. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/db.py +0 -0
  22. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/discovery.py +0 -0
  23. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/frames.py +0 -0
  24. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/icon.py +0 -0
  25. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/input_injection.py +0 -0
  26. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/inventory.py +0 -0
  27. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/logs.py +0 -0
  28. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/modal_loop.py +0 -0
  29. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/performance.py +0 -0
  30. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/preferences.py +0 -0
  31. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/protocol.py +0 -0
  32. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/server.py +0 -0
  33. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/tls.py +0 -0
  34. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/viewer.py +0 -0
  35. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/window_state.py +0 -0
  36. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/conftest.py +0 -0
  37. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_autostart.py +0 -0
  38. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_client_window.py +0 -0
  39. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_clipboard.py +0 -0
  40. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_config.py +0 -0
  41. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_discovery.py +0 -0
  42. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_frames.py +0 -0
  43. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_icon.py +0 -0
  44. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_input.py +0 -0
  45. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_input_injection.py +0 -0
  46. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_inventory.py +0 -0
  47. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_logs.py +0 -0
  48. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_modal_loop.py +0 -0
  49. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_performance.py +0 -0
  50. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_preferences.py +0 -0
  51. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_protocol.py +0 -0
  52. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_server_window.py +0 -0
  53. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_sharing.py +0 -0
  54. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_smoke.py +0 -0
  55. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_tls.py +0 -0
  56. {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_window_state.py +0 -0
  57. {remotedesktop-0.13.0 → remotedesktop-0.14.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.13.0
3
+ Version: 0.14.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
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.13.0"
3
+ __version__ = "0.14.0"
@@ -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
@@ -16,6 +16,7 @@ GUIs can show a debug log.
16
16
  import hmac
17
17
  import logging
18
18
  import socket as socket_module
19
+ import time
19
20
  from collections.abc import Callable
20
21
  from typing import cast
21
22
 
@@ -38,7 +39,7 @@ from remotedesktop.config import (
38
39
  load_client_identity,
39
40
  )
40
41
  from remotedesktop.discovery import DEFAULT_CONNECT_PORT
41
- from remotedesktop import frames
42
+ from remotedesktop import dxgi, frames
42
43
  from remotedesktop.input_injection import InputInjector
43
44
  from remotedesktop.performance import PerformanceMonitor
44
45
  from remotedesktop.protocol import MAX_PAYLOAD, PROTOCOL_VERSION, MessageStream
@@ -129,6 +130,12 @@ class ShareServer(QObject):
129
130
  self._delta_capable: set[MessageStream] = set()
130
131
  self._needs_keyframe: set[MessageStream] = set()
131
132
  self._previous_frame: QImage | None = None
133
+ # DXGI desktop duplication (~10 ms per changed 4K frame, ~0 when the
134
+ # screen is idle, vs ~96 ms for grabWindow). Created lazily on the
135
+ # first capture; None with a retry deadline while unavailable or
136
+ # lost (secure desktop, display-mode change) — grabWindow fills in.
137
+ self._dxgi: dxgi.DesktopDuplication | None = None
138
+ self._dxgi_retry_at = 0.0
132
139
 
133
140
  cert, key = credentials if credentials is not None else tls.ephemeral_credentials()
134
141
  self._server = QSslServer(self)
@@ -178,6 +185,9 @@ class ShareServer(QObject):
178
185
  self._delta_capable.clear()
179
186
  self._needs_keyframe.clear()
180
187
  self._previous_frame = None
188
+ if self._dxgi is not None:
189
+ self._dxgi.close()
190
+ self._dxgi = None
181
191
  if had_clients:
182
192
  self.clientCountChanged.emit(0)
183
193
  if self._performance is not None:
@@ -490,13 +500,40 @@ class ShareServer(QObject):
490
500
 
491
501
  def _capture(self) -> QImage | None:
492
502
  """Grab the primary screen at full resolution (tests override this
493
- to drive deterministic frame content)."""
503
+ to drive deterministic frame content).
504
+
505
+ DXGI desktop duplication when it works — returning the *same QImage
506
+ object* as last time when the screen is unchanged — otherwise
507
+ grabWindow."""
508
+ image = self._capture_dxgi()
509
+ if image is not None:
510
+ return image
494
511
  screen = QGuiApplication.primaryScreen()
495
512
  if screen is None:
496
513
  return None
497
514
  image = screen.grabWindow(0).toImage()
498
515
  return None if image.isNull() else image
499
516
 
517
+ def _capture_dxgi(self) -> QImage | None:
518
+ now = time.monotonic()
519
+ if self._dxgi is None:
520
+ if now < self._dxgi_retry_at:
521
+ return None
522
+ self._dxgi = dxgi.DesktopDuplication.create()
523
+ if self._dxgi is None:
524
+ # Not available on this system/session; check again rarely.
525
+ self._dxgi_retry_at = now + 60.0
526
+ return None
527
+ image = self._dxgi.grab()
528
+ if image is None:
529
+ # Lost (secure desktop, display-mode change) or no frame yet;
530
+ # grabWindow covers the gap and we retry shortly.
531
+ self._dxgi.close()
532
+ self._dxgi = None
533
+ self._dxgi_retry_at = now + 2.0
534
+ _log.info("DXGI capture unavailable — using grabWindow until it recovers")
535
+ return image
536
+
500
537
  def _broadcast_frame(self) -> None:
501
538
  if not self._streams:
502
539
  return
@@ -504,13 +541,18 @@ class ShareServer(QObject):
504
541
  if image is None:
505
542
  self.status.emit("Screen capture failed (null image)")
506
543
  return
507
- # bands is None when there is no comparable previous capture (first
508
- # frame, or the resolution changed): everyone gets a full frame.
509
- bands = (
510
- frames.changed_bands(self._previous_frame, image)
511
- if self._previous_frame is not None
512
- else None
513
- )
544
+ if image is self._previous_frame:
545
+ # DXGI reported no change since the last tick: skip the diff.
546
+ bands: list[tuple[int, int]] | None = []
547
+ else:
548
+ # bands is None when there is no comparable previous capture
549
+ # (first frame, or the resolution changed): everyone gets a
550
+ # full frame.
551
+ bands = (
552
+ frames.changed_bands(self._previous_frame, image)
553
+ if self._previous_frame is not None
554
+ else None
555
+ )
514
556
  self._previous_frame = image
515
557
  # Encode each variant at most once per tick, shared by all takers.
516
558
  legacy_jpeg: bytes | None = None
@@ -0,0 +1,78 @@
1
+ """DXGI desktop-duplication capture and its grabWindow fallback in ShareServer.
2
+
3
+ The real-hardware test skips wherever duplication is unavailable (headless
4
+ CI runners, RDP sessions); the ShareServer wiring is tested with fakes so
5
+ no real duplication is ever created there.
6
+ """
7
+
8
+ import pytest
9
+ from PySide6.QtGui import QGuiApplication, QImage
10
+
11
+ from remotedesktop import dxgi
12
+
13
+ from test_sharing import make_server
14
+
15
+
16
+ def test_real_desktop_duplication_matches_screen_size(qapp):
17
+ duplication = dxgi.DesktopDuplication.create()
18
+ if duplication is None:
19
+ pytest.skip("DXGI desktop duplication unavailable in this session")
20
+ try:
21
+ image = duplication.grab()
22
+ if image is None:
23
+ pytest.skip("duplication lost before the first frame")
24
+ screen = QGuiApplication.primaryScreen()
25
+ expected = screen.size() * screen.devicePixelRatio()
26
+ assert (image.width(), image.height()) == (
27
+ round(expected.width()),
28
+ round(expected.height()),
29
+ )
30
+ # A second grab either reports "unchanged" (same object), delivers a
31
+ # new frame, or the duplication was lost — never anything else.
32
+ again = duplication.grab()
33
+ assert again is None or isinstance(again, QImage)
34
+ finally:
35
+ duplication.close()
36
+
37
+
38
+ class FakeDuplication:
39
+ def __init__(self, images):
40
+ self.images = list(images)
41
+ self.closed = False
42
+
43
+ def grab(self):
44
+ return self.images.pop(0) if self.images else None
45
+
46
+ def close(self):
47
+ self.closed = True
48
+
49
+
50
+ def test_capture_uses_dxgi_and_repeats_unchanged_frames(qapp, credentials, tmp_path, monkeypatch):
51
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
52
+ frame = QImage(4, 4, QImage.Format.Format_RGB32)
53
+ fake = FakeDuplication([frame, frame])
54
+ monkeypatch.setattr(dxgi.DesktopDuplication, "create", staticmethod(lambda: fake))
55
+ try:
56
+ assert server._capture() is frame
57
+ assert server._capture() is frame # unchanged screen: same object again
58
+ finally:
59
+ server.close()
60
+ assert fake.closed # server.close() releases the duplication
61
+
62
+
63
+ def test_capture_dxgi_backs_off_after_loss(qapp, credentials, tmp_path, monkeypatch):
64
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
65
+ fake = FakeDuplication([]) # first grab returns None: lost immediately
66
+ created = []
67
+ monkeypatch.setattr(
68
+ dxgi.DesktopDuplication,
69
+ "create",
70
+ staticmethod(lambda: created.append(True) or fake),
71
+ )
72
+ try:
73
+ assert server._capture_dxgi() is None # created, then lost -> closed
74
+ assert fake.closed
75
+ assert server._capture_dxgi() is None # within the retry cooldown...
76
+ assert len(created) == 1 # ...so no re-create attempt yet
77
+ finally:
78
+ server.close()
File without changes
File without changes
File without changes