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.
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/CLAUDE.md +3 -3
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/PKG-INFO +7 -1
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/readme.md +6 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/client.py +26 -4
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/discovery.py +14 -4
- remotedesktop-0.6.0/src/remotedesktop/frames.py +101 -0
- remotedesktop-0.6.0/src/remotedesktop/logs.py +46 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/protocol.py +30 -3
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/server.py +21 -4
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/sharing.py +145 -16
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/viewer.py +15 -1
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_client_window.py +187 -164
- remotedesktop-0.6.0/tests/test_frames.py +65 -0
- remotedesktop-0.6.0/tests/test_logs.py +23 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_server_window.py +15 -6
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_sharing.py +153 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_smoke.py +58 -58
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/.gitignore +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/LICENSE +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/pyproject.toml +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/run_claude.bat +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/run_client.bat +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/run_server.bat +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/conftest.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_config.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_input.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.4.0 → remotedesktop-0.6.0}/tests/test_window_state.py +0 -0
- {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
|
|
42
|
+
- **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). The capability is negotiated via `"delta": true` in the client hello; clients without it (0.5.0) get full JPEG frames every tick because they force-decode frames as JPEG — never send them PNG. Each variant (legacy JPEG / keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
|
|
43
43
|
- **All persistence is one SQLite database** (`db.py`): `db.connect(path)` opens the DB and ensures every table (`settings`, `paired_clients`, `known_servers`, and the inventory tables). The file lives at `config.default_db_path()` — `platformdirs.user_data_dir("remotedesktop")` (`%LOCALAPPDATA%\remotedesktop\remotedesktop.db` on Windows). **Use `platformdirs` for any new data location; don't hardcode `%APPDATA%`.** Every store (`Settings`, `PairedClients`, `KnownServers`, `ConnectionInventory`) takes a `sqlite3.Connection`; the GUIs open one connection per window and share it. Both window classes take a `connection=` keyword so tests inject a temp-file DB and never touch the real one (see `tests/test_client_window.py` / `test_server_window.py`). Tests pass `db.connect(tmp_path/"x.db")` (or omit for in-memory), and use **separate DB files for the server and client** since they model different machines.
|
|
44
44
|
- **Trust model plumbing** (`config.py`): the client has a stable UUID identity (`load_client_identity`, in the `settings` table) and the server maps each approved client-id to a token (`PairedClients`). `ShareServer` takes an `approve_client(client_id, name) -> bool` callback; `ServerWindow` implements it as a modal QMessageBox. Tests inject stores backed by a temp DB and explicit identities so they never touch real data or prompt.
|
|
45
|
-
- **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, and `DiscoveryPanel` emit human-readable `status` signals for every connection phase, and both windows show them in a timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages.
|
|
45
|
+
- **Status/debug logging is a feature**: `ShareServer`, `ShareClient`, and `DiscoveryPanel` emit human-readable `status` signals for every connection phase, and both windows show them in a timestamped "Connection log" pane. When adding connection behavior, emit a status message for each new phase or failure path — there's a test asserting the server's phase messages. Everything the panes show is also written to a rotating debug log file (`logs.py`; `logs.init_logging("client"/"server")` is called only by each app's `main()`, under `platformdirs.user_log_dir("remotedesktop")` — `%LOCALAPPDATA%\remotedesktop\Logs\{client,server}.log` on Windows). Lower-level detail (socket state changes, protocol aborts, frame-drop/backlog, discovery datagram drops) goes straight to `logging.getLogger("remotedesktop.*")` at debug/warning level — silent failure paths should log there even when a status message would be too noisy. Library code and tests never call `init_logging`; without a handler those loggers are no-ops.
|
|
46
46
|
- **Connection inventory** (`inventory.py`): both windows have a tab (server: "Clients on LAN"; client: "Servers on LAN") backed by a `ConnectionInventory` — one `PeerRecord` per peer with attempts count, current state, first/last seen. The server drives it from `ShareServer.peerEvent` (a structured `{key, event, name, address, detail}` signal emitted alongside the status strings at each phase — keep both in sync when adding phases). The client drives it from `ClientWindow`'s own handlers (discovered servers from `DiscoveryPanel.serversFound`, plus attempt/connected/denied/disconnected). Server peers key by client-id; client peers key by `host:port`. **Persisted in SQLite** and reloaded on startup, so it survives restarts.
|
|
47
47
|
- **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; the callback gets the selected peer's key. The server's tab wires "Revoke access" → `ShareServer.revoke_client(client_id)` (removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` reports state "revoked"; a `_revoked` set distinguishes it from a plain disconnect). The client's tab wires "Forget server" → `KnownServers.forget(key)` + disconnect. After either, the next connection needs a fresh approval. Server and client each use a *separate table* (`server_peers` / `client_peers`, selected via the `table=` arg) so a machine running both apps doesn't commingle them; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
|
|
48
48
|
- Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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,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__(
|
|
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
|
-
|
|
112
|
+
except OSError as error:
|
|
113
|
+
_log.warning("Could not reply to probe from %s: %s", sender, error)
|
|
109
114
|
|
|
110
115
|
|
|
111
116
|
def discover_servers(
|
|
@@ -126,8 +131,10 @@ def discover_servers(
|
|
|
126
131
|
for host in broadcast_hosts:
|
|
127
132
|
try:
|
|
128
133
|
sock.sendto(probe, (host, discovery_port))
|
|
129
|
-
except OSError:
|
|
130
|
-
|
|
134
|
+
except OSError as error:
|
|
135
|
+
# A failed broadcast means the scan quietly finds nothing —
|
|
136
|
+
# worth a record when diagnosing "no servers found".
|
|
137
|
+
_log.warning("Probe broadcast to %s:%d failed: %s", host, discovery_port, error)
|
|
131
138
|
deadline = time.monotonic() + timeout
|
|
132
139
|
while (remaining := deadline - time.monotonic()) > 0:
|
|
133
140
|
sock.settimeout(remaining)
|
|
@@ -137,10 +144,13 @@ def discover_servers(
|
|
|
137
144
|
break
|
|
138
145
|
message = _parse(data, "reply")
|
|
139
146
|
if message is None:
|
|
147
|
+
_log.debug("Ignoring non-reply datagram (%d bytes) from %s", len(data), host)
|
|
140
148
|
continue
|
|
141
149
|
name = message.get("name")
|
|
142
150
|
port = message.get("port")
|
|
143
151
|
if not isinstance(name, str) or not isinstance(port, int):
|
|
152
|
+
_log.debug("Ignoring malformed reply from %s: %r", host, message)
|
|
144
153
|
continue
|
|
145
154
|
found.setdefault((host, port), ServerInfo(name=name, host=host, port=port))
|
|
155
|
+
_log.debug("Discovery scan finished: %d server(s) found", len(found))
|
|
146
156
|
return list(found.values())
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Inter-frame compression for the screen stream.
|
|
2
|
+
|
|
3
|
+
Consecutive captures are compared in horizontal bands (BAND_HEIGHT rows,
|
|
4
|
+
adjacent changed bands merged); only changed bands are encoded and shipped
|
|
5
|
+
in a delta payload, which the client patches onto its previous frame. Bands
|
|
6
|
+
keep the comparison and the crop cheap: a band is a contiguous byte range of
|
|
7
|
+
the image, so diffing is a C-speed memoryview compare.
|
|
8
|
+
|
|
9
|
+
Delta bands and keyframes for delta-capable clients are PNG — lossless, so
|
|
10
|
+
the client's canvas is pixel-identical to the capture and patches never
|
|
11
|
+
accumulate artifacts. (Legacy clients still get JPEG full frames: 0.5.0
|
|
12
|
+
force-decodes frames as JPEG, so they must never see PNG.)
|
|
13
|
+
|
|
14
|
+
Delta payload wire format: 4-byte big-endian header length, a JSON header
|
|
15
|
+
{"w", "h", "bands": [{"y", "h", "len"}, ...]}, then the bands' PNG bytes
|
|
16
|
+
concatenated in order.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import struct
|
|
21
|
+
|
|
22
|
+
from PySide6.QtCore import QBuffer
|
|
23
|
+
from PySide6.QtGui import QImage, QPainter
|
|
24
|
+
|
|
25
|
+
BAND_HEIGHT = 64
|
|
26
|
+
_HEADER_LEN = struct.Struct(">I")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encode_image(image: QImage, image_format: str = "PNG", quality: int = -1) -> bytes:
|
|
30
|
+
buffer = QBuffer()
|
|
31
|
+
buffer.open(QBuffer.OpenModeFlag.WriteOnly)
|
|
32
|
+
image.save(buffer, image_format, quality) # ty: ignore[no-matching-overload]
|
|
33
|
+
return bytes(buffer.data()) # ty: ignore[invalid-argument-type]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def changed_bands(previous: QImage, current: QImage) -> list[tuple[int, int]] | None:
|
|
37
|
+
"""Bands of `current` that differ from `previous`, as (y, height) pairs.
|
|
38
|
+
|
|
39
|
+
Adjacent changed bands are merged. Returns None when the images are not
|
|
40
|
+
comparable (different size or format) — the caller must fall back to a
|
|
41
|
+
full frame.
|
|
42
|
+
"""
|
|
43
|
+
if previous.size() != current.size() or previous.format() != current.format():
|
|
44
|
+
return None
|
|
45
|
+
prev_bits = previous.constBits()
|
|
46
|
+
cur_bits = current.constBits()
|
|
47
|
+
stride = current.bytesPerLine()
|
|
48
|
+
height = current.height()
|
|
49
|
+
bands: list[tuple[int, int]] = []
|
|
50
|
+
y = 0
|
|
51
|
+
while y < height:
|
|
52
|
+
h = min(BAND_HEIGHT, height - y)
|
|
53
|
+
if prev_bits[y * stride : (y + h) * stride] != cur_bits[y * stride : (y + h) * stride]:
|
|
54
|
+
if bands and bands[-1][0] + bands[-1][1] == y:
|
|
55
|
+
bands[-1] = (bands[-1][0], bands[-1][1] + h)
|
|
56
|
+
else:
|
|
57
|
+
bands.append((y, h))
|
|
58
|
+
y += h
|
|
59
|
+
return bands
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encode_delta(image: QImage, bands: list[tuple[int, int]]) -> bytes:
|
|
63
|
+
entries = []
|
|
64
|
+
blobs = []
|
|
65
|
+
for y, h in bands:
|
|
66
|
+
png = encode_image(image.copy(0, y, image.width(), h))
|
|
67
|
+
entries.append({"y": y, "h": h, "len": len(png)})
|
|
68
|
+
blobs.append(png)
|
|
69
|
+
header = json.dumps({"w": image.width(), "h": image.height(), "bands": entries}).encode()
|
|
70
|
+
return _HEADER_LEN.pack(len(header)) + header + b"".join(blobs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def apply_delta(canvas: QImage, payload: bytes) -> QImage | None:
|
|
74
|
+
"""Patch a delta payload onto a copy of `canvas`.
|
|
75
|
+
|
|
76
|
+
Returns the patched image, or None if the payload is malformed or was
|
|
77
|
+
produced for a different frame size (the caller should then wait for
|
|
78
|
+
the next keyframe).
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
(header_len,) = _HEADER_LEN.unpack_from(payload)
|
|
82
|
+
header = json.loads(payload[_HEADER_LEN.size : _HEADER_LEN.size + header_len].decode())
|
|
83
|
+
bands = [(int(b["y"]), int(b["h"]), int(b["len"])) for b in header["bands"]]
|
|
84
|
+
size_ok = int(header["w"]) == canvas.width() and int(header["h"]) == canvas.height()
|
|
85
|
+
except (struct.error, UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
if not size_ok:
|
|
88
|
+
return None
|
|
89
|
+
image = canvas.copy()
|
|
90
|
+
painter = QPainter(image)
|
|
91
|
+
offset = _HEADER_LEN.size + header_len
|
|
92
|
+
try:
|
|
93
|
+
for y, h, length in bands:
|
|
94
|
+
band = QImage.fromData(payload[offset : offset + length])
|
|
95
|
+
offset += length
|
|
96
|
+
if band.isNull() or band.height() != h:
|
|
97
|
+
return None
|
|
98
|
+
painter.drawImage(0, y, band)
|
|
99
|
+
finally:
|
|
100
|
+
painter.end()
|
|
101
|
+
return image
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Persistent debug logging for the client and server apps.
|
|
2
|
+
|
|
3
|
+
The GUI "Connection log" panes only live as long as their window, which makes
|
|
4
|
+
an intermittent connection problem impossible to diagnose after the fact.
|
|
5
|
+
`init_logging` attaches a rotating file handler to the "remotedesktop" logger
|
|
6
|
+
so everything the panes show — plus lower-level socket and protocol detail
|
|
7
|
+
that is logged directly — survives on disk with millisecond timestamps.
|
|
8
|
+
|
|
9
|
+
Only the app entry points call `init_logging`; library code and tests just
|
|
10
|
+
log to `logging.getLogger("remotedesktop...")`, which is a no-op without a
|
|
11
|
+
handler.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import logging.handlers
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import platformdirs
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_log_dir() -> Path:
|
|
22
|
+
return Path(platformdirs.user_log_dir("remotedesktop", appauthor=False))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init_logging(app_name: str, *, directory: Path | None = None) -> Path:
|
|
26
|
+
"""Write "remotedesktop" logger output to <log dir>/<app_name>.log.
|
|
27
|
+
|
|
28
|
+
Returns the log file path. `directory` exists for tests; the apps use
|
|
29
|
+
the platformdirs log dir.
|
|
30
|
+
"""
|
|
31
|
+
log_dir = directory if directory is not None else default_log_dir()
|
|
32
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path = log_dir / f"{app_name}.log"
|
|
34
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
35
|
+
path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
|
|
36
|
+
)
|
|
37
|
+
handler.setFormatter(
|
|
38
|
+
logging.Formatter(
|
|
39
|
+
"%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s %(message)s",
|
|
40
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
logger = logging.getLogger("remotedesktop")
|
|
44
|
+
logger.addHandler(handler)
|
|
45
|
+
logger.setLevel(logging.DEBUG)
|
|
46
|
+
return path
|
|
@@ -6,6 +6,7 @@ input aborts the connection.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import logging
|
|
9
10
|
import struct
|
|
10
11
|
|
|
11
12
|
from PySide6.QtCore import QObject, Signal
|
|
@@ -13,9 +14,12 @@ from PySide6.QtNetwork import QTcpSocket
|
|
|
13
14
|
|
|
14
15
|
PROTOCOL_VERSION = 1
|
|
15
16
|
|
|
17
|
+
_log = logging.getLogger("remotedesktop.protocol")
|
|
18
|
+
|
|
16
19
|
_HEADER = struct.Struct(">IB")
|
|
17
20
|
_KIND_JSON = 0
|
|
18
21
|
_KIND_FRAME = 1
|
|
22
|
+
_KIND_DELTA = 2 # inter-frame delta (see frames.py); unknown to 0.5.0 peers
|
|
19
23
|
MAX_PAYLOAD = 64 * 1024 * 1024
|
|
20
24
|
|
|
21
25
|
|
|
@@ -28,6 +32,7 @@ class MessageStream(QObject):
|
|
|
28
32
|
|
|
29
33
|
jsonReceived = Signal(dict)
|
|
30
34
|
frameReceived = Signal(bytes)
|
|
35
|
+
deltaReceived = Signal(bytes)
|
|
31
36
|
|
|
32
37
|
def __init__(
|
|
33
38
|
self,
|
|
@@ -44,8 +49,11 @@ class MessageStream(QObject):
|
|
|
44
49
|
def send_json(self, message: dict) -> None:
|
|
45
50
|
self._send(_KIND_JSON, json.dumps(message).encode())
|
|
46
51
|
|
|
47
|
-
def send_frame(self,
|
|
48
|
-
self._send(_KIND_FRAME,
|
|
52
|
+
def send_frame(self, frame: bytes) -> None:
|
|
53
|
+
self._send(_KIND_FRAME, frame)
|
|
54
|
+
|
|
55
|
+
def send_delta(self, payload: bytes) -> None:
|
|
56
|
+
self._send(_KIND_DELTA, payload)
|
|
49
57
|
|
|
50
58
|
def _send(self, kind: int, payload: bytes) -> None:
|
|
51
59
|
self.socket.write(_HEADER.pack(len(payload), kind) + payload)
|
|
@@ -56,6 +64,15 @@ class MessageStream(QObject):
|
|
|
56
64
|
return
|
|
57
65
|
length, kind = _HEADER.unpack(self.socket.peek(_HEADER.size).data())
|
|
58
66
|
if length > self.max_payload:
|
|
67
|
+
_log.warning(
|
|
68
|
+
"Aborting %s:%s: payload of %d bytes (kind=%d) exceeds the "
|
|
69
|
+
"%d-byte cap",
|
|
70
|
+
self.socket.peerAddress().toString(),
|
|
71
|
+
self.socket.peerPort(),
|
|
72
|
+
length,
|
|
73
|
+
kind,
|
|
74
|
+
self.max_payload,
|
|
75
|
+
)
|
|
59
76
|
self.socket.abort()
|
|
60
77
|
return
|
|
61
78
|
if self.socket.bytesAvailable() < _HEADER.size + length:
|
|
@@ -65,11 +82,21 @@ class MessageStream(QObject):
|
|
|
65
82
|
if kind == _KIND_JSON:
|
|
66
83
|
try:
|
|
67
84
|
message = json.loads(bytes(payload).decode())
|
|
68
|
-
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
85
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
86
|
+
_log.warning(
|
|
87
|
+
"Aborting %s:%s: undecodable JSON control message "
|
|
88
|
+
"(%d bytes): %s",
|
|
89
|
+
self.socket.peerAddress().toString(),
|
|
90
|
+
self.socket.peerPort(),
|
|
91
|
+
length,
|
|
92
|
+
error,
|
|
93
|
+
)
|
|
69
94
|
self.socket.abort()
|
|
70
95
|
return
|
|
71
96
|
if isinstance(message, dict):
|
|
72
97
|
self.jsonReceived.emit(message)
|
|
73
98
|
elif kind == _KIND_FRAME:
|
|
74
99
|
self.frameReceived.emit(payload)
|
|
100
|
+
elif kind == _KIND_DELTA:
|
|
101
|
+
self.deltaReceived.emit(payload)
|
|
75
102
|
# Unknown kinds are skipped so the protocol can grow.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Server GUI application: shares this computer's desktop with permitted
|
|
2
2
|
clients and prompts the user to approve first-time connections."""
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import socket
|
|
5
6
|
import sqlite3
|
|
6
7
|
import sys
|
|
@@ -20,7 +21,7 @@ from PySide6.QtWidgets import (
|
|
|
20
21
|
QWidget,
|
|
21
22
|
)
|
|
22
23
|
|
|
23
|
-
from remotedesktop import db, tls, window_state
|
|
24
|
+
from remotedesktop import db, logs, tls, window_state
|
|
24
25
|
from remotedesktop.autostart import Autostart
|
|
25
26
|
from remotedesktop.clipboard import ClipboardSync
|
|
26
27
|
from remotedesktop.config import PairedClients, Settings, default_config_dir, default_db_path
|
|
@@ -32,6 +33,8 @@ from remotedesktop.discovery import (
|
|
|
32
33
|
from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
33
34
|
from remotedesktop.sharing import ShareServer
|
|
34
35
|
|
|
36
|
+
_log = logging.getLogger("remotedesktop.server")
|
|
37
|
+
|
|
35
38
|
|
|
36
39
|
class ServerWindow(QMainWindow):
|
|
37
40
|
def __init__(
|
|
@@ -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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
|