remotedesktop 0.7.0__tar.gz → 0.9.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 (53) hide show
  1. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/CLAUDE.md +1 -0
  2. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/PKG-INFO +6 -2
  3. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/readme.md +5 -1
  4. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/client.py +15 -2
  6. remotedesktop-0.9.0/src/remotedesktop/icon.py +69 -0
  7. remotedesktop-0.9.0/src/remotedesktop/performance.py +342 -0
  8. remotedesktop-0.9.0/src/remotedesktop/preferences.py +49 -0
  9. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/protocol.py +6 -0
  10. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/server.py +56 -3
  11. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/sharing.py +31 -0
  12. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_client_window.py +5 -0
  13. remotedesktop-0.9.0/tests/test_icon.py +19 -0
  14. remotedesktop-0.9.0/tests/test_performance.py +250 -0
  15. remotedesktop-0.9.0/tests/test_preferences.py +31 -0
  16. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_server_window.py +42 -1
  17. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/.github/workflows/ci.yml +0 -0
  18. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/.gitignore +0 -0
  19. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/LICENSE +0 -0
  20. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/pyproject.toml +0 -0
  21. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/run_claude.bat +0 -0
  22. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/run_client.bat +0 -0
  23. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/run_server.bat +0 -0
  24. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/scripts/make_coverage_badge.py +0 -0
  25. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/scripts/make_venv.bat +0 -0
  26. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/autostart.py +0 -0
  27. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/clipboard.py +0 -0
  28. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/config.py +0 -0
  29. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/db.py +0 -0
  30. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/discovery.py +0 -0
  31. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/frames.py +0 -0
  32. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/input_injection.py +0 -0
  33. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/inventory.py +0 -0
  34. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/logs.py +0 -0
  35. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/tls.py +0 -0
  36. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/viewer.py +0 -0
  37. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/window_state.py +0 -0
  38. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/conftest.py +0 -0
  39. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_autostart.py +0 -0
  40. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_clipboard.py +0 -0
  41. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_config.py +0 -0
  42. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_discovery.py +0 -0
  43. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_frames.py +0 -0
  44. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_input.py +0 -0
  45. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_input_injection.py +0 -0
  46. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_inventory.py +0 -0
  47. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_logs.py +0 -0
  48. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_protocol.py +0 -0
  49. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_sharing.py +0 -0
  50. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_smoke.py +0 -0
  51. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_tls.py +0 -0
  52. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_window_state.py +0 -0
  53. {remotedesktop-0.7.0 → remotedesktop-0.9.0}/uv.lock +0 -0
@@ -49,6 +49,7 @@ Managed with `uv` (hatchling build backend, src layout):
49
49
  - **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
50
50
  - **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake — **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
51
51
  - **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** — a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
52
+ - **Performance monitoring** (`performance.py` + `preferences.py`): `PerformanceMonitor` is an opt-in collaborator (`performance=` on `ShareServer`/`ShareClient`, the `clipboard=` pattern — plain sharing tests leave it off). While admitted streams are attached, a 1 s QTimer samples `MessageStream.bytes_sent/bytes_received` counters into rolling `MetricSeries` (bandwidth, aggregated across streams via per-stream baselines) and pings the most recently admitted stream: `{"type": "ping", "id", "rtt"}` / `{"type": "pong", "id"}` — each side measures its own RTT and piggybacks it on its next ping so both ends graph both directions; peers without the feature ignore pings harmlessly (RTT graphs show "no data"). Ping/pong is admitted-streams-only on the server, like input/clipboard. Both windows have a "Performance" tab (`PerformanceTab`, custom QPainter graphs) that schedules **no paint work while hidden** (gated on `isVisible()`, repaint on `showEvent`) and a "Preferences" tab whose history-window setting persists via the `Settings` store (`performance_window_seconds`, default 120). The monitor's timer runs only while streams are attached; all timing is `time.monotonic()` with an injectable `clock=` for tests — never monkeypatch `time.monotonic` (the test `pump()` helper depends on it).
52
53
  - **Clipboard sync is bidirectional** (`clipboard.py` `ClipboardSync`): client copy → server and server copy → all clients, as `{"type": "clipboard", "text"/"image_png"}` messages. It's opt-in — `ShareServer`/`ShareClient` take a `clipboard=` param and do nothing with clipboard if it's None, so only the GUIs (and clipboard tests) enable it; **plain sharing/input tests must leave it off to avoid touching the real OS clipboard.** Echo/loop prevention is by *content signature*, not just a guard flag, because Windows fires `QClipboard.dataChanged` asynchronously (after a flag would be cleared); image signatures hash canonical RGBA pixels so a re-encoded PNG round trip doesn't loop. Server only accepts clipboard from streams past the approval handshake. Tests use a `FakeClipboard(QObject)` with a `changed` signal and recording `apply()` so two independent clipboards exist in one process.
