remotedesktop 0.12.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.12.0 → remotedesktop-0.14.0}/CLAUDE.md +2 -2
  2. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/PKG-INFO +1 -1
  3. {remotedesktop-0.12.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.12.0 → remotedesktop-0.14.0}/src/remotedesktop/frames.py +25 -14
  6. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/inventory.py +221 -218
  7. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/modal_loop.py +22 -8
  8. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/performance.py +6 -5
  9. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/sharing.py +58 -12
  10. remotedesktop-0.14.0/tests/test_dxgi.py +78 -0
  11. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_frames.py +29 -0
  12. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_inventory.py +125 -113
  13. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_modal_loop.py +46 -1
  14. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_performance.py +6 -5
  15. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/.github/workflows/ci.yml +0 -0
  16. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/.gitignore +0 -0
  17. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/LICENSE +0 -0
  18. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/pyproject.toml +0 -0
  19. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/readme.md +0 -0
  20. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/run_claude.bat +0 -0
  21. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/run_client.bat +0 -0
  22. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/run_server.bat +0 -0
  23. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/scripts/make_coverage_badge.py +0 -0
  24. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/scripts/make_venv.bat +0 -0
  25. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/autostart.py +0 -0
  26. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/client.py +0 -0
  27. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/clipboard.py +0 -0
  28. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/config.py +0 -0
  29. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/db.py +0 -0
  30. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/discovery.py +0 -0
  31. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/icon.py +0 -0
  32. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/input_injection.py +0 -0
  33. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/logs.py +0 -0
  34. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/preferences.py +0 -0
  35. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/protocol.py +0 -0
  36. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/server.py +0 -0
  37. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/tls.py +0 -0
  38. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/viewer.py +0 -0
  39. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/window_state.py +0 -0
  40. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/conftest.py +0 -0
  41. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_autostart.py +0 -0
  42. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_client_window.py +0 -0
  43. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_clipboard.py +0 -0
  44. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_config.py +0 -0
  45. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_discovery.py +0 -0
  46. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_icon.py +0 -0
  47. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_input.py +0 -0
  48. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_input_injection.py +0 -0
  49. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_logs.py +0 -0
  50. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_preferences.py +0 -0
  51. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_protocol.py +0 -0
  52. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_server_window.py +0 -0
  53. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_sharing.py +0 -0
  54. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_smoke.py +0 -0
  55. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_tls.py +0 -0
  56. {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_window_state.py +0 -0
  57. {remotedesktop-0.12.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.
@@ -47,7 +47,7 @@ Managed with `uv` (hatchling build backend, src layout):
47
47
  - **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; the callback gets the selected peer's key. The server's tab wires "Revoke access" → `ShareServer.revoke_client(client_id)` (removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` reports state "revoked"; a `_revoked` set distinguishes it from a plain disconnect). The client's tab wires "Forget server" → `KnownServers.forget(key)` + disconnect. After either, the next connection needs a fresh approval. Server and client each use a *separate table* (`server_peers` / `client_peers`, selected via the `table=` arg) so a machine running both apps doesn't commingle them; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
48
48
  - Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
49
49
  - **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
50
- - **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for the server this deadlocked when a *remote* click grabbed the server window's own title bar (the mouse-up sat unread on the socket). `ServerWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` between `WM_ENTERSIZEMOVE`/`WM_EXITSIZEMOVE` whose callback pumps Qt events (user input excluded). Tests inject a fake `timers` backend — never let them create real Win32 timers.
50
+ - **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for the server this deadlocked when a *remote* click grabbed the server window's own title bar (the mouse-up sat unread on the socket). `ServerWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). Tests inject a fake `timers` backend — never let them create real Win32 timers.
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.12.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.12.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
@@ -23,6 +23,11 @@ from PySide6.QtCore import QBuffer
23
23
  from PySide6.QtGui import QImage, QPainter
24
24
 
25
25
  BAND_HEIGHT = 64
26
+ # PNG is lossless at any "quality" — the setting only picks the zlib effort.
27
+ # 80 encodes a 4K frame ~35% faster than the default for ~20% more bytes;
28
+ # encoding happens on the GUI thread every tick, so time matters more than
29
+ # size on a LAN.
30
+ PNG_QUALITY = 80
26
31
  _HEADER_LEN = struct.Struct(">I")
27
32
 
28
33
 
@@ -63,7 +68,7 @@ def encode_delta(image: QImage, bands: list[tuple[int, int]]) -> bytes:
63
68
  entries = []
64
69
  blobs = []
65
70
  for y, h in bands:
66
- png = encode_image(image.copy(0, y, image.width(), h))
71
+ png = encode_image(image.copy(0, y, image.width(), h), "PNG", PNG_QUALITY)
67
72
  entries.append({"y": y, "h": h, "len": len(png)})
68
73
  blobs.append(png)
69
74
  header = json.dumps({"w": image.width(), "h": image.height(), "bands": entries}).encode()
@@ -71,11 +76,15 @@ def encode_delta(image: QImage, bands: list[tuple[int, int]]) -> bytes:
71
76
 
72
77
 
73
78
  def apply_delta(canvas: QImage, payload: bytes) -> QImage | None:
74
- """Patch a delta payload onto a copy of `canvas`.
75
-
76
- Returns the patched image, or None if the payload is malformed or was
77
- produced for a different frame size (the caller should then wait for
78
- the next keyframe).
79
+ """Patch a delta payload onto `canvas`, in place.
80
+
81
+ Returns the patched image (`canvas` itself), or None with the canvas
82
+ untouched if the payload is malformed or was produced for a different
83
+ frame size (the caller should then wait for the next keyframe). Every
84
+ band is decoded before any is painted, so a payload that goes bad
85
+ halfway never leaves a half-patched canvas. Painting in place skips a
86
+ full-frame copy per delta; QImage's copy-on-write still protects any
87
+ other holder of the image.
79
88
  """
80
89
  try:
81
90
  (header_len,) = _HEADER_LEN.unpack_from(payload)
@@ -86,16 +95,18 @@ def apply_delta(canvas: QImage, payload: bytes) -> QImage | None:
86
95
  return None
87
96
  if not size_ok:
88
97
  return None
89
- image = canvas.copy()
90
- painter = QPainter(image)
98
+ decoded: list[tuple[int, QImage]] = []
91
99
  offset = _HEADER_LEN.size + header_len
100
+ for y, h, length in bands:
101
+ band = QImage.fromData(payload[offset : offset + length])
102
+ offset += length
103
+ if band.isNull() or band.height() != h:
104
+ return None
105
+ decoded.append((y, band))
106
+ painter = QPainter(canvas)
92
107
  try:
93
- for y, h, length in bands:
94
- band = QImage.fromData(payload[offset : offset + length])
95
- offset += length
96
- if band.isNull() or band.height() != h:
97
- return None
108
+ for y, band in decoded:
98
109
  painter.drawImage(0, y, band)
99
110
  finally:
100
111
  painter.end()
101
- return image
112
+ return canvas