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.
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/CLAUDE.md +2 -2
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/PKG-INFO +1 -1
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/__init__.py +1 -1
- remotedesktop-0.14.0/src/remotedesktop/dxgi.py +334 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/frames.py +25 -14
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/inventory.py +221 -218
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/modal_loop.py +22 -8
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/performance.py +6 -5
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/sharing.py +58 -12
- remotedesktop-0.14.0/tests/test_dxgi.py +78 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_frames.py +29 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_inventory.py +125 -113
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_modal_loop.py +46 -1
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_performance.py +6 -5
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/.gitignore +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/LICENSE +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/pyproject.toml +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/readme.md +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/run_claude.bat +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/run_client.bat +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/run_server.bat +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/server.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/conftest.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_client_window.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_config.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_input.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_server_window.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.14.0}/tests/test_window_state.py +0 -0
- {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`
|
|
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.
|
|
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
|
|
@@ -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
|
|
75
|
-
|
|
76
|
-
Returns the patched image, or None
|
|
77
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
112
|
+
return canvas
|