53
54
  - The package version lives only in `src/remotedesktop/__init__.py` (`__version__`); hatchling reads it from there (`[tool.hatch.version]`), so bump it in that one place.
54
55
  - Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.7.0
3
+ Version: 0.9.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
@@ -30,7 +30,11 @@ computers on the same LAN, with autodiscovery of servers.
30
30
 
31
31
  The server can optionally start automatically when you log in to Windows —
32
32
  a checkbox on its Status tab registers it under the per-user Run key (no
33
- administrator rights needed).
33
+ administrator rights needed). The Status tab also has a *Restart server*
34
+ button that relaunches the app in a fresh process; since the server's
35
+ screen can be controlled remotely, this lets you update the software from
36
+ a remote desktop session and restart into the new version without visiting
37
+ the machine (approved clients reconnect without a new permission prompt).
34
38
 
35
39
  Connections are made to the desktop screen, keyboard, mouse, and clipboard.
36
40
  Other connections are not provided, such as shared drives, devices, or
@@ -11,7 +11,11 @@ computers on the same LAN, with autodiscovery of servers.
11
11
 
12
12
  The server can optionally start automatically when you log in to Windows —
13
13
  a checkbox on its Status tab registers it under the per-user Run key (no
14
- administrator rights needed).
14
+ administrator rights needed). The Status tab also has a *Restart server*
15
+ button that relaunches the app in a fresh process; since the server's
16
+ screen can be controlled remotely, this lets you update the software from
17
+ a remote desktop session and restart into the new version without visiting
18
+ the machine (approved clients reconnect without a new permission prompt).
15
19
 
16
20
  Connections are made to the desktop screen, keyboard, mouse, and clipboard.
17
21
  Other connections are not provided, such as shared drives, devices, or
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.9.0"
@@ -23,11 +23,13 @@ from PySide6.QtWidgets import (
23
23
  QWidget,
24
24
  )
25
25
 
26
- from remotedesktop import db, logs, window_state
26
+ from remotedesktop import db, icon, logs, window_state
27
27
  from remotedesktop.clipboard import ClipboardSync
28
28
  from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
29
29
  from remotedesktop.discovery import DISCOVERY_PORT, ServerInfo, discover_servers
30
30
  from remotedesktop.inventory import ConnectionInventory, InventoryTab
31
+ from remotedesktop.performance import PerformanceMonitor, PerformanceTab
32
+ from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
31
33
  from remotedesktop.sharing import ShareClient
32
34
  from remotedesktop.viewer import ViewerWidget
33
35
 
@@ -91,8 +93,14 @@ class ClientWindow(QMainWindow):
91
93
  ) -> None:
92
94
  super().__init__()
93
95
  self.setWindowTitle("Remote Desktop Client")
96
+ self.setWindowIcon(icon.app_icon("client"))
94
97
  # Tests inject a connection to a temp database; the app uses the default.
95
98
  self._db = connection if connection is not None else db.connect(default_db_path())
99
+ self._settings = Settings(self._db)
100
+ self.performance = PerformanceMonitor(
101
+ window_seconds=float(load_performance_window_seconds(self._settings)),
102
+ parent=self,
103
+ )
96
104
  self.viewer = ViewerWidget(self)
97
105
  self.inventory = ConnectionInventory(self._db, "client_peers", self)
98
106
  tabs = QTabWidget()
