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.
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/CLAUDE.md +1 -1
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/PKG-INFO +1 -1
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/__init__.py +1 -1
- remotedesktop-0.14.0/src/remotedesktop/dxgi.py +334 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/sharing.py +51 -9
- remotedesktop-0.14.0/tests/test_dxgi.py +78 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/.gitignore +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/LICENSE +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/pyproject.toml +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/readme.md +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/run_claude.bat +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/run_client.bat +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/run_server.bat +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/server.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/conftest.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_client_window.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_config.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_input.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_performance.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_server_window.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.13.0 → remotedesktop-0.14.0}/tests/test_window_state.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
|
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
|