remotedesktop 0.5.0__tar.gz → 0.6.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.6.0}/CLAUDE.md +3 -3
  2. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/PKG-INFO +7 -1
  3. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/readme.md +6 -0
  4. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/client.py +10 -3
  6. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/discovery.py +14 -4
  7. remotedesktop-0.6.0/src/remotedesktop/frames.py +101 -0
  8. remotedesktop-0.6.0/src/remotedesktop/logs.py +46 -0
  9. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/protocol.py +30 -3
  10. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/server.py +9 -1
  11. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/sharing.py +137 -16
  12. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/viewer.py +15 -1
  13. remotedesktop-0.6.0/tests/test_frames.py +65 -0
  14. remotedesktop-0.6.0/tests/test_logs.py +23 -0
  15. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_sharing.py +124 -0
  16. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/.github/workflows/ci.yml +0 -0
  17. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/.gitignore +0 -0
  18. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/LICENSE +0 -0
  19. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/pyproject.toml +0 -0
  20. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/run_claude.bat +0 -0
  21. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/run_client.bat +0 -0
  22. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/run_server.bat +0 -0
  23. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/scripts/make_coverage_badge.py +0 -0
  24. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/scripts/make_venv.bat +0 -0
  25. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/autostart.py +0 -0
  26. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/clipboard.py +0 -0
  27. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/config.py +0 -0
  28. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/db.py +0 -0
  29. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/input_injection.py +0 -0
  30. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/inventory.py +0 -0
  31. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/tls.py +0 -0
  32. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/src/remotedesktop/window_state.py +0 -0
  33. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/conftest.py +0 -0
  34. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_autostart.py +0 -0
  35. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_client_window.py +0 -0
  36. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_clipboard.py +0 -0
  37. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_config.py +0 -0
  38. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_discovery.py +0 -0
  39. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_input.py +0 -0
  40. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_input_injection.py +0 -0
  41. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_inventory.py +0 -0
  42. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_protocol.py +0 -0
  43. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_server_window.py +0 -0
  44. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_smoke.py +0 -0
  45. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_tls.py +0 -0
  46. {remotedesktop-0.5.0 → remotedesktop-0.6.0}/tests/test_window_state.py +0 -0
  47. {remotedesktop-0.5.0 → remotedesktop-0.6.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.6.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.6.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."""
@@ -135,6 +138,9 @@ class ClientWindow(QMainWindow):
135
138
  self.discovery_panel.refresh()
136
139
 
137
140
  def log(self, message: str) -> None:
141
+ # Everything shown in the Connection log pane also goes to the debug
142
+ # log file (when main() enabled it), so it survives the window.
143
+ _log.info(message)
138
144
  self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
139
145
 
140
146
  def _forget_server(self, key: str) -> None:
@@ -249,10 +255,11 @@ class ClientWindow(QMainWindow):
249
255
 
250
256
 
251
257
  def main() -> None: # pragma: no cover - runs the Qt event loop
258
+ log_path = logs.init_logging("client")
252
259
  app = QApplication(sys.argv)
253
- window = ClientWindow()
260
+ window = ClientWindow() # auto_scan starts the first LAN scan
261
+ window.log(f"Detailed log: {log_path}")
254
262
  window.show()
255
- window.discovery_panel.refresh()
256
263
  raise SystemExit(app.exec())
257
264
 
258
265
 
@@ -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__(
@@ -119,6 +122,9 @@ class ServerWindow(QMainWindow):
119
122
  window_state.restore_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
120
123
 
121
124
  def log(self, message: str) -> None:
125
+ # Everything shown in the Connection log pane also goes to the debug
126
+ # log file (when main() enabled it), so it survives the window.
127
+ _log.info(message)
122
128
  self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
123
129
 
124
130
  def _record_peer(self, event: dict) -> None:
@@ -188,8 +194,10 @@ class ServerWindow(QMainWindow):
188
194
 
189
195
 
190
196
  def main() -> None: # pragma: no cover - runs the Qt event loop
197
+ log_path = logs.init_logging("server")
191
198
  app = QApplication(sys.argv)
192
199
  window = ServerWindow()
200
+ window.log(f"Detailed log: {log_path}")
193
201
  window.show()
194
202
  raise SystemExit(app.exec())
195
203
 
@@ -14,11 +14,12 @@ 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 (
24
25
  QHostAddress,
@@ -36,11 +37,16 @@ from remotedesktop.config import (
36
37
  load_client_identity,
37
38
  )
38
39
  from remotedesktop.discovery import DEFAULT_CONNECT_PORT
40
+ from remotedesktop import frames
39
41
  from remotedesktop.input_injection import InputInjector
40
42
  from remotedesktop.protocol import MAX_PAYLOAD, PROTOCOL_VERSION, MessageStream
41
43
  from remotedesktop import db, tls
42
44
 
45
+ _log = logging.getLogger("remotedesktop.sharing")
46
+
43
47
  DEFAULT_FPS = 10
48
+ # Only for full frames to legacy (0.5.0) clients, which force-decode frames
49
+ # as JPEG. Delta-capable clients get lossless PNG keyframes and delta bands.
44
50
  JPEG_QUALITY = 70
45
51
  # Skip sending to a client whose socket buffer is this far behind.
46
52
  _MAX_SEND_BACKLOG = 8 * 1024 * 1024
@@ -99,6 +105,16 @@ class ShareServer(QObject):
99
105
  self._final: set[MessageStream] = set()
100
106
  # What each controlling stream currently holds down: (buttons, vks).
101
107
  self._pressed: dict[MessageStream, tuple[set[str], set[int]]] = {}
108
+ # Streams currently too far behind to receive frames (see
109
+ # _broadcast_frame); entering/leaving this set emits a status message.
110
+ self._backlogged: set[MessageStream] = set()
111
+ # Inter-frame compression state: which admitted streams understand
112
+ # delta frames (hello carried "delta": true), which need a full
113
+ # keyframe next broadcast (just admitted, just caught up after a
114
+ # backlog, or asked for one), and the previous capture to diff.
115
+ self._delta_capable: set[MessageStream] = set()
116
+ self._needs_keyframe: set[MessageStream] = set()
117
+ self._previous_frame: QImage | None = None
102
118
 
103
119
  cert, key = credentials if credentials is not None else tls.ephemeral_credentials()
104
120
  self._server = QSslServer(self)
@@ -144,6 +160,10 @@ class ShareServer(QObject):
144
160
  self._prompting.clear()
145
161
  self._final.clear()
146
162
  self._pressed.clear()
163
+ self._backlogged.clear()
164
+ self._delta_capable.clear()
165
+ self._needs_keyframe.clear()
166
+ self._previous_frame = None
147
167
  if had_clients:
148
168
  self.clientCountChanged.emit(0)
149
169
  self._server.close()
@@ -159,6 +179,9 @@ class ShareServer(QObject):
159
179
  )
160
180
  stream = MessageStream(sock, self, max_payload=_PREAUTH_MAX_PAYLOAD)
161
181
  self._all_streams.add(stream)
182
+ sock.stateChanged.connect(
183
+ lambda state, s=sock: _log.debug("Socket %s state: %s", _peer(s), state.name)
184
+ )
162
185
  sock.disconnected.connect(lambda s=stream: self._drop(s))
163
186
  sock.errorOccurred.connect(
164
187
  lambda _error, s=sock: self.status.emit(
@@ -177,6 +200,13 @@ class ShareServer(QObject):
177
200
  self.status.emit(f"Clipboard update received from {_peer(stream.socket)}")
178
201
  self._clipboard.apply(message)
179
202
  return
203
+ if message.get("type") == "keyframe":
204
+ # The client lost sync with the delta stream (e.g. a band failed
205
+ # to decode) and wants a full frame to rebuild its canvas.
206
+ if stream in self._streams:
207
+ self.status.emit(f"Keyframe requested by {_peer(stream.socket)}")
208
+ self._needs_keyframe.add(stream)
209
+ return
180
210
  if (
181
211
  message.get("type") != "hello"
182
212
  or stream in self._streams
@@ -224,6 +254,8 @@ class ShareServer(QObject):
224
254
  client_id = message.get("client_id")
225
255
  client_name = str(message.get("name", "unknown"))
226
256
  self.status.emit(f'Hello from "{client_name}" ({client_id}) at {peer}')
257
+ if message.get("delta"):
258
+ self._delta_capable.add(stream)
227
259
  if isinstance(client_id, str) and client_id:
228
260
  self._stream_key[stream] = (client_id, client_name, peer)
229
261
  self._emit_peer(stream, "attempt")
@@ -289,6 +321,7 @@ class ShareServer(QObject):
289
321
  def _admit(self, stream: MessageStream, client_name: str, welcome: dict) -> None:
290
322
  stream.max_payload = MAX_PAYLOAD # pre-auth cap lifted once approved
291
323
  stream.send_json(welcome)
324
+ self._needs_keyframe.add(stream) # its first frame must be a full one
292
325
  self._streams.append(stream)
293
326
  self.clientCountChanged.emit(len(self._streams))
294
327
  self.status.emit(
@@ -359,6 +392,9 @@ class ShareServer(QObject):
359
392
  self._controllers.discard(stream)
360
393
  self._all_streams.discard(stream)
361
394
  self._prompting.discard(stream)
395
+ self._backlogged.discard(stream)
396
+ self._delta_capable.discard(stream)
397
+ self._needs_keyframe.discard(stream)
362
398
  self._release_input(stream)
363
399
  final = stream in self._final
364
400
  self._final.discard(stream)
@@ -383,26 +419,73 @@ class ShareServer(QObject):
383
419
  self.status.emit(f"Client disconnected — {len(self._streams)} viewer(s) remaining")
384
420
  if not self._streams:
385
421
  self._timer.stop()
422
+ self._previous_frame = None # don't hold a stale capture
386
423
  self.status.emit("No viewers left — screen capture stopped")
387
424
 
388
- def _broadcast_frame(self) -> None:
389
- if not self._streams:
390
- return
425
+ def _capture(self) -> QImage | None:
426
+ """Grab the primary screen at full resolution (tests override this
427
+ to drive deterministic frame content)."""
391
428
  screen = QGuiApplication.primaryScreen()
392
429
  if screen is None:
393
- return
430
+ return None
394
431
  image = screen.grabWindow(0).toImage()
395
- if image.isNull():
432
+ return None if image.isNull() else image
433
+
434
+ def _broadcast_frame(self) -> None:
435
+ if not self._streams:
436
+ return
437
+ image = self._capture()
438
+ if image is None:
396
439
  self.status.emit("Screen capture failed (null image)")
397
440
  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]
441
+ # bands is None when there is no comparable previous capture (first
442
+ # frame, or the resolution changed): everyone gets a full frame.
443
+ bands = (
444
+ frames.changed_bands(self._previous_frame, image)
445
+ if self._previous_frame is not None
446
+ else None
447
+ )
448
+ self._previous_frame = image
449
+ # Encode each variant at most once per tick, shared by all takers.
450
+ legacy_jpeg: bytes | None = None
451
+ keyframe_png: bytes | None = None
452
+ delta_payload: bytes | None = None
402
453
  for stream in self._streams:
403
454
  if stream.socket.bytesToWrite() > _MAX_SEND_BACKLOG:
404
- continue # client is not keeping up; drop this frame for it
405
- stream.send_frame(jpeg)
455
+ # Client is not keeping up; drop frames for it (and say so —
456
+ # to the viewer this looks like a frozen or flaky connection).
457
+ # Its canvas will be stale once it catches up, so it must
458
+ # restart from a keyframe.
459
+ self._needs_keyframe.add(stream)
460
+ if stream not in self._backlogged:
461
+ self._backlogged.add(stream)
462
+ self.status.emit(
463
+ f"Viewer at {_peer(stream.socket)} is not keeping up "
464
+ f"({stream.socket.bytesToWrite() // 1024} KB unsent) — "
465
+ "dropping frames for it"
466
+ )
467
+ continue
468
+ if stream in self._backlogged:
469
+ self._backlogged.discard(stream)
470
+ self.status.emit(
471
+ f"Viewer at {_peer(stream.socket)} caught up — resuming frames"
472
+ )
473
+ if stream not in self._delta_capable:
474
+ # Legacy (0.5.0) client: full JPEG every tick, as before.
475
+ if legacy_jpeg is None:
476
+ legacy_jpeg = frames.encode_image(image, "JPEG", JPEG_QUALITY)
477
+ stream.send_frame(legacy_jpeg)
478
+ elif stream in self._needs_keyframe or bands is None:
479
+ if keyframe_png is None:
480
+ keyframe_png = frames.encode_image(image)
481
+ _log.debug("Keyframe: %d KB PNG", len(keyframe_png) // 1024)
482
+ stream.send_frame(keyframe_png)
483
+ self._needs_keyframe.discard(stream)
484
+ elif bands:
485
+ if delta_payload is None:
486
+ delta_payload = frames.encode_delta(image, bands)
487
+ stream.send_delta(delta_payload)
488
+ # else: nothing changed since the last frame — send nothing.
406
489
 
407
490
 
408
491
  class ShareClient(QObject):
@@ -435,9 +518,14 @@ class ShareClient(QObject):
435
518
  self._server_key = ""
436
519
  self._server_fingerprint = ""
437
520
  self._server_token: str | None = None
521
+ self._frame_count = 0
522
+ self._last_image: QImage | None = None # delta patches build on this
438
523
  self._socket = QSslSocket(self)
439
524
  self._socket.setSslConfiguration(tls.client_configuration())
440
525
  self._stream = MessageStream(self._socket, self)
526
+ self._socket.stateChanged.connect(
527
+ lambda state: _log.debug("Client socket state: %s", state.name)
528
+ )
441
529
  self._socket.encrypted.connect(self._on_encrypted)
442
530
  self._socket.sslErrors.connect(self._on_ssl_errors)
443
531
  self._socket.disconnected.connect(self._on_disconnected)
@@ -446,10 +534,13 @@ class ShareClient(QObject):
446
534
  )
447
535
  self._stream.jsonReceived.connect(self._on_message)
448
536
  self._stream.frameReceived.connect(self._on_frame)
537
+ self._stream.deltaReceived.connect(self._on_delta)
449
538
 
450
539
  def connect_to(self, host: str, port: int) -> None:
451
540
  self._socket.abort() # drop any previous connection or attempt
452
541
  self._got_first_frame = False
542
+ self._frame_count = 0
543
+ self._last_image = None
453
544
  self._server_key = f"{host}:{port}"
454
545
  record = self._known.get(self._server_key) if self._known else None
455
546
  self._server_token = record.get("token") if record else None
@@ -492,6 +583,7 @@ class ShareClient(QObject):
492
583
  "version": PROTOCOL_VERSION,
493
584
  "client_id": self._client_id,
494
585
  "name": self._name,
586
+ "delta": True, # we understand inter-frame delta messages
495
587
  }
496
588
  if self._server_token:
497
589
  hello["token"] = self._server_token
@@ -538,15 +630,44 @@ class ShareClient(QObject):
538
630
  self.status.emit("Clipboard update received from server")
539
631
  self._clipboard.apply(message)
540
632
 
541
- def _on_frame(self, jpeg: bytes) -> None:
542
- image = QImage.fromData(jpeg, "JPEG") # ty: ignore[invalid-argument-type]
633
+ def _on_frame(self, data: bytes) -> None:
634
+ # Full frame — PNG keyframe or (legacy servers) JPEG; sniffed from
635
+ # the payload's magic bytes.
636
+ image = QImage.fromData(data)
543
637
  if image.isNull():
544
- self.status.emit(f"Received undecodable frame ({len(jpeg)} bytes)")
638
+ self.status.emit(f"Received undecodable frame ({len(data)} bytes)")
545
639
  return
640
+ self._deliver(image, len(data))
641
+
642
+ def _on_delta(self, payload: bytes) -> None:
643
+ if self._last_image is None:
644
+ self.status.emit("Delta frame arrived before a keyframe — requesting one")
645
+ self._stream.send_json({"type": "keyframe"})
646
+ return
647
+ image = frames.apply_delta(self._last_image, payload)
648
+ if image is None:
649
+ self.status.emit("Undecodable delta frame — requesting a keyframe")
650
+ self._stream.send_json({"type": "keyframe"})
651
+ return
652
+ self._deliver(image, len(payload))
653
+
654
+ def _deliver(self, image: QImage, byte_count: int) -> None:
655
+ self._last_image = image
546
656
  if not self._got_first_frame:
547
657
  self._got_first_frame = True
548
658
  self.status.emit(
549
659
  f"First frame received: {image.width()}x{image.height()} "
550
- f"({len(jpeg) // 1024} KB as JPEG)"
660
+ f"({byte_count // 1024} KB)"
661
+ )
662
+ self._frame_count += 1
663
+ # A heartbeat in the debug log (~10 s at the default fps): gaps between
664
+ # these lines show exactly when the stream stalled.
665
+ if self._frame_count % 100 == 0:
666
+ _log.debug(
667
+ "Received %d frames (latest %dx%d, %d KB)",
668
+ self._frame_count,
669
+ image.width(),
670
+ image.height(),
671
+ byte_count // 1024,
551
672
  )
552
673
  self.frameReceived.emit(image)
@@ -29,6 +29,11 @@ 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.
36
+ self._scaled: QPixmap | None = None
32
37
  self._message = "Not connected"
33
38
  # Buttons/keys currently held, so releases can be forwarded even when
34
39
  # they happen outside the frame or when the widget loses focus —
@@ -45,10 +50,12 @@ class ViewerWidget(QWidget):
45
50
 
46
51
  def show_frame(self, image: QImage) -> None:
47
52
  self._frame = QPixmap.fromImage(image)
53
+ self._scaled = None
48
54
  self.update()
49
55
 
50
56
  def clear(self, message: str = "Not connected") -> None:
51
57
  self._frame = None
58
+ self._scaled = None
52
59
  self._message = message
53
60
  self._pressed_buttons.clear()
54
61
  self._pressed_keys.clear()
@@ -167,4 +174,11 @@ class ViewerWidget(QWidget):
167
174
  painter.setPen(Qt.GlobalColor.white)
168
175
  painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._message)
169
176
  return
170
- painter.drawPixmap(self._display_rect().toRect(), self._frame)
177
+ rect = self._display_rect().toRect()
178
+ if self._scaled is None or self._scaled.size() != rect.size():
179
+ self._scaled = self._frame.scaled(
180
+ rect.size(),
181
+ Qt.AspectRatioMode.IgnoreAspectRatio, # rect is already aspect-correct
182
+ Qt.TransformationMode.SmoothTransformation,
183
+ )
184
+ 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,6 +1,8 @@
1
+ import logging
1
2
  import time
2
3
 
3
4
  from PySide6.QtCore import QEventLoop
5
+ from PySide6.QtGui import QColor, QImage, QPainter
4
6
  from PySide6.QtNetwork import QSslSocket
5
7
 
6
8
  from remotedesktop import db, tls
@@ -397,6 +399,128 @@ def test_disconnected_client_paths_are_noops(qapp, tmp_path):
397
399
  client.close()
398
400
 
399
401
 
402
+ def solid_image(color, *, width=64, height=128):
403
+ image = QImage(width, height, QImage.Format.Format_RGB32)
404
+ image.fill(QColor(color))
405
+ return image
406
+
407
+
408
+ def test_delta_streaming_sends_only_changes(qapp, credentials, tmp_path):
409
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
410
+ captures = {"image": solid_image("red")}
411
+ server._capture = lambda: captures["image"]
412
+ client = make_client(tmp_path)
413
+ images, raw_frames, deltas = [], [], []
414
+ client.frameReceived.connect(images.append)
415
+ client._stream.frameReceived.connect(raw_frames.append)
416
+ client._stream.deltaReceived.connect(deltas.append)
417
+ client.connect_to("127.0.0.1", server.port)
418
+ try:
419
+ # First frame is a full PNG keyframe.
420
+ pump(qapp, lambda: images)
421
+ assert raw_frames[0][:4] == b"\x89PNG"
422
+ assert images[0].pixelColor(5, 5).name() == "#ff0000"
423
+
424
+ # Static screen: nothing more is sent at all.
425
+ pump(qapp, lambda: True, timeout=0.4)
426
+ assert len(images) == 1 and not deltas
427
+
428
+ # Change the bottom band: exactly that region arrives as a delta.
429
+ changed = solid_image("red")
430
+ painter = QPainter(changed)
431
+ painter.fillRect(0, 64, 64, 64, QColor("blue"))
432
+ painter.end()
433
+ captures["image"] = changed
434
+ pump(qapp, lambda: deltas and len(images) >= 2)
435
+ assert len(raw_frames) == 1 # no second full frame was needed
436
+ latest = images[-1]
437
+ assert latest.pixelColor(5, 5).name() == "#ff0000" # untouched region intact
438
+ assert latest.pixelColor(5, 100).name() == "#0000ff" # patched region updated
439
+ finally:
440
+ client.close()
441
+ server.close()
442
+
443
+
444
+ def test_legacy_client_still_gets_full_jpeg_frames(qapp, credentials, tmp_path):
445
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
446
+ server._capture = lambda: solid_image("red") # static screen
447
+ sock, stream = raw_tls_stream(qapp, server.port)
448
+ received = []
449
+ stream.frameReceived.connect(received.append)
450
+ # A 0.5.0 hello has no "delta" capability flag.
451
+ stream.send_json(
452
+ {"type": "hello", "version": PROTOCOL_VERSION, "client_id": "legacy-1", "name": "old"}
453
+ )
454
+ try:
455
+ # Legacy clients get a full frame every tick even with a static
456
+ # screen, and it must be JPEG (0.5.0 force-decodes frames as JPEG).
457
+ pump(qapp, lambda: len(received) >= 3)
458
+ assert all(frame[:3] == b"\xff\xd8\xff" for frame in received)
459
+ finally:
460
+ sock.abort()
461
+ server.close()
462
+
463
+
464
+ def test_desynced_client_requests_and_gets_a_keyframe(qapp, credentials, tmp_path):
465
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
466
+ captures = {"image": solid_image("red")}
467
+ server._capture = lambda: captures["image"]
468
+ client = make_client(tmp_path)
469
+ images, raw_frames = [], []
470
+ client.frameReceived.connect(images.append)
471
+ client._stream.frameReceived.connect(raw_frames.append)
472
+ client.connect_to("127.0.0.1", server.port)
473
+ try:
474
+ pump(qapp, lambda: images)
475
+ # Corrupt the client's canvas size so the next delta is rejected and
476
+ # a keyframe is requested.
477
+ client._last_image = solid_image("red", width=8, height=8)
478
+ captures["image"] = solid_image("green")
479
+ pump(qapp, lambda: len(raw_frames) >= 2) # the recovery keyframe
480
+ pump(qapp, lambda: images and images[-1].pixelColor(5, 5).name() == "#008000")
481
+ finally:
482
+ client.close()
483
+ server.close()
484
+
485
+
486
+ def test_backlogged_viewer_frame_drop_is_reported(qapp, credentials, tmp_path, monkeypatch):
487
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
488
+ statuses: list[str] = []
489
+ server.status.connect(statuses.append)
490
+ client = make_client(tmp_path)
491
+ frames = []
492
+ client.frameReceived.connect(frames.append)
493
+ # A negative cap makes every stream count as backlogged, so no frames go out.
494
+ monkeypatch.setattr("remotedesktop.sharing._MAX_SEND_BACKLOG", -1)
495
+ client.connect_to("127.0.0.1", server.port)
496
+ try:
497
+ pump(qapp, lambda: any("not keeping up" in s for s in statuses))
498
+ assert sum("not keeping up" in s for s in statuses) == 1 # reported once, not per frame
499
+ assert not frames
500
+ # Once the backlog clears the server says so and frames resume.
501
+ monkeypatch.setattr("remotedesktop.sharing._MAX_SEND_BACKLOG", 8 * 1024 * 1024)
502
+ pump(qapp, lambda: frames)
503
+ assert any("caught up" in s for s in statuses)
504
+ finally:
505
+ client.close()
506
+ server.close()
507
+
508
+
509
+ def test_oversized_preauth_message_aborts_and_logs(qapp, credentials, tmp_path, caplog):
510
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
511
+ sock, stream = raw_tls_stream(qapp, server.port)
512
+ caplog.set_level(logging.DEBUG, logger="remotedesktop")
513
+ try:
514
+ # Larger than the pre-auth 64 KB cap: the server aborts the socket
515
+ # and the reason must land in the debug log.
516
+ stream.send_frame(b"x" * (128 * 1024))
517
+ pump(qapp, lambda: sock.state() != QSslSocket.SocketState.ConnectedState)
518
+ assert any("exceeds the" in r.getMessage() for r in caplog.records)
519
+ finally:
520
+ sock.abort()
521
+ server.close()
522
+
523
+
400
524
  def test_server_reports_phases_in_status(qapp, credentials, tmp_path):
401
525
  server = make_server(credentials, tmp_path, approve=lambda *_: True)
402
526
  statuses: list[str] = []
File without changes
File without changes
File without changes