@@ -101,6 +109,7 @@ class ClientWindow(QMainWindow):
101
109
  InventoryTab(self.inventory, "Forget server", self._forget_server),
102
110
  "Servers on LAN",
103
111
  )
112
+ tabs.addTab(PerformanceTab(self.performance), "Performance")
104
113
  self.setCentralWidget(tabs)
105
114
 
106
115
  self.discovery_panel = DiscoveryPanel(self)
@@ -112,6 +121,7 @@ class ClientWindow(QMainWindow):
112
121
  self.connection_log.setReadOnly(True)
113
122
  self.connection_log.setMaximumBlockCount(1000)
114
123
  tabs.addTab(self.connection_log, "Connection log")
124
+ tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
115
125
 
116
126
  self.discovery_panel.serverActivated.connect(self._on_server_activated)
117
127
  self.discovery_panel.serversFound.connect(self._record_discovered)
@@ -128,7 +138,6 @@ class ClientWindow(QMainWindow):
128
138
  self._server_key = ""
129
139
  self._frame_count = 0
130
140
  self.statusBar().showMessage("Not connected")
131
- self._settings = Settings(self._db)
132
141
  window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
133
142
  self.log("Client started")
134
143
  # Tests pass auto_scan=False so window tests never broadcast on the LAN.
@@ -180,10 +189,12 @@ class ClientWindow(QMainWindow):
180
189
  self._server_key, "attempt", name=server.name,
181
190
  address=self._server_key, detail=self._server_key,
182
191
  )
192
+ self.performance.reset() # graphs show only the current connection
183
193
  client = ShareClient(
184
194
  identity=self._identity,
185
195
  known_servers=self._known_servers,
186
196
  clipboard=self._clipboard,
197
+ performance=self.performance,
187
198
  parent=self,
188
199
  )
189
200
  self._client = client
@@ -254,7 +265,9 @@ class ClientWindow(QMainWindow):
254
265
 
255
266
  def main() -> None: # pragma: no cover - runs the Qt event loop
256
267
  log_path = logs.init_logging("client")
268
+ icon.set_windows_app_id("remotedesktop.client")
257
269
  app = QApplication(sys.argv)
270
+ app.setWindowIcon(icon.app_icon("client"))
258
271
  window = ClientWindow() # auto_scan starts the first LAN scan
259
272
  window.log(f"Detailed log: {log_path}")
260
273
  window.show()
