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.
Files changed (47) hide show
  1. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/CLAUDE.md +3 -3
  2. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/PKG-INFO +7 -1
  3. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/readme.md +6 -0
  4. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/client.py +11 -6
  6. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/discovery.py +14 -4
  7. remotedesktop-0.7.0/src/remotedesktop/frames.py +101 -0
  8. remotedesktop-0.7.0/src/remotedesktop/logs.py +46 -0
  9. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/protocol.py +30 -3
  10. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/server.py +11 -2
  11. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/sharing.py +148 -17
  12. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/viewer.py +30 -2
  13. remotedesktop-0.7.0/tests/test_frames.py +65 -0
  14. remotedesktop-0.7.0/tests/test_logs.py +23 -0
  15. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_sharing.py +141 -1
  16. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_smoke.py +16 -0
  17. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/.github/workflows/ci.yml +0 -0
  18. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/.gitignore +0 -0
  19. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/LICENSE +0 -0
  20. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/pyproject.toml +0 -0
  21. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/run_claude.bat +0 -0
  22. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/run_client.bat +0 -0
  23. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/run_server.bat +0 -0
  24. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/scripts/make_coverage_badge.py +0 -0
  25. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/scripts/make_venv.bat +0 -0
  26. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/autostart.py +0 -0
  27. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/clipboard.py +0 -0
  28. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/config.py +0 -0
  29. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/db.py +0 -0
  30. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/input_injection.py +0 -0
  31. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/inventory.py +0 -0
  32. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/tls.py +0 -0
  33. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/src/remotedesktop/window_state.py +0 -0
  34. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/conftest.py +0 -0
  35. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_autostart.py +0 -0
  36. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_client_window.py +0 -0
  37. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_clipboard.py +0 -0
  38. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_config.py +0 -0
  39. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_discovery.py +0 -0
  40. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_input.py +0 -0
  41. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_input_injection.py +0 -0
  42. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_inventory.py +0 -0
  43. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_protocol.py +0 -0
  44. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_server_window.py +0 -0
  45. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_tls.py +0 -0
  46. {remotedesktop-0.5.0 → remotedesktop-0.7.0}/tests/test_window_state.py +0 -0
  47. {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 JPEG frames; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen via `QScreen.grabWindow(0)`, JPEG-encodes once, and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. `ShareClient` decodes frames to QImage for the viewer.
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.5.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,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
@@ -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
- log_dock = QDockWidget("Connection log", self)
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
- pass
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
- pass
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, jpeg: bytes) -> None:
48
- self._send(_KIND_FRAME, jpeg)
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.addWidget(self.connection_log, stretch=1)
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 QBuffer, QObject, QTimer, Signal
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
- DEFAULT_FPS = 10
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 _broadcast_frame(self) -> None:
389
- if not self._streams:
390
- return
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
- buffer = QBuffer()
399
- buffer.open(QBuffer.OpenModeFlag.WriteOnly)
400
- image.save(buffer, "JPEG", JPEG_QUALITY) # ty: ignore[no-matching-overload]
401
- jpeg = bytes(buffer.data()) # ty: ignore[invalid-argument-type]
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
- continue # client is not keeping up; drop this frame for it
405
- stream.send_frame(jpeg)
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, jpeg: bytes) -> None:
542
- image = QImage.fromData(jpeg, "JPEG") # ty: ignore[invalid-argument-type]
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(jpeg)} bytes)")
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"({len(jpeg) // 1024} KB as JPEG)"
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
- painter.drawPixmap(self._display_rect().toRect(), self._frame)
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.QtNetwork import QSslSocket
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