remotedesktop 0.4.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.4.0 → remotedesktop-0.6.0}/CLAUDE.md +3 -3
  2. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/PKG-INFO +7 -1
  3. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/readme.md +6 -0
  4. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/client.py +26 -4
  6. {remotedesktop-0.4.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.4.0 → remotedesktop-0.6.0}/src/remotedesktop/protocol.py +30 -3
  10. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/server.py +21 -4
  11. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/sharing.py +145 -16
  12. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/viewer.py +15 -1
  13. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_client_window.py +187 -164
  14. remotedesktop-0.6.0/tests/test_frames.py +65 -0
  15. remotedesktop-0.6.0/tests/test_logs.py +23 -0
  16. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_server_window.py +15 -6
  17. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_sharing.py +153 -0
  18. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_smoke.py +58 -58
  19. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/.github/workflows/ci.yml +0 -0
  20. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/.gitignore +0 -0
  21. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/LICENSE +0 -0
  22. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/pyproject.toml +0 -0
  23. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/run_claude.bat +0 -0
  24. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/run_client.bat +0 -0
  25. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/run_server.bat +0 -0
  26. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/scripts/make_coverage_badge.py +0 -0
  27. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/scripts/make_venv.bat +0 -0
  28. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/autostart.py +0 -0
  29. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/clipboard.py +0 -0
  30. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/config.py +0 -0
  31. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/db.py +0 -0
  32. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/input_injection.py +0 -0
  33. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/inventory.py +0 -0
  34. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/tls.py +0 -0
  35. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/window_state.py +0 -0
  36. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/conftest.py +0 -0
  37. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_autostart.py +0 -0
  38. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_clipboard.py +0 -0
  39. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_config.py +0 -0
  40. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_discovery.py +0 -0
  41. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_input.py +0 -0
  42. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_input_injection.py +0 -0
  43. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_inventory.py +0 -0
  44. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_protocol.py +0 -0
  45. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_tls.py +0 -0
  46. {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_window_state.py +0 -0
  47. {remotedesktop-0.4.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.4.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.4.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."""
@@ -83,7 +86,9 @@ class DiscoveryPanel(QWidget):
83
86
 
84
87
 
85
88
  class ClientWindow(QMainWindow):
86
- def __init__(self, *, connection: sqlite3.Connection | None = None) -> None:
89
+ def __init__(
90
+ self, *, connection: sqlite3.Connection | None = None, auto_scan: bool = True
91
+ ) -> None:
87
92
  super().__init__()
88
93
  self.setWindowTitle("Remote Desktop Client")
89
94
  # Tests inject a connection to a temp database; the app uses the default.
@@ -128,8 +133,14 @@ class ClientWindow(QMainWindow):
128
133
  self._settings = Settings(self._db)
129
134
  window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
130
135
  self.log("Client started")
136
+ # Tests pass auto_scan=False so window tests never broadcast on the LAN.
137
+ if auto_scan:
138
+ self.discovery_panel.refresh()
131
139
 
132
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)
133
144
  self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
134
145
 
135
146
  def _forget_server(self, key: str) -> None:
@@ -180,6 +191,7 @@ class ClientWindow(QMainWindow):
180
191
  self._client = client
181
192
  client.status.connect(self.log)
182
193
  client.connected.connect(self._on_connected)
194
+ client.approvalPending.connect(self._on_approval_pending)
183
195
  client.denied.connect(self._on_denied)
184
196
  client.disconnected.connect(self._on_disconnected)
185
197
  client.frameReceived.connect(self._on_frame)
@@ -187,6 +199,15 @@ class ClientWindow(QMainWindow):
187
199
  self.statusBar().showMessage(f"Connecting to {server.name} ({server.host}:{server.port}) …")
188
200
  client.connect_to(server.host, server.port)
189
201
 
202
+ def _on_approval_pending(self) -> None:
203
+ self.viewer.clear(
204
+ f"Waiting for approval — someone at {self._server_name} "
205
+ "must allow this connection"
206
+ )
207
+ self.statusBar().showMessage(
208
+ f"Waiting for the user on {self._server_name} to approve this computer …"
209
+ )
210
+
190
211
  def _on_connected(self, server_name: str) -> None:
191
212
  self._server_name = server_name or self._server_name
192
213
  self._connected = True
@@ -234,10 +255,11 @@ class ClientWindow(QMainWindow):
234
255
 
235
256
 
236
257
  def main() -> None: # pragma: no cover - runs the Qt event loop
258
+ log_path = logs.init_logging("client")
237
259
  app = QApplication(sys.argv)
238
- window = ClientWindow()
260
+ window = ClientWindow() # auto_scan starts the first LAN scan
261
+ window.log(f"Detailed log: {log_path}")
239
262
  window.show()
240
- window.discovery_panel.refresh()
241
263
  raise SystemExit(app.exec())
242
264
 
243
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:
@@ -163,12 +169,21 @@ class ServerWindow(QMainWindow):
163
169
  self.share_server.revoke_client(client_id)
164
170
 
165
171
  def _ask_approval(self, client_id: str, client_name: str) -> bool:
166
- answer = QMessageBox.question(
167
- self,
172
+ box = QMessageBox(
173
+ QMessageBox.Icon.Question,
168
174
  "Connection request",
169
175
  f'Allow "{client_name}" to view this desktop?\n\nClient id: {client_id}',
176
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
177
+ self,
170
178
  )
171
- return answer == QMessageBox.StandardButton.Yes
179
+ # The server usually isn't the foreground app when a request arrives,
180
+ # and Windows won't give focus to a background app's dialog — keep it
181
+ # on top so it can't sit unnoticed behind other windows.
182
+ box.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
183
+ box.show()
184
+ box.raise_()
185
+ box.activateWindow()
186
+ return box.exec() == QMessageBox.StandardButton.Yes
172
187
 
173
188
  def closeEvent(self, event: QCloseEvent) -> None:
174
189
  window_state.save_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
@@ -179,8 +194,10 @@ class ServerWindow(QMainWindow):
179
194
 
180
195
 
181
196
  def main() -> None: # pragma: no cover - runs the Qt event loop
197
+ log_path = logs.init_logging("server")
182
198
  app = QApplication(sys.argv)
183
199
  window = ServerWindow()
200
+ window.log(f"Detailed log: {log_path}")
184
201
  window.show()
185
202
  raise SystemExit(app.exec())
186
203