@@ -0,0 +1,69 @@
1
+ """The application icon, drawn in code.
2
+
3
+ A monitor with a mouse pointer on the screen — "this desktop is controlled
4
+ remotely". Painted with QPainter at every common icon size (no binary asset
5
+ to version or package), with an accent color per app so the client (blue)
6
+ and server (green) are distinguishable in the taskbar when both run on one
7
+ machine.
8
+ """
9
+
10
+ import ctypes
11
+ import sys
12
+
13
+ from PySide6.QtCore import QPointF, QRectF, Qt
14
+ from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap, QPolygonF
15
+
16
+ _SIZES = (16, 24, 32, 48, 64, 128, 256)
17
+ _FRAME = QColor("#37474f")
18
+ _ACCENTS = {"client": QColor("#1e88e5"), "server": QColor("#43a047")}
19
+ # A classic cursor-arrow outline on a 0..17 grid, placed on the screen.
20
+ _POINTER = [(0, 0), (0, 14), (4, 11), (7, 17), (9.5, 16), (6.5, 10), (11, 10)]
21
+
22
+
23
+ def _pixmap(size: int, accent: QColor) -> QPixmap:
24
+ pixmap = QPixmap(size, size)
25
+ pixmap.fill(Qt.GlobalColor.transparent)
26
+ painter = QPainter(pixmap)
27
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
28
+ s = size / 64.0
29
+ painter.setPen(Qt.PenStyle.NoPen)
30
+ # Monitor frame, screen, stand, base.
31
+ painter.setBrush(_FRAME)
32
+ painter.drawRoundedRect(QRectF(4 * s, 6 * s, 56 * s, 40 * s), 5 * s, 5 * s)
33
+ painter.setBrush(accent)
34
+ painter.drawRoundedRect(QRectF(9 * s, 11 * s, 46 * s, 30 * s), 2 * s, 2 * s)
35
+ painter.setBrush(_FRAME)
36
+ painter.drawRect(QRectF(28 * s, 46 * s, 8 * s, 6 * s))
37
+ painter.drawRoundedRect(QRectF(18 * s, 52 * s, 28 * s, 5 * s), 2 * s, 2 * s)
38
+ # Mouse pointer on the screen: remote control.
39
+ pointer = QPolygonF(
40
+ [QPointF((22 + x * 1.4) * s, (14 + y * 1.4) * s) for x, y in _POINTER]
41
+ )
42
+ painter.setBrush(QColor("white"))
43
+ painter.drawPolygon(pointer)
44
+ painter.end()
45
+ return pixmap
46
+
47
+
48
+ def app_icon(role: str) -> QIcon:
49
+ """The window/taskbar icon for "client" or "server"."""
50
+ accent = _ACCENTS[role]
51
+ icon = QIcon()
52
+ for size in _SIZES:
53
+ icon.addPixmap(_pixmap(size, accent))
54
+ return icon
55
+
56
+
57
+ def set_windows_app_id(app_id: str) -> None:
58
+ """Give this process its own Windows taskbar identity.
59
+
60
+ Without it, Windows groups the app under the Python interpreter's
61
+ identity and may show the interpreter's icon in the taskbar instead of
62
+ the window icon. Harmless no-op off Windows or on failure.
63
+ """
64
+ if sys.platform != "win32": # pragma: no cover - Windows is the target
65
+ return
66
+ try:
67
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
68
+ except (AttributeError, OSError): # pragma: no cover - defensive
69
+ pass
@@ -0,0 +1,342 @@
1
+ """Connection performance monitoring: bandwidth and round-trip time.
2
+
3
+ `PerformanceMonitor` is an opt-in collaborator for `ShareServer`/`ShareClient`
4
+ (the `clipboard=` pattern): while at least one admitted stream is attached, a
5
+ 1 s timer samples the streams' framed byte counters into rolling bandwidth
6
+ series and pings the peer. Each side measures its own round-trip time and
7
+ piggybacks the latest measurement on its next ping (`"rtt"`), so both ends can
8
+ graph both directions. Peers that predate this feature simply never answer
9
+ pings — the RTT series stay empty and nothing else is affected.
10
+
11
+ Wire messages (JSON, admitted streams only):
12
+ {"type": "ping", "id": <int>, "rtt": <ms, omitted before first measurement>}
13
+ {"type": "pong", "id": <int>} # pure echo
14
+
15
+ `PerformanceTab` shows the graphs. It schedules no paint work while hidden
16
+ (background pages of a QTabWidget are hidden widgets): sampling continues in
17
+ the monitor, and the tab repaints on show with the accrued history.
18
+
19
+ All timing uses time.monotonic(), injectable as `clock=` for tests.
20
+ """
21
+
22
+ import itertools
23
+ import logging
24
+ import time
25
+ from collections import deque
26
+ from collections.abc import Callable
27
+ from typing import Protocol
28
+
29
+ from PySide6.QtCore import QObject, QPointF, QTimer, Qt, Signal
30
+ from PySide6.QtGui import QColor, QPainter, QPaintEvent, QPolygonF, QShowEvent
31
+ from PySide6.QtWidgets import QVBoxLayout, QWidget
32
+
33
+ _log = logging.getLogger("remotedesktop.performance")
34
+
35
+
36
+ class StreamLike(Protocol):
37
+ """What the monitor needs from a stream: `protocol.MessageStream`
38
+ satisfies this, and tests substitute a plain fake with a recording
39
+ send_json."""
40
+
41
+ bytes_sent: int
42
+ bytes_received: int
43
+
44
+ def send_json(self, message: dict) -> None: ...
45
+
46
+
47
+ DEFAULT_WINDOW_SECONDS = 120.0
48
+ DEFAULT_INTERVAL_MS = 1000
49
+ _PING_PRUNE_SECONDS = 30.0
50
+ _HEARTBEAT_TICKS = 10 # one debug-log line per this many samples
51
+
52
+
53
+ class MetricSeries:
54
+ """(monotonic time, value) samples kept for a rolling time window."""
55
+
56
+ def __init__(self, window_seconds: float, *, clock: Callable[[], float] = time.monotonic) -> None:
57
+ self._window = window_seconds
58
+ self._clock = clock
59
+ self._samples: deque[tuple[float, float]] = deque()
60
+
61
+ def add(self, value: float) -> None:
62
+ self._samples.append((self._clock(), value))
63
+ self._trim()
64
+
65
+ def samples(self) -> list[tuple[float, float]]:
66
+ self._trim()
67
+ return list(self._samples)
68
+
69
+ def latest(self) -> float | None:
70
+ return self._samples[-1][1] if self._samples else None
71
+
72
+ def clear(self) -> None:
73
+ self._samples.clear()
74
+
75
+ def set_window(self, window_seconds: float) -> None:
76
+ self._window = window_seconds
77
+ self._trim()
78
+
79
+ def _trim(self) -> None:
80
+ cutoff = self._clock() - self._window
81
+ while self._samples and self._samples[0][0] < cutoff:
82
+ self._samples.popleft()
83
+
84
+
85
+ class PerformanceMonitor(QObject):
86
+ """Samples bandwidth and measures RTT for the attached streams.
87
+
88
+ The timer runs only while streams are attached (the ShareServer._timer
89
+ discipline), so an idle app schedules no periodic work. Bandwidth
90
+ aggregates all attached streams; pings go to the most recently attached
91
+ stream only, which keeps the RTT series a single coherent line when a
92
+ server has several viewers.
93
+ """
94
+
95
+ updated = Signal() # one emit per sample tick
96
+
97
+ def __init__(
98
+ self,
99
+ *,
100
+ window_seconds: float = DEFAULT_WINDOW_SECONDS,
101
+ interval_ms: int = DEFAULT_INTERVAL_MS,
102
+ clock: Callable[[], float] = time.monotonic,
103
+ parent: QObject | None = None,
104
+ ) -> None:
105
+ super().__init__(parent)
106
+ self._clock = clock
107
+ self._window = window_seconds
108
+ self.send_bps = MetricSeries(window_seconds, clock=clock)
109
+ self.recv_bps = MetricSeries(window_seconds, clock=clock)
110
+ self.rtt_ms = MetricSeries(window_seconds, clock=clock)
111
+ self.peer_rtt_ms = MetricSeries(window_seconds, clock=clock)
112
+ self._streams: list[StreamLike] = []
113
+ self._baselines: dict[StreamLike, tuple[int, int]] = {}
114
+ self._active: StreamLike | None = None
115
+ self._pending: dict[int, float] = {}
116
+ self._ids = itertools.count(1)
117
+ self._last_tick: float | None = None
118
+ self._tick_count = 0
119
+ self._timer = QTimer(self)
120
+ self._timer.setInterval(interval_ms)
121
+ self._timer.timeout.connect(self._on_tick)
122
+
123
+ @property
124
+ def window_seconds(self) -> float:
125
+ return self._window
126
+
127
+ def set_window_seconds(self, window_seconds: float) -> None:
128
+ self._window = window_seconds
129
+ for series in (self.send_bps, self.recv_bps, self.rtt_ms, self.peer_rtt_ms):
130
+ series.set_window(window_seconds)
131
+
132
+ def add_stream(self, stream: StreamLike) -> None:
133
+ if stream not in self._baselines:
134
+ self._streams.append(stream)
135
+ self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
136
+ self._active = stream
137
+ if not self._timer.isActive():
138
+ self._last_tick = None
139
+ self._timer.start()
140
+
141
+ def remove_stream(self, stream: StreamLike) -> None:
142
+ if stream not in self._baselines:
143
+ return
144
+ self._streams.remove(stream)
145
+ del self._baselines[stream]
146
+ if self._active is stream:
147
+ self._active = self._streams[-1] if self._streams else None
148
+ if not self._streams:
149
+ self._timer.stop()
150
+ self._pending.clear()
151
+ self._last_tick = None
152
+
153
+ def reset(self) -> None:
154
+ """Detach everything and clear the graphs (new connection attempt)."""
155
+ for stream in list(self._streams):
156
+ self.remove_stream(stream)
157
+ for series in (self.send_bps, self.recv_bps, self.rtt_ms, self.peer_rtt_ms):
158
+ series.clear()
159
+
160
+ def handle_message(self, stream: StreamLike, message: dict) -> None:
161
+ match message.get("type"):
162
+ case "ping":
163
+ stream.send_json({"type": "pong", "id": message.get("id")})
164
+ rtt = message.get("rtt")
165
+ if isinstance(rtt, (int, float)) and stream is self._active:
166
+ self.peer_rtt_ms.add(float(rtt))
167
+ case "pong":
168
+ ping_id = message.get("id")
169
+ if isinstance(ping_id, int):
170
+ sent = self._pending.pop(ping_id, None)
171
+ if sent is not None:
172
+ self.rtt_ms.add((self._clock() - sent) * 1000.0)
173
+
174
+ def _on_tick(self) -> None:
175
+ now = self._clock()
176
+ delta_sent = delta_received = 0
177
+ for stream in self._streams:
178
+ base_sent, base_received = self._baselines[stream]
179
+ delta_sent += stream.bytes_sent - base_sent
180
+ delta_received += stream.bytes_received - base_received
181
+ self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
182
+ if self._last_tick is not None and (elapsed := now - self._last_tick) > 0:
183
+ self.send_bps.add(delta_sent / elapsed)
184
+ self.recv_bps.add(delta_received / elapsed)
185
+ self._last_tick = now
186
+
187
+ if self._active is not None:
188
+ for ping_id in [i for i, t in self._pending.items() if now - t > _PING_PRUNE_SECONDS]:
189
+ del self._pending[ping_id]
190
+ ping_id = next(self._ids)
191
+ self._pending[ping_id] = now
192
+ ping: dict = {"type": "ping", "id": ping_id}
193
+ if (rtt := self.rtt_ms.latest()) is not None:
194
+ ping["rtt"] = rtt
195
+ self._active.send_json(ping)
196
+
197
+ self._tick_count += 1
198
+ if self._tick_count % _HEARTBEAT_TICKS == 0:
199
+ _log.debug(
200
+ "send %.1f KB/s, recv %.1f KB/s, rtt %s, peer rtt %s, %d stream(s)",
201
+ (self.send_bps.latest() or 0.0) / 1024,
202
+ (self.recv_bps.latest() or 0.0) / 1024,
203
+ f"{rtt:.1f} ms" if (rtt := self.rtt_ms.latest()) is not None else "n/a",
204
+ f"{peer:.1f} ms" if (peer := self.peer_rtt_ms.latest()) is not None else "n/a",
205
+ len(self._streams),
206
+ )
207
+ self.updated.emit()
208
+
209
+
210
+ def format_rate(bytes_per_second: float) -> str:
211
+ if bytes_per_second >= 1024 * 1024:
212
+ return f"{bytes_per_second / (1024 * 1024):.1f} MB/s"
213
+ if bytes_per_second >= 1024:
214
+ return f"{bytes_per_second / 1024:.1f} KB/s"
215
+ return f"{bytes_per_second:.0f} B/s"
216
+
217
+
218
+ def format_ms(ms: float) -> str:
219
+ return f"{ms:.1f} ms"
220
+
221
+
222
+ class GraphWidget(QWidget):
223
+ """A rolling line graph over the monitor's window, painted with QPainter.
224
+
225
+ Holds references to the monitor's live MetricSeries and pulls their data
226
+ inside paintEvent, so a hidden graph does no per-sample work at all.
227
+ """
228
+
229
+ def __init__(
230
+ self,
231
+ title: str,
232
+ series: list[tuple[str, QColor, MetricSeries]],
233
+ monitor: PerformanceMonitor,
234
+ format_value: Callable[[float], str],
235
+ *,
236
+ clock: Callable[[], float] = time.monotonic,
237
+ parent: QWidget | None = None,
238
+ ) -> None:
239
+ super().__init__(parent)
240
+ self._title = title
241
+ self._series = series
242
+ self._monitor = monitor
243
+ self._format_value = format_value
244
+ self._clock = clock
245
+ self.setMinimumSize(300, 120)
246
+
247
+ def paintEvent(self, event: QPaintEvent) -> None:
248
+ painter = QPainter(self)
249
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
250
+ palette = self.palette()
251
+ painter.fillRect(self.rect(), palette.base())
252
+ painter.setPen(palette.color(palette.ColorRole.Mid))
253
+ painter.drawRect(self.rect().adjusted(0, 0, -1, -1))
254
+ painter.setPen(palette.color(palette.ColorRole.Text))
255
+ painter.drawText(8, 16, self._title)
256
+
257
+ window = self._monitor.window_seconds
258
+ now = self._clock()
259
+ data = [(label, color, series.samples()) for label, color, series in self._series]
260
+ if not any(samples for _label, _color, samples in data):
261
+ painter.drawText(
262
+ self.rect(), Qt.AlignmentFlag.AlignCenter, "no data"
263
+ )
264
+ return
265
+ y_max = max(
266
+ (value for _l, _c, samples in data for _t, value in samples), default=1.0
267
+ )
268
+ y_max = max(y_max, 1.0)
269
+
270
+ graph = self.rect().adjusted(8, 24, -8, -20)
271
+ for label, color, samples in data:
272
+ if not samples:
273
+ continue
274
+ polyline = QPolygonF()
275
+ for t, value in samples:
276
+ x = graph.left() + graph.width() * (1 - (now - t) / window)
277
+ y = graph.bottom() - graph.height() * (value / y_max)
278
+ polyline.append(QPointF(x, y))
279
+ painter.setPen(color)
280
+ painter.drawPolyline(polyline)
281
+
282
+ # Legend with latest values along the bottom edge.
283
+ x = graph.left()
284
+ for label, color, samples in data:
285
+ latest = samples[-1][1] if samples else None
286
+ text = f"{label} {self._format_value(latest)}" if latest is not None else f"{label} —"
287
+ painter.setPen(color)
288
+ painter.drawText(x, self.rect().bottom() - 6, text)
289
+ x += painter.fontMetrics().horizontalAdvance(text) + 16
290
+ painter.setPen(palette.color(palette.ColorRole.Text))
291
+ painter.drawText(
292
+ self.rect().right() - 8 - painter.fontMetrics().horizontalAdvance(self._format_value(y_max)),
293
+ 16,
294
+ self._format_value(y_max),
295
+ )
296
+
297
+
298
+ class PerformanceTab(QWidget):
299
+ """Bandwidth and round-trip-time graphs for the current connection.
300
+
301
+ Graphs repaint only while this tab is visible: sampling continues in the
302
+ monitor regardless, but a background tab schedules zero paint work.
303
+ """
304
+
305
+ def __init__(self, monitor: PerformanceMonitor, parent: QWidget | None = None) -> None:
306
+ super().__init__(parent)
307
+ self._monitor = monitor
308
+ self.bandwidth_graph = GraphWidget(
309
+ "Bandwidth",
310
+ [
311
+ ("send", QColor("#2196f3"), monitor.send_bps),
312
+ ("receive", QColor("#4caf50"), monitor.recv_bps),
313
+ ],
314
+ monitor,
315
+ format_rate,
316
+ )
317
+ self.ping_graph = GraphWidget(
318
+ "Round-trip time",
319
+ [
320
+ ("this side", QColor("#ff9800"), monitor.rtt_ms),
321
+ ("peer", QColor("#9c27b0"), monitor.peer_rtt_ms),
322
+ ],
323
+ monitor,
324
+ format_ms,
325
+ )
326
+ layout = QVBoxLayout(self)
327
+ layout.addWidget(self.bandwidth_graph, stretch=1)
328
+ layout.addWidget(self.ping_graph, stretch=1)
329
+ monitor.updated.connect(self._refresh)
330
+
331
+ def _refresh(self) -> None:
332
+ if not self.isVisible():
333
+ return # hidden tab page: schedule no paint work at all
334
+ self._refresh_now()
335
+
336
+ def _refresh_now(self) -> None:
337
+ self.bandwidth_graph.update()
338
+ self.ping_graph.update()
339
+
340
+ def showEvent(self, event: QShowEvent) -> None:
341
+ super().showEvent(event)
342
+ self._refresh_now() # repaint with the history accrued while hidden
@@ -0,0 +1,49 @@
1
+ """The Preferences tab: user-adjustable settings, persisted in `Settings`.
2
+
3
+ Currently one preference — the performance-history window length. Values live
4
+ in the shared SQLite settings table (the single-database architecture), so
5
+ they survive restarts and are injectable in tests like every other store.
6
+ """
7
+
8
+ from PySide6.QtWidgets import QFormLayout, QSpinBox, QWidget
9
+
10
+ from remotedesktop.config import Settings
11
+ from remotedesktop.performance import PerformanceMonitor
12
+
13
+ PERFORMANCE_WINDOW_KEY = "performance_window_seconds"
14
+ DEFAULT_PERFORMANCE_WINDOW_SECONDS = 120
15
+
16
+
17
+ def load_performance_window_seconds(settings: Settings) -> int:
18
+ raw = settings.get(PERFORMANCE_WINDOW_KEY, str(DEFAULT_PERFORMANCE_WINDOW_SECONDS))
19
+ try:
20
+ value = int(raw) # ty: ignore[invalid-argument-type]
21
+ except (TypeError, ValueError):
22
+ return DEFAULT_PERFORMANCE_WINDOW_SECONDS
23
+ return value if value > 0 else DEFAULT_PERFORMANCE_WINDOW_SECONDS
24
+
25
+
26
+ class PreferencesTab(QWidget):
27
+ def __init__(
28
+ self,
29
+ settings: Settings,
30
+ performance: PerformanceMonitor,
31
+ parent: QWidget | None = None,
32
+ ) -> None:
33
+ super().__init__(parent)
34
+ self._settings = settings
35
+ self._performance = performance
36
+ self.history_minutes = QSpinBox()
37
+ self.history_minutes.setRange(1, 30)
38
+ self.history_minutes.setSuffix(" min")
39
+ self.history_minutes.setValue(
40
+ max(1, round(load_performance_window_seconds(settings) / 60))
41
+ )
42
+ self.history_minutes.valueChanged.connect(self._on_history_changed)
43
+ layout = QFormLayout(self)
44
+ layout.addRow("Performance history", self.history_minutes)
45
+
46
+ def _on_history_changed(self, minutes: int) -> None:
47
+ seconds = minutes * 60
48
+ self._settings.set(PERFORMANCE_WINDOW_KEY, str(seconds))
49
+ self._performance.set_window_seconds(float(seconds))
@@ -44,6 +44,10 @@ class MessageStream(QObject):
44
44
  super().__init__(parent)
45
45
  self.socket = socket
46
46
  self.max_payload = max_payload
47
+ # Framed application bytes (headers included, TLS overhead excluded),
48
+ # sampled by the performance monitor to compute bandwidth.
49
+ self.bytes_sent = 0
50
+ self.bytes_received = 0
47
51
  socket.readyRead.connect(self._on_ready_read)
48
52
 
49
53
  def send_json(self, message: dict) -> None:
@@ -56,6 +60,7 @@ class MessageStream(QObject):
56
60
  self._send(_KIND_DELTA, payload)
57
61
 
58
62
  def _send(self, kind: int, payload: bytes) -> None:
63
+ self.bytes_sent += _HEADER.size + len(payload)
59
64
  self.socket.write(_HEADER.pack(len(payload), kind) + payload)
60
65
 
61
66
  def _on_ready_read(self) -> None:
@@ -79,6 +84,7 @@ class MessageStream(QObject):
79
84
  return
80
85
  self.socket.read(_HEADER.size)
81
86
  payload = self.socket.read(length).data()
87
+ self.bytes_received += _HEADER.size + length
82
88
  if kind == _KIND_JSON:
83
89
  try:
84
90
  message = json.loads(bytes(payload).decode())