remotedesktop 0.5.0__tar.gz → 0.7.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.5.0 → remotedesktop-0.7.0}/CLAUDE.md +3 -3
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/PKG-INFO +7 -1
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/readme.md +6 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/client.py +11 -6
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/discovery.py +14 -4
- remotedesktop-0.7.0/src/remotedesktop/frames.py +101 -0
- remotedesktop-0.7.0/src/remotedesktop/logs.py +46 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/protocol.py +30 -3
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/server.py +11 -2
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/sharing.py +148 -17
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/viewer.py +30 -2
- remotedesktop-0.7.0/tests/test_frames.py +65 -0
- remotedesktop-0.7.0/tests/test_logs.py +23 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_sharing.py +141 -1
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_smoke.py +16 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/.gitignore +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/LICENSE +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/pyproject.toml +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/run_claude.bat +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/run_client.bat +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/run_server.bat +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/conftest.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_client_window.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_config.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_input.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_server_window.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_window_state.py +0 -0
- {remotedesktop-0.5.0 → remotedesktop-0.7.0}/uv.lock +0 -0
|
@@ -8,7 +8,7 @@ A Python client/server remote desktop GUI application (PySide6) for Windows comp
|
|
|
8
8
|
|
|
9
9
|
- **Autodiscovery and connection** of servers on the LAN.
|
|
10
10
|
- **In scope:** desktop screen, keyboard, mouse, and clipboard.
|
|
11
|
-
- **Out of scope:** shared drives, devices, and multimedia (e.g., audio).
|
|
11
|
+
- **Out of scope:** shared drives, devices, and multimedia (e.g., audio). Smooth playback of fast-changing full-screen content (video, games) is a non-goal: the screen transfer is lossless and optimized for mostly-static desktop work, not for video bandwidth.
|
|
12
12
|
- **Two GUI apps:** a client and a server, each run on its respective computer.
|
|
13
13
|
- **Trust model:** on first connection, the user on the server side must explicitly permit the client. After that, the client may reconnect whenever the server is running without further approval.
|
|
14
14
|
- **Constraint:** does not use Windows RDP and does not rely on any Microsoft-based authentication.
|
|
@@ -39,10 +39,10 @@ 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`) 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 or
|
|
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).
|
|
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
|
-
- **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.
|
|
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.
|
|
46
46
|
- **Connection inventory** (`inventory.py`): both windows have a tab (server: "Clients on LAN"; client: "Servers on LAN") backed by a `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The server drives it from `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases). The client drives it from `ClientWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
|
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -36,6 +36,12 @@ Connections are made to the desktop screen, keyboard, mouse, and clipboard.
|
|
|
36
36
|
Other connections are not provided, such as shared drives, devices, or
|
|
37
37
|
multimedia (e.g., audio).
|
|
38
38
|
|
|
39
|
+
The screen transfer is optimized for ordinary desktop work — documents,
|
|
40
|
+
code, terminals — where most of the screen is static between frames: it is
|
|
41
|
+
lossless (pixel-exact at full resolution) and only changed screen regions
|
|
42
|
+
are transmitted. Smooth playback of fast-changing full-screen content, such
|
|
43
|
+
as video or games, is a non-goal.
|
|
44
|
+
|
|
39
45
|
The client and server are PySide6 (Qt) GUI apps, each run on its respective
|
|
40
46
|
computer. The client requests connection to the server, and for the initial
|
|
41
47
|
connection the user on the server side must permit the connection. After
|
|
@@ -17,6 +17,12 @@ Connections are made to the desktop screen, keyboard, mouse, and clipboard.
|
|
|
17
17
|
Other connections are not provided, such as shared drives, devices, or
|
|
18
18
|
multimedia (e.g., audio).
|
|
19
19
|
|
|
20
|
+
The screen transfer is optimized for ordinary desktop work — documents,
|
|
21
|
+
code, terminals — where most of the screen is static between frames: it is
|
|
22
|
+
lossless (pixel-exact at full resolution) and only changed screen regions
|
|
23
|
+
are transmitted. Smooth playback of fast-changing full-screen content, such
|
|
24
|
+
as video or games, is a non-goal.
|
|
25
|
+
|
|
20
26
|
The client and server are PySide6 (Qt) GUI apps, each run on its respective
|
|
21
27
|
computer. The client requests connection to the server, and for the initial
|
|
22
28
|
connection the user on the server side must permit the connection. After
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Client GUI application: discovers servers on the LAN, connects to one,
|
|
2
2
|
and shows its desktop in a viewer widget."""
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import sqlite3
|
|
5
6
|
import sys
|
|
6
7
|
import threading
|
|
@@ -22,7 +23,7 @@ from PySide6.QtWidgets import (
|
|
|
22
23
|
QWidget,
|
|
23
24
|
)
|
|
24
25
|
|
|
25
|
-
from remotedesktop import db, window_state
|
|
26
|
+
from remotedesktop import db, logs, window_state
|
|
26
27
|
from remotedesktop.clipboard import ClipboardSync
|
|
27
28
|
from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
|
|
28
29
|
from remotedesktop.discovery import DISCOVERY_PORT, ServerInfo, discover_servers
|
|
@@ -30,6 +31,8 @@ from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
|
30
31
|
from remotedesktop.sharing import ShareClient
|
|
31
32
|
from remotedesktop.viewer import ViewerWidget
|
|
32
33
|
|
|
34
|
+
_log = logging.getLogger("remotedesktop.client")
|
|
35
|
+
|
|
33
36
|
|
|
34
37
|
class DiscoveryPanel(QWidget):
|
|
35
38
|
"""Scans the LAN for servers and lists them for the user to pick."""
|
|
@@ -108,9 +111,7 @@ class ClientWindow(QMainWindow):
|
|
|
108
111
|
self.connection_log = QPlainTextEdit(self)
|
|
109
112
|
self.connection_log.setReadOnly(True)
|
|
110
113
|
self.connection_log.setMaximumBlockCount(1000)
|
|
111
|
-
|
|
112
|
-
log_dock.setWidget(self.connection_log)
|
|
113
|
-
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, log_dock)
|
|
114
|
+
tabs.addTab(self.connection_log, "Connection log")
|
|
114
115
|
|
|
115
116
|
self.discovery_panel.serverActivated.connect(self._on_server_activated)
|
|
116
117
|
self.discovery_panel.serversFound.connect(self._record_discovered)
|
|
@@ -135,6 +136,9 @@ class ClientWindow(QMainWindow):
|
|
|
135
136
|
self.discovery_panel.refresh()
|
|
136
137
|
|
|
137
138
|
def log(self, message: str) -> None:
|
|
139
|
+
# Everything shown in the Connection log pane also goes to the debug
|
|
140
|
+
# log file (when main() enabled it), so it survives the window.
|
|
141
|
+
_log.info(message)
|
|
138
142
|
self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
|
|
139
143
|
|
|
140
144
|
def _forget_server(self, key: str) -> None:
|
|
@@ -249,10 +253,11 @@ class ClientWindow(QMainWindow):
|
|
|
249
253
|
|
|
250
254
|
|
|
251
255
|
def main() -> None: # pragma: no cover - runs the Qt event loop
|
|
256
|
+
log_path = logs.init_logging("client")
|
|
252
257
|
app = QApplication(sys.argv)
|
|
253
|
-
window = ClientWindow()
|
|
258
|
+
window = ClientWindow() # auto_scan starts the first LAN scan
|
|
259
|
+
window.log(f"Detailed log: {log_path}")
|
|
254
260
|
window.show()
|
|
255
|
-
window.discovery_panel.refresh()
|
|
256
261
|
raise SystemExit(app.exec())
|
|
257
262
|
|
|
258
263
|
|
|
@@ -6,12 +6,15 @@ whose magic, protocol version, or type don't match are ignored.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import logging
|
|
9
10
|
import socket
|
|
10
11
|
import threading
|
|
11
12
|
import time
|
|
12
13
|
from collections.abc import Sequence
|
|
13
14
|
from dataclasses import dataclass
|
|
14
15
|
|
|
16
|
+
_log = logging.getLogger("remotedesktop.discovery")
|
|
17
|
+
|
|
15
18
|
DISCOVERY_PORT = 48653
|
|
16
19
|
DEFAULT_CONNECT_PORT = 48654
|
|
17
20
|
|
|
@@ -101,11 +104,13 @@ class DiscoveryResponder:
|
|
|
101
104
|
except OSError:
|
|
102
105
|
return
|
|
103
106
|
if _parse(data, "probe") is None:
|
|
107
|
+
_log.debug("Ignoring non-probe datagram (%d bytes) from %s", len(data), sender)
|
|
104
108
|
continue
|
|
109
|
+
_log.debug("Probe from %s — sending reply", sender)
|
|
105
110
|
try:
|
|
106
111
|
self._socket.sendto(self._reply, sender)
|
|
107
|
-
except OSError:
|
|
108
|
-
|
|
112
|
+
except OSError as error:
|
|
113
|
+
_log.warning("Could not reply to probe from %s: %s", sender, error)
|
|
109
114
|
|
|
110
115
|
|
|
111
116
|
def discover_servers(
|
|
@@ -126,8 +131,10 @@ def discover_servers(
|
|
|
126
131
|
for host in broadcast_hosts:
|
|
127
132
|
try:
|
|
128
133
|
sock.sendto(probe, (host, discovery_port))
|
|
129
|
-
except OSError:
|
|
130
|
-
|
|
134
|
+
except OSError as error:
|
|
135
|
+
# A failed broadcast means the scan quietly finds nothing —
|
|
136
|
+
# worth a record when diagnosing "no servers found".
|
|
137
|
+
_log.warning("Probe broadcast to %s:%d failed: %s", host, discovery_port, error)
|
|
131
138
|
deadline = time.monotonic() + timeout
|
|
132
139
|
while (remaining := deadline - time.monotonic()) > 0:
|
|
133
140
|
sock.settimeout(remaining)
|
|
@@ -137,10 +144,13 @@ def discover_servers(
|
|
|
137
144
|
break
|
|
138
145
|
message = _parse(data, "reply")
|
|
139
146
|
if message is None:
|
|
147
|
+
_log.debug("Ignoring non-reply datagram (%d bytes) from %s", len(data), host)
|
|
140
148
|
continue
|
|
141
149
|
name = message.get("name")
|
|
142
150
|
port = message.get("port")
|
|
143
151
|
if not isinstance(name, str) or not isinstance(port, int):
|
|
152
|
+
_log.debug("Ignoring malformed reply from %s: %r", host, message)
|
|
144
153
|
continue
|
|
145
154
|
found.setdefault((host, port), ServerInfo(name=name, host=host, port=port))
|
|
155
|
+
_log.debug("Discovery scan finished: %d server(s) found", len(found))
|
|
146
156
|
return list(found.values())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Inter-frame compression for the screen stream.
|
|
2
|
+
|
|
3
|
+
Consecutive captures are compared in horizontal bands (BAND_HEIGHT rows,
|
|
4
|
+
adjacent changed bands merged); only changed bands are encoded and shipped
|
|
5
|
+
in a delta payload, which the client patches onto its previous frame. Bands
|
|
6
|
+
keep the comparison and the crop cheap: a band is a contiguous byte range of
|
|
7
|
+
the image, so diffing is a C-speed memoryview compare.
|
|
8
|
+
|
|
9
|
+
Delta bands and keyframes for delta-capable clients are PNG — lossless, so
|
|
10
|
+
the client's canvas is pixel-identical to the capture and patches never
|
|
11
|
+
accumulate artifacts. (Legacy clients still get JPEG full frames: 0.5.0
|
|
12
|
+
force-decodes frames as JPEG, so they must never see PNG.)
|
|
13
|
+
|
|
14
|
+
Delta payload wire format: 4-byte big-endian header length, a JSON header
|
|
15
|
+
{"w", "h", "bands": [{"y", "h", "len"}, ...]}, then the bands' PNG bytes
|
|
16
|
+
concatenated in order.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import struct
|
|
21
|
+
|
|
22
|
+
from PySide6.QtCore import QBuffer
|
|
23
|
+
from PySide6.QtGui import QImage, QPainter
|
|
24
|
+
|
|
25
|
+
BAND_HEIGHT = 64
|
|
26
|
+
_HEADER_LEN = struct.Struct(">I")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encode_image(image: QImage, image_format: str = "PNG", quality: int = -1) -> bytes:
|
|
30
|
+
buffer = QBuffer()
|
|
31
|
+
buffer.open(QBuffer.OpenModeFlag.WriteOnly)
|
|
32
|
+
image.save(buffer, image_format, quality) # ty: ignore[no-matching-overload]
|
|
33
|
+
return bytes(buffer.data()) # ty: ignore[invalid-argument-type]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def changed_bands(previous: QImage, current: QImage) -> list[tuple[int, int]] | None:
|
|
37
|
+
"""Bands of `current` that differ from `previous`, as (y, height) pairs.
|
|
38
|
+
|
|
39
|
+
Adjacent changed bands are merged. Returns None when the images are not
|
|
40
|
+
comparable (different size or format) — the caller must fall back to a
|
|
41
|
+
full frame.
|
|
42
|
+
"""
|
|
43
|
+
if previous.size() != current.size() or previous.format() != current.format():
|
|
44
|
+
return None
|
|
45
|
+
prev_bits = previous.constBits()
|
|
46
|
+
cur_bits = current.constBits()
|
|
47
|
+
stride = current.bytesPerLine()
|
|
48
|
+
height = current.height()
|
|
49
|
+
bands: list[tuple[int, int]] = []
|
|
50
|
+
y = 0
|
|
51
|
+
while y < height:
|
|
52
|
+
h = min(BAND_HEIGHT, height - y)
|
|
53
|
+
if prev_bits[y * stride : (y + h) * stride] != cur_bits[y * stride : (y + h) * stride]:
|
|
54
|
+
if bands and bands[-1][0] + bands[-1][1] == y:
|
|
55
|
+
bands[-1] = (bands[-1][0], bands[-1][1] + h)
|
|
56
|
+
else:
|
|
57
|
+
bands.append((y, h))
|
|
58
|
+
y += h
|
|
59
|
+
return bands
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encode_delta(image: QImage, bands: list[tuple[int, int]]) -> bytes:
|
|
63
|
+
entries = []
|
|
64
|
+
blobs = []
|
|
65
|
+
for y, h in bands:
|
|
66
|
+
png = encode_image(image.copy(0, y, image.width(), h))
|
|
67
|
+
entries.append({"y": y, "h": h, "len": len(png)})
|
|
68
|
+
blobs.append(png)
|
|
69
|
+
header = json.dumps({"w": image.width(), "h": image.height(), "bands": entries}).encode()
|
|
70
|
+
return _HEADER_LEN.pack(len(header)) + header + b"".join(blobs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
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
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
(header_len,) = _HEADER_LEN.unpack_from(payload)
|
|
82
|
+
header = json.loads(payload[_HEADER_LEN.size : _HEADER_LEN.size + header_len].decode())
|
|
83
|
+
bands = [(int(b["y"]), int(b["h"]), int(b["len"])) for b in header["bands"]]
|
|
84
|
+
size_ok = int(header["w"]) == canvas.width() and int(header["h"]) == canvas.height()
|
|
85
|
+
except (struct.error, UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
if not size_ok:
|
|
88
|
+
return None
|
|
89
|
+
image = canvas.copy()
|
|
90
|
+
painter = QPainter(image)
|
|
91
|
+
offset = _HEADER_LEN.size + header_len
|
|
92
|
+
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
|
|
98
|
+
painter.drawImage(0, y, band)
|
|
99
|
+
finally:
|
|
100
|
+
painter.end()
|
|
101
|
+
return image
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Persistent debug logging for the client and server apps.
|
|
2
|
+
|
|
3
|
+
The GUI "Connection log" panes only live as long as their window, which makes
|
|
4
|
+
an intermittent connection problem impossible to diagnose after the fact.
|
|
5
|
+
`init_logging` attaches a rotating file handler to the "remotedesktop" logger
|
|
6
|
+
so everything the panes show — plus lower-level socket and protocol detail
|
|
7
|
+
that is logged directly — survives on disk with millisecond timestamps.
|
|
8
|
+
|
|
9
|
+
Only the app entry points call `init_logging`; library code and tests just
|
|
10
|
+
log to `logging.getLogger("remotedesktop...")`, which is a no-op without a
|
|
11
|
+
handler.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import logging.handlers
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import platformdirs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_log_dir() -> Path:
|
|
22
|
+
return Path(platformdirs.user_log_dir("remotedesktop", appauthor=False))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init_logging(app_name: str, *, directory: Path | None = None) -> Path:
|
|
26
|
+
"""Write "remotedesktop" logger output to <log dir>/<app_name>.log.
|
|
27
|
+
|
|
28
|
+
Returns the log file path. `directory` exists for tests; the apps use
|
|
29
|
+
the platformdirs log dir.
|
|
30
|
+
"""
|
|
31
|
+
log_dir = directory if directory is not None else default_log_dir()
|
|
32
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path = log_dir / f"{app_name}.log"
|
|
34
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
35
|
+
path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
|
|
36
|
+
)
|
|
37
|
+
handler.setFormatter(
|
|
38
|
+
logging.Formatter(
|
|
39
|
+
"%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s %(message)s",
|
|
40
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
logger = logging.getLogger("remotedesktop")
|
|
44
|
+
logger.addHandler(handler)
|
|
45
|
+
logger.setLevel(logging.DEBUG)
|
|
46
|
+
return path
|
|
@@ -6,6 +6,7 @@ input aborts the connection.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import logging
|
|
9
10
|
import struct
|
|
10
11
|
|
|
11
12
|
from PySide6.QtCore import QObject, Signal
|
|
@@ -13,9 +14,12 @@ from PySide6.QtNetwork import QTcpSocket
|
|
|
13
14
|
|
|
14
15
|
PROTOCOL_VERSION = 1
|
|
15
16
|
|
|
17
|
+
_log = logging.getLogger("remotedesktop.protocol")
|
|
18
|
+
|
|
16
19
|
_HEADER = struct.Struct(">IB")
|
|
17
20
|
_KIND_JSON = 0
|
|
18
21
|
_KIND_FRAME = 1
|
|
22
|
+
_KIND_DELTA = 2 # inter-frame delta (see frames.py); unknown to 0.5.0 peers
|
|
19
23
|
MAX_PAYLOAD = 64 * 1024 * 1024
|
|
20
24
|
|
|
21
25
|
|
|
@@ -28,6 +32,7 @@ class MessageStream(QObject):
|
|
|
28
32
|
|
|
29
33
|
jsonReceived = Signal(dict)
|
|
30
34
|
frameReceived = Signal(bytes)
|
|
35
|
+
deltaReceived = Signal(bytes)
|
|
31
36
|
|
|
32
37
|
def __init__(
|
|
33
38
|
self,
|
|
@@ -44,8 +49,11 @@ class MessageStream(QObject):
|
|
|
44
49
|
def send_json(self, message: dict) -> None:
|
|
45
50
|
self._send(_KIND_JSON, json.dumps(message).encode())
|
|
46
51
|
|
|
47
|
-
def send_frame(self,
|
|
48
|
-
self._send(_KIND_FRAME,
|
|
52
|
+
def send_frame(self, frame: bytes) -> None:
|
|
53
|
+
self._send(_KIND_FRAME, frame)
|
|
54
|
+
|
|
55
|
+
def send_delta(self, payload: bytes) -> None:
|
|
56
|
+
self._send(_KIND_DELTA, payload)
|
|
49
57
|
|
|
50
58
|
def _send(self, kind: int, payload: bytes) -> None:
|
|
51
59
|
self.socket.write(_HEADER.pack(len(payload), kind) + payload)
|
|
@@ -56,6 +64,15 @@ class MessageStream(QObject):
|
|
|
56
64
|
return
|
|
57
65
|
length, kind = _HEADER.unpack(self.socket.peek(_HEADER.size).data())
|
|
58
66
|
if length > self.max_payload:
|
|
67
|
+
_log.warning(
|
|
68
|
+
"Aborting %s:%s: payload of %d bytes (kind=%d) exceeds the "
|
|
69
|
+
"%d-byte cap",
|
|
70
|
+
self.socket.peerAddress().toString(),
|
|
71
|
+
self.socket.peerPort(),
|
|
72
|
+
length,
|
|
73
|
+
kind,
|
|
74
|
+
self.max_payload,
|
|
75
|
+
)
|
|
59
76
|
self.socket.abort()
|
|
60
77
|
return
|
|
61
78
|
if self.socket.bytesAvailable() < _HEADER.size + length:
|
|
@@ -65,11 +82,21 @@ class MessageStream(QObject):
|
|
|
65
82
|
if kind == _KIND_JSON:
|
|
66
83
|
try:
|
|
67
84
|
message = json.loads(bytes(payload).decode())
|
|
68
|
-
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
85
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
86
|
+
_log.warning(
|
|
87
|
+
"Aborting %s:%s: undecodable JSON control message "
|
|
88
|
+
"(%d bytes): %s",
|
|
89
|
+
self.socket.peerAddress().toString(),
|
|
90
|
+
self.socket.peerPort(),
|
|
91
|
+
length,
|
|
92
|
+
error,
|
|
93
|
+
)
|
|
69
94
|
self.socket.abort()
|
|
70
95
|
return
|
|
71
96
|
if isinstance(message, dict):
|
|
72
97
|
self.jsonReceived.emit(message)
|
|
73
98
|
elif kind == _KIND_FRAME:
|
|
74
99
|
self.frameReceived.emit(payload)
|
|
100
|
+
elif kind == _KIND_DELTA:
|
|
101
|
+
self.deltaReceived.emit(payload)
|
|
75
102
|
# Unknown kinds are skipped so the protocol can grow.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Server GUI application: shares this computer's desktop with permitted
|
|
2
2
|
clients and prompts the user to approve first-time connections."""
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import socket
|
|
5
6
|
import sqlite3
|
|
6
7
|
import sys
|
|
@@ -20,7 +21,7 @@ from PySide6.QtWidgets import (
|
|
|
20
21
|
QWidget,
|
|
21
22
|
)
|
|
22
23
|
|
|
23
|
-
from remotedesktop import db, tls, window_state
|
|
24
|
+
from remotedesktop import db, logs, tls, window_state
|
|
24
25
|
from remotedesktop.autostart import Autostart
|
|
25
26
|
from remotedesktop.clipboard import ClipboardSync
|
|
26
27
|
from remotedesktop.config import PairedClients, Settings, default_config_dir, default_db_path
|
|
@@ -32,6 +33,8 @@ from remotedesktop.discovery import (
|
|
|
32
33
|
from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
33
34
|
from remotedesktop.sharing import ShareServer
|
|
34
35
|
|
|
36
|
+
_log = logging.getLogger("remotedesktop.server")
|
|
37
|
+
|
|
35
38
|
|
|
36
39
|
class ServerWindow(QMainWindow):
|
|
37
40
|
def __init__(
|
|
@@ -61,7 +64,7 @@ class ServerWindow(QMainWindow):
|
|
|
61
64
|
status_layout = QVBoxLayout(status_tab)
|
|
62
65
|
status_layout.addWidget(self._summary)
|
|
63
66
|
status_layout.addWidget(self.autostart_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
64
|
-
status_layout.
|
|
67
|
+
status_layout.addStretch(1)
|
|
65
68
|
|
|
66
69
|
# Tests inject a connection to a temp database; the app uses the default.
|
|
67
70
|
self._db = connection if connection is not None else db.connect(default_db_path())
|
|
@@ -72,6 +75,7 @@ class ServerWindow(QMainWindow):
|
|
|
72
75
|
InventoryTab(self.inventory, "Revoke access", self._revoke_client),
|
|
73
76
|
"Clients on LAN",
|
|
74
77
|
)
|
|
78
|
+
tabs.addTab(self.connection_log, "Connection log")
|
|
75
79
|
self.setCentralWidget(tabs)
|
|
76
80
|
|
|
77
81
|
if credentials is None:
|
|
@@ -119,6 +123,9 @@ class ServerWindow(QMainWindow):
|
|
|
119
123
|
window_state.restore_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
|
|
120
124
|
|
|
121
125
|
def log(self, message: str) -> None:
|
|
126
|
+
# Everything shown in the Connection log pane also goes to the debug
|
|
127
|
+
# log file (when main() enabled it), so it survives the window.
|
|
128
|
+
_log.info(message)
|
|
122
129
|
self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
|
|
123
130
|
|
|
124
131
|
def _record_peer(self, event: dict) -> None:
|
|
@@ -188,8 +195,10 @@ class ServerWindow(QMainWindow):
|
|
|
188
195
|
|
|
189
196
|
|
|
190
197
|
def main() -> None: # pragma: no cover - runs the Qt event loop
|
|
198
|
+
log_path = logs.init_logging("server")
|
|
191
199
|
app = QApplication(sys.argv)
|
|
192
200
|
window = ServerWindow()
|
|
201
|
+
window.log(f"Detailed log: {log_path}")
|
|
193
202
|
window.show()
|
|
194
203
|
raise SystemExit(app.exec())
|
|
195
204
|
|
|
@@ -14,13 +14,15 @@ GUIs can show a debug log.
|
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
import hmac
|
|
17
|
+
import logging
|
|
17
18
|
import socket as socket_module
|
|
18
19
|
from collections.abc import Callable
|
|
19
20
|
from typing import cast
|
|
20
21
|
|
|
21
|
-
from PySide6.QtCore import
|
|
22
|
+
from PySide6.QtCore import QObject, QTimer, Signal
|
|
22
23
|
from PySide6.QtGui import QGuiApplication, QImage
|
|
23
24
|
from PySide6.QtNetwork import (
|
|
25
|
+
QAbstractSocket,
|
|
24
26
|
QHostAddress,
|
|
25
27
|
QSslCertificate,
|
|
26
28
|
QSslKey,
|
|
@@ -36,11 +38,19 @@ from remotedesktop.config import (
|
|
|
36
38
|
load_client_identity,
|
|
37
39
|
)
|
|
38
40
|
from remotedesktop.discovery import DEFAULT_CONNECT_PORT
|
|
41
|
+
from remotedesktop import frames
|
|
39
42
|
from remotedesktop.input_injection import InputInjector
|
|
40
43
|
from remotedesktop.protocol import MAX_PAYLOAD, PROTOCOL_VERSION, MessageStream
|
|
41
44
|
from remotedesktop import db, tls
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
_log = logging.getLogger("remotedesktop.sharing")
|
|
47
|
+
|
|
48
|
+
# 30 fps keeps key-to-glyph latency low (~17 ms average sampling delay).
|
|
49
|
+
# Affordable since inter-frame compression: an unchanged screen costs only
|
|
50
|
+
# a capture and a memory compare per tick — nothing is encoded or sent.
|
|
51
|
+
DEFAULT_FPS = 30
|
|
52
|
+
# Only for full frames to legacy (0.5.0) clients, which force-decode frames
|
|
53
|
+
# as JPEG. Delta-capable clients get lossless PNG keyframes and delta bands.
|
|
44
54
|
JPEG_QUALITY = 70
|
|
45
55
|
# Skip sending to a client whose socket buffer is this far behind.
|
|
46
56
|
_MAX_SEND_BACKLOG = 8 * 1024 * 1024
|
|
@@ -99,6 +109,16 @@ class ShareServer(QObject):
|
|
|
99
109
|
self._final: set[MessageStream] = set()
|
|
100
110
|
# What each controlling stream currently holds down: (buttons, vks).
|
|
101
111
|
self._pressed: dict[MessageStream, tuple[set[str], set[int]]] = {}
|
|
112
|
+
# Streams currently too far behind to receive frames (see
|
|
113
|
+
# _broadcast_frame); entering/leaving this set emits a status message.
|
|
114
|
+
self._backlogged: set[MessageStream] = set()
|
|
115
|
+
# Inter-frame compression state: which admitted streams understand
|
|
116
|
+
# delta frames (hello carried "delta": true), which need a full
|
|
117
|
+
# keyframe next broadcast (just admitted, just caught up after a
|
|
118
|
+
# backlog, or asked for one), and the previous capture to diff.
|
|
119
|
+
self._delta_capable: set[MessageStream] = set()
|
|
120
|
+
self._needs_keyframe: set[MessageStream] = set()
|
|
121
|
+
self._previous_frame: QImage | None = None
|
|
102
122
|
|
|
103
123
|
cert, key = credentials if credentials is not None else tls.ephemeral_credentials()
|
|
104
124
|
self._server = QSslServer(self)
|
|
@@ -144,6 +164,10 @@ class ShareServer(QObject):
|
|
|
144
164
|
self._prompting.clear()
|
|
145
165
|
self._final.clear()
|
|
146
166
|
self._pressed.clear()
|
|
167
|
+
self._backlogged.clear()
|
|
168
|
+
self._delta_capable.clear()
|
|
169
|
+
self._needs_keyframe.clear()
|
|
170
|
+
self._previous_frame = None
|
|
147
171
|
if had_clients:
|
|
148
172
|
self.clientCountChanged.emit(0)
|
|
149
173
|
self._server.close()
|
|
@@ -153,12 +177,18 @@ class ShareServer(QObject):
|
|
|
153
177
|
while self._server.hasPendingConnections():
|
|
154
178
|
# QSslServer hands out QSslSockets; the stubs say QTcpSocket.
|
|
155
179
|
sock = cast(QSslSocket, self._server.nextPendingConnection())
|
|
180
|
+
# TCP_NODELAY: input events and delta frames are small messages;
|
|
181
|
+
# Nagle + delayed ACK would add tens to hundreds of ms of latency.
|
|
182
|
+
sock.setSocketOption(QAbstractSocket.SocketOption.LowDelayOption, 1)
|
|
156
183
|
self.status.emit(
|
|
157
184
|
f"Incoming TLS connection from {_peer(sock)} "
|
|
158
185
|
f"(encrypted={sock.isEncrypted()}) — waiting for hello"
|
|
159
186
|
)
|
|
160
187
|
stream = MessageStream(sock, self, max_payload=_PREAUTH_MAX_PAYLOAD)
|
|
161
188
|
self._all_streams.add(stream)
|
|
189
|
+
sock.stateChanged.connect(
|
|
190
|
+
lambda state, s=sock: _log.debug("Socket %s state: %s", _peer(s), state.name)
|
|
191
|
+
)
|
|
162
192
|
sock.disconnected.connect(lambda s=stream: self._drop(s))
|
|
163
193
|
sock.errorOccurred.connect(
|
|
164
194
|
lambda _error, s=sock: self.status.emit(
|
|
@@ -177,6 +207,13 @@ class ShareServer(QObject):
|
|
|
177
207
|
self.status.emit(f"Clipboard update received from {_peer(stream.socket)}")
|
|
178
208
|
self._clipboard.apply(message)
|
|
179
209
|
return
|
|
210
|
+
if message.get("type") == "keyframe":
|
|
211
|
+
# The client lost sync with the delta stream (e.g. a band failed
|
|
212
|
+
# to decode) and wants a full frame to rebuild its canvas.
|
|
213
|
+
if stream in self._streams:
|
|
214
|
+
self.status.emit(f"Keyframe requested by {_peer(stream.socket)}")
|
|
215
|
+
self._needs_keyframe.add(stream)
|
|
216
|
+
return
|
|
180
217
|
if (
|
|
181
218
|
message.get("type") != "hello"
|
|
182
219
|
or stream in self._streams
|
|
@@ -224,6 +261,8 @@ class ShareServer(QObject):
|
|
|
224
261
|
client_id = message.get("client_id")
|
|
225
262
|
client_name = str(message.get("name", "unknown"))
|
|
226
263
|
self.status.emit(f'Hello from "{client_name}" ({client_id}) at {peer}')
|
|
264
|
+
if message.get("delta"):
|
|
265
|
+
self._delta_capable.add(stream)
|
|
227
266
|
if isinstance(client_id, str) and client_id:
|
|
228
267
|
self._stream_key[stream] = (client_id, client_name, peer)
|
|
229
268
|
self._emit_peer(stream, "attempt")
|
|
@@ -289,6 +328,7 @@ class ShareServer(QObject):
|
|
|
289
328
|
def _admit(self, stream: MessageStream, client_name: str, welcome: dict) -> None:
|
|
290
329
|
stream.max_payload = MAX_PAYLOAD # pre-auth cap lifted once approved
|
|
291
330
|
stream.send_json(welcome)
|
|
331
|
+
self._needs_keyframe.add(stream) # its first frame must be a full one
|
|
292
332
|
self._streams.append(stream)
|
|
293
333
|
self.clientCountChanged.emit(len(self._streams))
|
|
294
334
|
self.status.emit(
|
|
@@ -359,6 +399,9 @@ class ShareServer(QObject):
|
|
|
359
399
|
self._controllers.discard(stream)
|
|
360
400
|
self._all_streams.discard(stream)
|
|
361
401
|
self._prompting.discard(stream)
|
|
402
|
+
self._backlogged.discard(stream)
|
|
403
|
+
self._delta_capable.discard(stream)
|
|
404
|
+
self._needs_keyframe.discard(stream)
|
|
362
405
|
self._release_input(stream)
|
|
363
406
|
final = stream in self._final
|
|
364
407
|
self._final.discard(stream)
|
|
@@ -383,26 +426,73 @@ class ShareServer(QObject):
|
|
|
383
426
|
self.status.emit(f"Client disconnected — {len(self._streams)} viewer(s) remaining")
|
|
384
427
|
if not self._streams:
|
|
385
428
|
self._timer.stop()
|
|
429
|
+
self._previous_frame = None # don't hold a stale capture
|
|
386
430
|
self.status.emit("No viewers left — screen capture stopped")
|
|
387
431
|
|
|
388
|
-
def
|
|
389
|
-
|
|
390
|
-
|
|
432
|
+
def _capture(self) -> QImage | None:
|
|
433
|
+
"""Grab the primary screen at full resolution (tests override this
|
|
434
|
+
to drive deterministic frame content)."""
|
|
391
435
|
screen = QGuiApplication.primaryScreen()
|
|
392
436
|
if screen is None:
|
|
393
|
-
return
|
|
437
|
+
return None
|
|
394
438
|
image = screen.grabWindow(0).toImage()
|
|
395
|
-
if image.isNull()
|
|
439
|
+
return None if image.isNull() else image
|
|
440
|
+
|
|
441
|
+
def _broadcast_frame(self) -> None:
|
|
442
|
+
if not self._streams:
|
|
443
|
+
return
|
|
444
|
+
image = self._capture()
|
|
445
|
+
if image is None:
|
|
396
446
|
self.status.emit("Screen capture failed (null image)")
|
|
397
447
|
return
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
448
|
+
# bands is None when there is no comparable previous capture (first
|
|
449
|
+
# frame, or the resolution changed): everyone gets a full frame.
|
|
450
|
+
bands = (
|
|
451
|
+
frames.changed_bands(self._previous_frame, image)
|
|
452
|
+
if self._previous_frame is not None
|
|
453
|
+
else None
|
|
454
|
+
)
|
|
455
|
+
self._previous_frame = image
|
|
456
|
+
# Encode each variant at most once per tick, shared by all takers.
|
|
457
|
+
legacy_jpeg: bytes | None = None
|
|
458
|
+
keyframe_png: bytes | None = None
|
|
459
|
+
delta_payload: bytes | None = None
|
|
402
460
|
for stream in self._streams:
|
|
403
461
|
if stream.socket.bytesToWrite() > _MAX_SEND_BACKLOG:
|
|
404
|
-
|
|
405
|
-
|
|
462
|
+
# Client is not keeping up; drop frames for it (and say so —
|
|
463
|
+
# to the viewer this looks like a frozen or flaky connection).
|
|
464
|
+
# Its canvas will be stale once it catches up, so it must
|
|
465
|
+
# restart from a keyframe.
|
|
466
|
+
self._needs_keyframe.add(stream)
|
|
467
|
+
if stream not in self._backlogged:
|
|
468
|
+
self._backlogged.add(stream)
|
|
469
|
+
self.status.emit(
|
|
470
|
+
f"Viewer at {_peer(stream.socket)} is not keeping up "
|
|
471
|
+
f"({stream.socket.bytesToWrite() // 1024} KB unsent) — "
|
|
472
|
+
"dropping frames for it"
|
|
473
|
+
)
|
|
474
|
+
continue
|
|
475
|
+
if stream in self._backlogged:
|
|
476
|
+
self._backlogged.discard(stream)
|
|
477
|
+
self.status.emit(
|
|
478
|
+
f"Viewer at {_peer(stream.socket)} caught up — resuming frames"
|
|
479
|
+
)
|
|
480
|
+
if stream not in self._delta_capable:
|
|
481
|
+
# Legacy (0.5.0) client: full JPEG every tick, as before.
|
|
482
|
+
if legacy_jpeg is None:
|
|
483
|
+
legacy_jpeg = frames.encode_image(image, "JPEG", JPEG_QUALITY)
|
|
484
|
+
stream.send_frame(legacy_jpeg)
|
|
485
|
+
elif stream in self._needs_keyframe or bands is None:
|
|
486
|
+
if keyframe_png is None:
|
|
487
|
+
keyframe_png = frames.encode_image(image)
|
|
488
|
+
_log.debug("Keyframe: %d KB PNG", len(keyframe_png) // 1024)
|
|
489
|
+
stream.send_frame(keyframe_png)
|
|
490
|
+
self._needs_keyframe.discard(stream)
|
|
491
|
+
elif bands:
|
|
492
|
+
if delta_payload is None:
|
|
493
|
+
delta_payload = frames.encode_delta(image, bands)
|
|
494
|
+
stream.send_delta(delta_payload)
|
|
495
|
+
# else: nothing changed since the last frame — send nothing.
|
|
406
496
|
|
|
407
497
|
|
|
408
498
|
class ShareClient(QObject):
|
|
@@ -435,9 +525,14 @@ class ShareClient(QObject):
|
|
|
435
525
|
self._server_key = ""
|
|
436
526
|
self._server_fingerprint = ""
|
|
437
527
|
self._server_token: str | None = None
|
|
528
|
+
self._frame_count = 0
|
|
529
|
+
self._last_image: QImage | None = None # delta patches build on this
|
|
438
530
|
self._socket = QSslSocket(self)
|
|
439
531
|
self._socket.setSslConfiguration(tls.client_configuration())
|
|
440
532
|
self._stream = MessageStream(self._socket, self)
|
|
533
|
+
self._socket.stateChanged.connect(
|
|
534
|
+
lambda state: _log.debug("Client socket state: %s", state.name)
|
|
535
|
+
)
|
|
441
536
|
self._socket.encrypted.connect(self._on_encrypted)
|
|
442
537
|
self._socket.sslErrors.connect(self._on_ssl_errors)
|
|
443
538
|
self._socket.disconnected.connect(self._on_disconnected)
|
|
@@ -446,10 +541,13 @@ class ShareClient(QObject):
|
|
|
446
541
|
)
|
|
447
542
|
self._stream.jsonReceived.connect(self._on_message)
|
|
448
543
|
self._stream.frameReceived.connect(self._on_frame)
|
|
544
|
+
self._stream.deltaReceived.connect(self._on_delta)
|
|
449
545
|
|
|
450
546
|
def connect_to(self, host: str, port: int) -> None:
|
|
451
547
|
self._socket.abort() # drop any previous connection or attempt
|
|
452
548
|
self._got_first_frame = False
|
|
549
|
+
self._frame_count = 0
|
|
550
|
+
self._last_image = None
|
|
453
551
|
self._server_key = f"{host}:{port}"
|
|
454
552
|
record = self._known.get(self._server_key) if self._known else None
|
|
455
553
|
self._server_token = record.get("token") if record else None
|
|
@@ -477,6 +575,9 @@ class ShareClient(QObject):
|
|
|
477
575
|
self._socket.ignoreSslErrors()
|
|
478
576
|
|
|
479
577
|
def _on_encrypted(self) -> None:
|
|
578
|
+
# TCP_NODELAY (settable only once connected): keystrokes are tiny
|
|
579
|
+
# messages; Nagle + delayed ACK would add tens to hundreds of ms.
|
|
580
|
+
self._socket.setSocketOption(QAbstractSocket.SocketOption.LowDelayOption, 1)
|
|
480
581
|
cert = self._socket.peerCertificate()
|
|
481
582
|
fingerprint = "" if cert.isNull() else tls.certificate_fingerprint(cert)
|
|
482
583
|
self._server_fingerprint = fingerprint
|
|
@@ -492,6 +593,7 @@ class ShareClient(QObject):
|
|
|
492
593
|
"version": PROTOCOL_VERSION,
|
|
493
594
|
"client_id": self._client_id,
|
|
494
595
|
"name": self._name,
|
|
596
|
+
"delta": True, # we understand inter-frame delta messages
|
|
495
597
|
}
|
|
496
598
|
if self._server_token:
|
|
497
599
|
hello["token"] = self._server_token
|
|
@@ -538,15 +640,44 @@ class ShareClient(QObject):
|
|
|
538
640
|
self.status.emit("Clipboard update received from server")
|
|
539
641
|
self._clipboard.apply(message)
|
|
540
642
|
|
|
541
|
-
def _on_frame(self,
|
|
542
|
-
|
|
643
|
+
def _on_frame(self, data: bytes) -> None:
|
|
644
|
+
# Full frame — PNG keyframe or (legacy servers) JPEG; sniffed from
|
|
645
|
+
# the payload's magic bytes.
|
|
646
|
+
image = QImage.fromData(data)
|
|
543
647
|
if image.isNull():
|
|
544
|
-
self.status.emit(f"Received undecodable frame ({len(
|
|
648
|
+
self.status.emit(f"Received undecodable frame ({len(data)} bytes)")
|
|
545
649
|
return
|
|
650
|
+
self._deliver(image, len(data))
|
|
651
|
+
|
|
652
|
+
def _on_delta(self, payload: bytes) -> None:
|
|
653
|
+
if self._last_image is None:
|
|
654
|
+
self.status.emit("Delta frame arrived before a keyframe — requesting one")
|
|
655
|
+
self._stream.send_json({"type": "keyframe"})
|
|
656
|
+
return
|
|
657
|
+
image = frames.apply_delta(self._last_image, payload)
|
|
658
|
+
if image is None:
|
|
659
|
+
self.status.emit("Undecodable delta frame — requesting a keyframe")
|
|
660
|
+
self._stream.send_json({"type": "keyframe"})
|
|
661
|
+
return
|
|
662
|
+
self._deliver(image, len(payload))
|
|
663
|
+
|
|
664
|
+
def _deliver(self, image: QImage, byte_count: int) -> None:
|
|
665
|
+
self._last_image = image
|
|
546
666
|
if not self._got_first_frame:
|
|
547
667
|
self._got_first_frame = True
|
|
548
668
|
self.status.emit(
|
|
549
669
|
f"First frame received: {image.width()}x{image.height()} "
|
|
550
|
-
f"({
|
|
670
|
+
f"({byte_count // 1024} KB)"
|
|
671
|
+
)
|
|
672
|
+
self._frame_count += 1
|
|
673
|
+
# A heartbeat in the debug log (~10 s at the default fps): gaps between
|
|
674
|
+
# these lines show exactly when the stream stalled.
|
|
675
|
+
if self._frame_count % 100 == 0:
|
|
676
|
+
_log.debug(
|
|
677
|
+
"Received %d frames (latest %dx%d, %d KB)",
|
|
678
|
+
self._frame_count,
|
|
679
|
+
image.width(),
|
|
680
|
+
image.height(),
|
|
681
|
+
byte_count // 1024,
|
|
551
682
|
)
|
|
552
683
|
self.frameReceived.emit(image)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Viewer widget that displays the remote desktop and forwards input to it."""
|
|
2
2
|
|
|
3
|
-
from PySide6.QtCore import QPointF, QRectF, Qt, Signal
|
|
3
|
+
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
|
|
4
4
|
from PySide6.QtGui import (
|
|
5
5
|
QFocusEvent,
|
|
6
6
|
QImage,
|
|
@@ -29,6 +29,14 @@ class ViewerWidget(QWidget):
|
|
|
29
29
|
def __init__(self, parent: QWidget | None = None) -> None:
|
|
30
30
|
super().__init__(parent)
|
|
31
31
|
self._frame: QPixmap | None = None
|
|
32
|
+
# The frame stays at the server's full resolution; what is painted is
|
|
33
|
+
# a SmoothTransformation-scaled copy (proper filtering, unlike the
|
|
34
|
+
# nearest-neighbor scaling of drawPixmap into a rect), cached until
|
|
35
|
+
# the frame or the display size changes. It is scaled to *device*
|
|
36
|
+
# pixels and stamped with the devicePixelRatio: sizing it in logical
|
|
37
|
+
# pixels would make Qt bilinear-upscale it again by the Windows
|
|
38
|
+
# display-scaling factor (125-150% on most monitors), blurring text.
|
|
39
|
+
self._scaled: QPixmap | None = None
|
|
32
40
|
self._message = "Not connected"
|
|
33
41
|
# Buttons/keys currently held, so releases can be forwarded even when
|
|
34
42
|
# they happen outside the frame or when the widget loses focus —
|
|
@@ -45,10 +53,12 @@ class ViewerWidget(QWidget):
|
|
|
45
53
|
|
|
46
54
|
def show_frame(self, image: QImage) -> None:
|
|
47
55
|
self._frame = QPixmap.fromImage(image)
|
|
56
|
+
self._scaled = None
|
|
48
57
|
self.update()
|
|
49
58
|
|
|
50
59
|
def clear(self, message: str = "Not connected") -> None:
|
|
51
60
|
self._frame = None
|
|
61
|
+
self._scaled = None
|
|
52
62
|
self._message = message
|
|
53
63
|
self._pressed_buttons.clear()
|
|
54
64
|
self._pressed_keys.clear()
|
|
@@ -167,4 +177,22 @@ class ViewerWidget(QWidget):
|
|
|
167
177
|
painter.setPen(Qt.GlobalColor.white)
|
|
168
178
|
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._message)
|
|
169
179
|
return
|
|
170
|
-
|
|
180
|
+
rect = self._display_rect().toRect()
|
|
181
|
+
dpr = self.devicePixelRatioF()
|
|
182
|
+
target = QSize(round(rect.width() * dpr), round(rect.height() * dpr))
|
|
183
|
+
if (
|
|
184
|
+
self._scaled is None
|
|
185
|
+
or self._scaled.size() != target
|
|
186
|
+
or self._scaled.devicePixelRatio() != dpr
|
|
187
|
+
):
|
|
188
|
+
if self._frame.size() == target:
|
|
189
|
+
# Displayed at exactly 1:1 device pixels — no resample at all.
|
|
190
|
+
self._scaled = QPixmap(self._frame)
|
|
191
|
+
else:
|
|
192
|
+
self._scaled = self._frame.scaled(
|
|
193
|
+
target,
|
|
194
|
+
Qt.AspectRatioMode.IgnoreAspectRatio, # rect is already aspect-correct
|
|
195
|
+
Qt.TransformationMode.SmoothTransformation,
|
|
196
|
+
)
|
|
197
|
+
self._scaled.setDevicePixelRatio(dpr)
|
|
198
|
+
painter.drawPixmap(rect.topLeft(), self._scaled)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from PySide6.QtGui import QColor, QImage, QPainter
|
|
2
|
+
|
|
3
|
+
from remotedesktop import frames
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def solid(width, height, color):
|
|
7
|
+
image = QImage(width, height, QImage.Format.Format_RGB32)
|
|
8
|
+
image.fill(QColor(color))
|
|
9
|
+
return image
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fill_rect(image, x, y, w, h, color):
|
|
13
|
+
painter = QPainter(image)
|
|
14
|
+
painter.fillRect(x, y, w, h, QColor(color))
|
|
15
|
+
painter.end()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_identical_images_have_no_changed_bands(qapp):
|
|
19
|
+
a = solid(64, 200, "red")
|
|
20
|
+
assert frames.changed_bands(a, solid(64, 200, "red")) == []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_a_change_is_localized_to_its_band(qapp):
|
|
24
|
+
a = solid(64, 200, "red")
|
|
25
|
+
b = solid(64, 200, "red")
|
|
26
|
+
fill_rect(b, 10, 70, 4, 4, "blue") # inside the second 64-row band
|
|
27
|
+
assert frames.changed_bands(a, b) == [(64, 64)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_adjacent_changed_bands_merge(qapp):
|
|
31
|
+
a = solid(64, 200, "red")
|
|
32
|
+
b = solid(64, 200, "red")
|
|
33
|
+
fill_rect(b, 0, 10, 4, 4, "blue")
|
|
34
|
+
fill_rect(b, 0, 70, 4, 4, "blue")
|
|
35
|
+
assert frames.changed_bands(a, b) == [(0, 128)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_incomparable_images_return_none(qapp):
|
|
39
|
+
assert frames.changed_bands(solid(64, 200, "red"), solid(64, 100, "red")) is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_delta_round_trip_is_lossless(qapp):
|
|
43
|
+
base = solid(64, 128, "red")
|
|
44
|
+
modified = solid(64, 128, "red")
|
|
45
|
+
fill_rect(modified, 0, 64, 64, 64, "blue")
|
|
46
|
+
bands = frames.changed_bands(base, modified)
|
|
47
|
+
assert bands is not None and bands == [(64, 64)]
|
|
48
|
+
payload = frames.encode_delta(modified, bands)
|
|
49
|
+
patched = frames.apply_delta(base, payload)
|
|
50
|
+
assert patched is not None
|
|
51
|
+
# PNG is lossless, so the patched canvas is pixel-identical.
|
|
52
|
+
assert patched == modified
|
|
53
|
+
assert patched.pixelColor(5, 5).name() == "#ff0000"
|
|
54
|
+
assert patched.pixelColor(5, 100).name() == "#0000ff"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_delta_for_a_different_size_is_rejected(qapp):
|
|
58
|
+
modified = solid(64, 128, "blue")
|
|
59
|
+
payload = frames.encode_delta(modified, [(0, 64)])
|
|
60
|
+
assert frames.apply_delta(solid(32, 32, "red"), payload) is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_malformed_delta_is_rejected(qapp):
|
|
64
|
+
assert frames.apply_delta(solid(32, 32, "red"), b"garbage") is None
|
|
65
|
+
assert frames.apply_delta(solid(32, 32, "red"), b"") is None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from remotedesktop import logs
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_init_logging_writes_formatted_records(tmp_path):
|
|
7
|
+
path = logs.init_logging("client", directory=tmp_path)
|
|
8
|
+
root = logging.getLogger("remotedesktop")
|
|
9
|
+
try:
|
|
10
|
+
assert path == tmp_path / "client.log"
|
|
11
|
+
logging.getLogger("remotedesktop.sharing").debug("hello from the test")
|
|
12
|
+
for handler in root.handlers:
|
|
13
|
+
handler.flush()
|
|
14
|
+
text = path.read_text(encoding="utf-8")
|
|
15
|
+
assert "hello from the test" in text
|
|
16
|
+
assert "remotedesktop.sharing" in text
|
|
17
|
+
assert "DEBUG" in text
|
|
18
|
+
finally:
|
|
19
|
+
# Detach the file handler so other tests (and later init calls)
|
|
20
|
+
# don't keep writing into this tmp_path file.
|
|
21
|
+
for handler in list(root.handlers):
|
|
22
|
+
root.removeHandler(handler)
|
|
23
|
+
handler.close()
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import time
|
|
2
3
|
|
|
3
4
|
from PySide6.QtCore import QEventLoop
|
|
4
|
-
from PySide6.
|
|
5
|
+
from PySide6.QtGui import QColor, QImage, QPainter
|
|
6
|
+
from PySide6.QtNetwork import QAbstractSocket, QSslSocket
|
|
5
7
|
|
|
6
8
|
from remotedesktop import db, tls
|
|
7
9
|
from remotedesktop.config import KnownServers, PairedClients
|
|
@@ -397,6 +399,144 @@ def test_disconnected_client_paths_are_noops(qapp, tmp_path):
|
|
|
397
399
|
client.close()
|
|
398
400
|
|
|
399
401
|
|
|
402
|
+
def test_low_delay_is_set_on_both_ends(qapp, credentials, tmp_path):
|
|
403
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
404
|
+
client = make_client(tmp_path)
|
|
405
|
+
names = []
|
|
406
|
+
client.connected.connect(names.append)
|
|
407
|
+
client.connect_to("127.0.0.1", server.port)
|
|
408
|
+
try:
|
|
409
|
+
pump(qapp, lambda: names and server._streams)
|
|
410
|
+
option = QAbstractSocket.SocketOption.LowDelayOption
|
|
411
|
+
assert client._socket.socketOption(option) == 1
|
|
412
|
+
assert server._streams[0].socket.socketOption(option) == 1
|
|
413
|
+
finally:
|
|
414
|
+
client.close()
|
|
415
|
+
server.close()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def solid_image(color, *, width=64, height=128):
|
|
419
|
+
image = QImage(width, height, QImage.Format.Format_RGB32)
|
|
420
|
+
image.fill(QColor(color))
|
|
421
|
+
return image
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def test_delta_streaming_sends_only_changes(qapp, credentials, tmp_path):
|
|
425
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
426
|
+
captures = {"image": solid_image("red")}
|
|
427
|
+
server._capture = lambda: captures["image"]
|
|
428
|
+
client = make_client(tmp_path)
|
|
429
|
+
images, raw_frames, deltas = [], [], []
|
|
430
|
+
client.frameReceived.connect(images.append)
|
|
431
|
+
client._stream.frameReceived.connect(raw_frames.append)
|
|
432
|
+
client._stream.deltaReceived.connect(deltas.append)
|
|
433
|
+
client.connect_to("127.0.0.1", server.port)
|
|
434
|
+
try:
|
|
435
|
+
# First frame is a full PNG keyframe.
|
|
436
|
+
pump(qapp, lambda: images)
|
|
437
|
+
assert raw_frames[0][:4] == b"\x89PNG"
|
|
438
|
+
assert images[0].pixelColor(5, 5).name() == "#ff0000"
|
|
439
|
+
|
|
440
|
+
# Static screen: nothing more is sent at all.
|
|
441
|
+
pump(qapp, lambda: True, timeout=0.4)
|
|
442
|
+
assert len(images) == 1 and not deltas
|
|
443
|
+
|
|
444
|
+
# Change the bottom band: exactly that region arrives as a delta.
|
|
445
|
+
changed = solid_image("red")
|
|
446
|
+
painter = QPainter(changed)
|
|
447
|
+
painter.fillRect(0, 64, 64, 64, QColor("blue"))
|
|
448
|
+
painter.end()
|
|
449
|
+
captures["image"] = changed
|
|
450
|
+
pump(qapp, lambda: deltas and len(images) >= 2)
|
|
451
|
+
assert len(raw_frames) == 1 # no second full frame was needed
|
|
452
|
+
latest = images[-1]
|
|
453
|
+
assert latest.pixelColor(5, 5).name() == "#ff0000" # untouched region intact
|
|
454
|
+
assert latest.pixelColor(5, 100).name() == "#0000ff" # patched region updated
|
|
455
|
+
finally:
|
|
456
|
+
client.close()
|
|
457
|
+
server.close()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_legacy_client_still_gets_full_jpeg_frames(qapp, credentials, tmp_path):
|
|
461
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
462
|
+
server._capture = lambda: solid_image("red") # static screen
|
|
463
|
+
sock, stream = raw_tls_stream(qapp, server.port)
|
|
464
|
+
received = []
|
|
465
|
+
stream.frameReceived.connect(received.append)
|
|
466
|
+
# A 0.5.0 hello has no "delta" capability flag.
|
|
467
|
+
stream.send_json(
|
|
468
|
+
{"type": "hello", "version": PROTOCOL_VERSION, "client_id": "legacy-1", "name": "old"}
|
|
469
|
+
)
|
|
470
|
+
try:
|
|
471
|
+
# Legacy clients get a full frame every tick even with a static
|
|
472
|
+
# screen, and it must be JPEG (0.5.0 force-decodes frames as JPEG).
|
|
473
|
+
pump(qapp, lambda: len(received) >= 3)
|
|
474
|
+
assert all(frame[:3] == b"\xff\xd8\xff" for frame in received)
|
|
475
|
+
finally:
|
|
476
|
+
sock.abort()
|
|
477
|
+
server.close()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_desynced_client_requests_and_gets_a_keyframe(qapp, credentials, tmp_path):
|
|
481
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
482
|
+
captures = {"image": solid_image("red")}
|
|
483
|
+
server._capture = lambda: captures["image"]
|
|
484
|
+
client = make_client(tmp_path)
|
|
485
|
+
images, raw_frames = [], []
|
|
486
|
+
client.frameReceived.connect(images.append)
|
|
487
|
+
client._stream.frameReceived.connect(raw_frames.append)
|
|
488
|
+
client.connect_to("127.0.0.1", server.port)
|
|
489
|
+
try:
|
|
490
|
+
pump(qapp, lambda: images)
|
|
491
|
+
# Corrupt the client's canvas size so the next delta is rejected and
|
|
492
|
+
# a keyframe is requested.
|
|
493
|
+
client._last_image = solid_image("red", width=8, height=8)
|
|
494
|
+
captures["image"] = solid_image("green")
|
|
495
|
+
pump(qapp, lambda: len(raw_frames) >= 2) # the recovery keyframe
|
|
496
|
+
pump(qapp, lambda: images and images[-1].pixelColor(5, 5).name() == "#008000")
|
|
497
|
+
finally:
|
|
498
|
+
client.close()
|
|
499
|
+
server.close()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def test_backlogged_viewer_frame_drop_is_reported(qapp, credentials, tmp_path, monkeypatch):
|
|
503
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
504
|
+
statuses: list[str] = []
|
|
505
|
+
server.status.connect(statuses.append)
|
|
506
|
+
client = make_client(tmp_path)
|
|
507
|
+
frames = []
|
|
508
|
+
client.frameReceived.connect(frames.append)
|
|
509
|
+
# A negative cap makes every stream count as backlogged, so no frames go out.
|
|
510
|
+
monkeypatch.setattr("remotedesktop.sharing._MAX_SEND_BACKLOG", -1)
|
|
511
|
+
client.connect_to("127.0.0.1", server.port)
|
|
512
|
+
try:
|
|
513
|
+
pump(qapp, lambda: any("not keeping up" in s for s in statuses))
|
|
514
|
+
assert sum("not keeping up" in s for s in statuses) == 1 # reported once, not per frame
|
|
515
|
+
assert not frames
|
|
516
|
+
# Once the backlog clears the server says so and frames resume.
|
|
517
|
+
monkeypatch.setattr("remotedesktop.sharing._MAX_SEND_BACKLOG", 8 * 1024 * 1024)
|
|
518
|
+
pump(qapp, lambda: frames)
|
|
519
|
+
assert any("caught up" in s for s in statuses)
|
|
520
|
+
finally:
|
|
521
|
+
client.close()
|
|
522
|
+
server.close()
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def test_oversized_preauth_message_aborts_and_logs(qapp, credentials, tmp_path, caplog):
|
|
526
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
527
|
+
sock, stream = raw_tls_stream(qapp, server.port)
|
|
528
|
+
caplog.set_level(logging.DEBUG, logger="remotedesktop")
|
|
529
|
+
try:
|
|
530
|
+
# Larger than the pre-auth 64 KB cap: the server aborts the socket
|
|
531
|
+
# and the reason must land in the debug log.
|
|
532
|
+
stream.send_frame(b"x" * (128 * 1024))
|
|
533
|
+
pump(qapp, lambda: sock.state() != QSslSocket.SocketState.ConnectedState)
|
|
534
|
+
assert any("exceeds the" in r.getMessage() for r in caplog.records)
|
|
535
|
+
finally:
|
|
536
|
+
sock.abort()
|
|
537
|
+
server.close()
|
|
538
|
+
|
|
539
|
+
|
|
400
540
|
def test_server_reports_phases_in_status(qapp, credentials, tmp_path):
|
|
401
541
|
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
402
542
|
statuses: list[str] = []
|
|
@@ -56,3 +56,19 @@ def test_viewer_widget_shows_and_clears_frames(qapp) -> None:
|
|
|
56
56
|
assert viewer.has_frame
|
|
57
57
|
viewer.clear("gone")
|
|
58
58
|
assert not viewer.has_frame
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_viewer_scales_frames_in_device_pixels(qapp) -> None:
|
|
62
|
+
from PySide6.QtGui import QImage
|
|
63
|
+
|
|
64
|
+
viewer = ViewerWidget()
|
|
65
|
+
viewer.resize(320, 240)
|
|
66
|
+
viewer.show_frame(QImage(640, 480, QImage.Format.Format_RGB32)) # same 4:3 aspect
|
|
67
|
+
viewer.grab() # forces a paint pass without showing a window
|
|
68
|
+
assert viewer._scaled is not None
|
|
69
|
+
dpr = viewer.devicePixelRatioF()
|
|
70
|
+
# Scaled to physical pixels and stamped with the ratio, so painting it
|
|
71
|
+
# never resamples a second time.
|
|
72
|
+
assert viewer._scaled.devicePixelRatio() == dpr
|
|
73
|
+
assert viewer._scaled.size().width() == round(320 * dpr)
|
|
74
|
+
assert viewer._scaled.size().height() == round(240 * dpr)
|
|
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
|