remotedesktop 0.7.0__tar.gz → 0.8.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 (51) hide show
  1. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/CLAUDE.md +1 -0
  2. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/PKG-INFO +6 -2
  3. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/readme.md +5 -1
  4. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/client.py +11 -1
  6. remotedesktop-0.8.0/src/remotedesktop/performance.py +342 -0
  7. remotedesktop-0.8.0/src/remotedesktop/preferences.py +49 -0
  8. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/protocol.py +6 -0
  9. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/server.py +52 -2
  10. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/sharing.py +31 -0
  11. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_client_window.py +4 -0
  12. remotedesktop-0.8.0/tests/test_performance.py +250 -0
  13. remotedesktop-0.8.0/tests/test_preferences.py +31 -0
  14. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_server_window.py +41 -1
  15. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/.github/workflows/ci.yml +0 -0
  16. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/.gitignore +0 -0
  17. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/LICENSE +0 -0
  18. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/pyproject.toml +0 -0
  19. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/run_claude.bat +0 -0
  20. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/run_client.bat +0 -0
  21. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/run_server.bat +0 -0
  22. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/scripts/make_coverage_badge.py +0 -0
  23. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/scripts/make_venv.bat +0 -0
  24. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/autostart.py +0 -0
  25. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/clipboard.py +0 -0
  26. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/config.py +0 -0
  27. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/db.py +0 -0
  28. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/discovery.py +0 -0
  29. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/frames.py +0 -0
  30. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/input_injection.py +0 -0
  31. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/inventory.py +0 -0
  32. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/logs.py +0 -0
  33. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/tls.py +0 -0
  34. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/viewer.py +0 -0
  35. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/window_state.py +0 -0
  36. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/conftest.py +0 -0
  37. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_autostart.py +0 -0
  38. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_clipboard.py +0 -0
  39. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_config.py +0 -0
  40. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_discovery.py +0 -0
  41. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_frames.py +0 -0
  42. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_input.py +0 -0
  43. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_input_injection.py +0 -0
  44. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_inventory.py +0 -0
  45. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_logs.py +0 -0
  46. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_protocol.py +0 -0
  47. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_sharing.py +0 -0
  48. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_smoke.py +0 -0
  49. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_tls.py +0 -0
  50. {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_window_state.py +0 -0
  51. {remotedesktop-0.7.0 → remotedesktop-0.8.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.8.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.8.0"
@@ -28,6 +28,8 @@ 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
 
@@ -93,6 +95,11 @@ class ClientWindow(QMainWindow):
93
95
  self.setWindowTitle("Remote Desktop Client")
94
96
  # Tests inject a connection to a temp database; the app uses the default.
95
97
  self._db = connection if connection is not None else db.connect(default_db_path())
98
+ self._settings = Settings(self._db)
99
+ self.performance = PerformanceMonitor(
100
+ window_seconds=float(load_performance_window_seconds(self._settings)),
101
+ parent=self,
102
+ )
96
103
  self.viewer = ViewerWidget(self)
97
104
  self.inventory = ConnectionInventory(self._db, "client_peers", self)
98
105
  tabs = QTabWidget()
@@ -101,6 +108,7 @@ class ClientWindow(QMainWindow):
101
108
  InventoryTab(self.inventory, "Forget server", self._forget_server),
102
109
  "Servers on LAN",
103
110
  )
111
+ tabs.addTab(PerformanceTab(self.performance), "Performance")
104
112
  self.setCentralWidget(tabs)
105
113
 
106
114
  self.discovery_panel = DiscoveryPanel(self)
@@ -112,6 +120,7 @@ class ClientWindow(QMainWindow):
112
120
  self.connection_log.setReadOnly(True)
113
121
  self.connection_log.setMaximumBlockCount(1000)
114
122
  tabs.addTab(self.connection_log, "Connection log")
123
+ tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
115
124
 
116
125
  self.discovery_panel.serverActivated.connect(self._on_server_activated)
117
126
  self.discovery_panel.serversFound.connect(self._record_discovered)
@@ -128,7 +137,6 @@ class ClientWindow(QMainWindow):
128
137
  self._server_key = ""
129
138
  self._frame_count = 0
130
139
  self.statusBar().showMessage("Not connected")
131
- self._settings = Settings(self._db)
132
140
  window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
133
141
  self.log("Client started")
134
142
  # Tests pass auto_scan=False so window tests never broadcast on the LAN.
@@ -180,10 +188,12 @@ class ClientWindow(QMainWindow):
180
188
  self._server_key, "attempt", name=server.name,
181
189
  address=self._server_key, detail=self._server_key,
182
190
  )
191
+ self.performance.reset() # graphs show only the current connection
183
192
  client = ShareClient(
184
193
  identity=self._identity,
185
194
  known_servers=self._known_servers,
186
195
  clipboard=self._clipboard,
196
+ performance=self.performance,
187
197
  parent=self,
188
198
  )
189
199
  self._client = client
@@ -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())
@@ -7,7 +7,7 @@ import sqlite3
7
7
  import sys
8
8
  import time
9
9
 
10
- from PySide6.QtCore import Qt
10
+ from PySide6.QtCore import QProcess, Qt
11
11
  from PySide6.QtGui import QCloseEvent
12
12
  from PySide6.QtWidgets import (
13
13
  QApplication,
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
16
16
  QMainWindow,
17
17
  QMessageBox,
18
18
  QPlainTextEdit,
19
+ QPushButton,
19
20
  QTabWidget,
20
21
  QVBoxLayout,
21
22
  QWidget,
@@ -31,6 +32,8 @@ from remotedesktop.discovery import (
31
32
  DiscoveryResponder,
32
33
  )
33
34
  from remotedesktop.inventory import ConnectionInventory, InventoryTab
35
+ from remotedesktop.performance import PerformanceMonitor, PerformanceTab
36
+ from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
34
37
  from remotedesktop.sharing import ShareServer
35
38
 
36
39
  _log = logging.getLogger("remotedesktop.server")
@@ -60,14 +63,27 @@ class ServerWindow(QMainWindow):
60
63
  self.connection_log = QPlainTextEdit()
61
64
  self.connection_log.setReadOnly(True)
62
65
  self.connection_log.setMaximumBlockCount(1000)
66
+ self.restart_button = QPushButton("Restart server")
67
+ self.restart_button.setToolTip(
68
+ "Relaunch this app (e.g. after updating the software). It can be "
69
+ "clicked from a remote desktop session, so an update doesn't "
70
+ "require visiting this computer."
71
+ )
72
+ self.restart_button.clicked.connect(self._restart_server)
63
73
  status_tab = QWidget()
64
74
  status_layout = QVBoxLayout(status_tab)
65
75
  status_layout.addWidget(self._summary)
66
76
  status_layout.addWidget(self.autostart_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
77
+ status_layout.addWidget(self.restart_button, alignment=Qt.AlignmentFlag.AlignHCenter)
67
78
  status_layout.addStretch(1)
68
79
 
69
80
  # Tests inject a connection to a temp database; the app uses the default.
70
81
  self._db = connection if connection is not None else db.connect(default_db_path())
82
+ self._settings = Settings(self._db)
83
+ self.performance = PerformanceMonitor(
84
+ window_seconds=float(load_performance_window_seconds(self._settings)),
85
+ parent=self,
86
+ )
71
87
  self.inventory = ConnectionInventory(self._db, "server_peers", self)
72
88
  tabs = QTabWidget()
73
89
  tabs.addTab(status_tab, "Status")
@@ -75,7 +91,9 @@ class ServerWindow(QMainWindow):
75
91
  InventoryTab(self.inventory, "Revoke access", self._revoke_client),
76
92
  "Clients on LAN",
77
93
  )
94
+ tabs.addTab(PerformanceTab(self.performance), "Performance")
78
95
  tabs.addTab(self.connection_log, "Connection log")
96
+ tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
79
97
  self.setCentralWidget(tabs)
80
98
 
81
99
  if credentials is None:
@@ -91,6 +109,7 @@ class ServerWindow(QMainWindow):
91
109
  credentials=credentials,
92
110
  paired=paired,
93
111
  clipboard=self._clipboard,
112
+ performance=self.performance,
94
113
  parent=self,
95
114
  )
96
115
  self.share_server.status.connect(self.log)
@@ -119,7 +138,6 @@ class ServerWindow(QMainWindow):
119
138
  f"(UDP port {discovery_port}, TCP port {self.share_server.port})"
120
139
  )
121
140
  self._update_summary(0)
122
- self._settings = Settings(self._db)
123
141
  window_state.restore_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
124
142
 
125
143
  def log(self, message: str) -> None:
@@ -169,6 +187,38 @@ class ServerWindow(QMainWindow):
169
187
  if answer == QMessageBox.StandardButton.Yes:
170
188
  self.share_server.revoke_client(client_id)
171
189
 
190
+ def _restart_server(self) -> None:
191
+ """Relaunch this app in a new process and exit.
192
+
193
+ Meant to be clicked through a remote desktop session after updating
194
+ the software, so the new version starts without anyone at this
195
+ computer. The listening sockets are closed before spawning so the
196
+ replacement can bind the same ports.
197
+ """
198
+ answer = QMessageBox.question(
199
+ self,
200
+ "Restart server",
201
+ "Restart the server app?\n\n"
202
+ "Viewers will be disconnected and can reconnect in a few seconds "
203
+ "(approved clients reconnect without a new permission prompt).",
204
+ )
205
+ if answer != QMessageBox.StandardButton.Yes:
206
+ return
207
+ self.log("Restarting: freeing ports and launching a new server process")
208
+ _log.info("Restart requested — relaunching %s -m remotedesktop.server", sys.executable)
209
+ if self.responder is not None:
210
+ self.responder.stop()
211
+ self.responder = None
212
+ self.share_server.close()
213
+ if not QProcess.startDetached(sys.executable, ["-m", "remotedesktop.server"]):
214
+ # Extremely unlikely (sys.executable exists); the app stays open —
215
+ # sharing is stopped, but the machine isn't left with nothing.
216
+ self.log("Restart failed: could not launch a new process — restart manually")
217
+ _log.error("QProcess.startDetached failed for %s", sys.executable)
218
+ return
219
+ self.close()
220
+ QApplication.quit()
221
+
172
222
  def _ask_approval(self, client_id: str, client_name: str) -> bool:
173
223
  box = QMessageBox(
174
224
  QMessageBox.Icon.Question,
@@ -40,6 +40,7 @@ from remotedesktop.config import (
40
40
  from remotedesktop.discovery import DEFAULT_CONNECT_PORT
41
41
  from remotedesktop import frames
42
42
  from remotedesktop.input_injection import InputInjector
43
+ from remotedesktop.performance import PerformanceMonitor
43
44
  from remotedesktop.protocol import MAX_PAYLOAD, PROTOCOL_VERSION, MessageStream
44
45
  from remotedesktop import db, tls
45
46
 
@@ -87,9 +88,11 @@ class ShareServer(QObject):
87
88
  fps: int = DEFAULT_FPS,
88
89
  injector: InputInjector | None = None,
89
90
  clipboard=None,
91
+ performance: PerformanceMonitor | None = None,
90
92
  parent: QObject | None = None,
91
93
  ) -> None:
92
94
  super().__init__(parent)
95
+ self._performance = performance
93
96
  self._approve_client = approve_client
94
97
  self._paired = paired if paired is not None else PairedClients(db.connect(default_db_path()))
95
98
  self._fps = fps
@@ -170,6 +173,8 @@ class ShareServer(QObject):
170
173
  self._previous_frame = None
171
174
  if had_clients:
172
175
  self.clientCountChanged.emit(0)
176
+ if self._performance is not None:
177
+ self._performance.reset()
173
178
  self._server.close()
174
179
  self.status.emit("Server closed")
175
180
 
@@ -198,6 +203,11 @@ class ShareServer(QObject):
198
203
  stream.jsonReceived.connect(lambda m, s=stream: self._on_message(s, m))
199
204
 
200
205
  def _on_message(self, stream: MessageStream, message: dict) -> None:
206
+ if message.get("type") in ("ping", "pong"):
207
+ # Same admission rule as input/clipboard: pre-auth peers get nothing.
208
+ if stream in self._streams and self._performance is not None:
209
+ self._performance.handle_message(stream, message)
210
+ return
201
211
  if message.get("type") == "input":
202
212
  if stream in self._streams:
203
213
  self._inject(stream, message)
@@ -330,6 +340,8 @@ class ShareServer(QObject):
330
340
  stream.send_json(welcome)
331
341
  self._needs_keyframe.add(stream) # its first frame must be a full one
332
342
  self._streams.append(stream)
343
+ if self._performance is not None:
344
+ self._performance.add_stream(stream)
333
345
  self.clientCountChanged.emit(len(self._streams))
334
346
  self.status.emit(
335
347
  f'Streaming screen to "{client_name}" at {self._fps} fps '
@@ -396,6 +408,10 @@ class ShareServer(QObject):
396
408
  stream.send_json(message)
397
409
 
398
410
  def _drop(self, stream: MessageStream) -> None:
411
+ # Detach from the monitor before the deferred delete below, so it
412
+ # never samples a deleted stream (idempotent for never-admitted ones).
413
+ if self._performance is not None:
414
+ self._performance.remove_stream(stream)
399
415
  self._controllers.discard(stream)
400
416
  self._all_streams.discard(stream)
401
417
  self._prompting.discard(stream)
@@ -511,9 +527,11 @@ class ShareClient(QObject):
511
527
  *,
512
528
  known_servers: KnownServers | None = None,
513
529
  clipboard=None,
530
+ performance: PerformanceMonitor | None = None,
514
531
  parent: QObject | None = None,
515
532
  ) -> None:
516
533
  super().__init__(parent)
534
+ self._performance = performance
517
535
  self._client_id, self._name = identity or load_client_identity(
518
536
  db.connect(default_db_path())
519
537
  )
@@ -545,6 +563,9 @@ class ShareClient(QObject):
545
563
 
546
564
  def connect_to(self, host: str, port: int) -> None:
547
565
  self._socket.abort() # drop any previous connection or attempt
566
+ if self._performance is not None:
567
+ # abort() emits no disconnected signal, so detach explicitly.
568
+ self._performance.remove_stream(self._stream)
548
569
  self._got_first_frame = False
549
570
  self._frame_count = 0
550
571
  self._last_image = None
@@ -600,6 +621,8 @@ class ShareClient(QObject):
600
621
  self._stream.send_json(hello)
601
622
 
602
623
  def _on_disconnected(self) -> None:
624
+ if self._performance is not None:
625
+ self._performance.remove_stream(self._stream)
603
626
  self.status.emit("Disconnected from server")
604
627
  self.disconnected.emit()
605
628
 
@@ -626,6 +649,11 @@ class ShareClient(QObject):
626
649
  f'Server "{name}" accepted the connection — waiting for first frame'
627
650
  )
628
651
  self.connected.emit(name)
652
+ if self._performance is not None:
653
+ # Attach only once admitted: handshake traffic is never
654
+ # sampled and no ping goes to a server that hasn't
655
+ # welcomed us.
656
+ self._performance.add_stream(self._stream)
629
657
  case "pending":
630
658
  self.status.emit(
631
659
  "Server is asking its user for permission — waiting for approval"
@@ -639,6 +667,9 @@ class ShareClient(QObject):
639
667
  if self._clipboard is not None:
640
668
  self.status.emit("Clipboard update received from server")
641
669
  self._clipboard.apply(message)
670
+ case "ping" | "pong":
671
+ if self._performance is not None:
672
+ self._performance.handle_message(self._stream, message)
642
673
 
643
674
  def _on_frame(self, data: bytes) -> None:
644
675
  # Full frame — PNG keyframe or (legacy servers) JPEG; sniffed from
@@ -30,6 +30,10 @@ def test_window_starts_disconnected(qapp, tmp_path):
30
30
  window = make_window(tmp_path)
31
31
  assert window.statusBar().currentMessage() == "Not connected"
32
32
  assert "Client started" in window.connection_log.toPlainText()
33
+ tabs = window.centralWidget()
34
+ labels = [tabs.tabText(i) for i in range(tabs.count())]
35
+ assert "Performance" in labels and "Preferences" in labels
36
+ assert not window.performance._timer.isActive() # idle: no periodic work
33
37
  window.close()
34
38
 
35
39
 
@@ -0,0 +1,250 @@
1
+ import json
2
+
3
+ import pytest
4
+ from PySide6.QtGui import QShowEvent
5
+ from PySide6.QtNetwork import QHostAddress, QTcpServer, QTcpSocket
6
+ from PySide6.QtWidgets import QApplication
7
+
8
+ from remotedesktop import db
9
+ from remotedesktop.config import KnownServers, PairedClients
10
+ from remotedesktop.performance import (
11
+ GraphWidget,
12
+ MetricSeries,
13
+ PerformanceMonitor,
14
+ PerformanceTab,
15
+ )
16
+ from remotedesktop.protocol import MessageStream
17
+ from remotedesktop.sharing import ShareClient, ShareServer
18
+
19
+ from test_sharing import IDENTITY, pump
20
+
21
+
22
+ class FakeClock:
23
+ def __init__(self) -> None:
24
+ self.t = 1000.0
25
+
26
+ def __call__(self) -> float:
27
+ return self.t
28
+
29
+ def advance(self, dt: float) -> None:
30
+ self.t += dt
31
+
32
+
33
+ class FakeStream:
34
+ def __init__(self) -> None:
35
+ self.bytes_sent = 0
36
+ self.bytes_received = 0
37
+ self.sent: list[dict] = []
38
+
39
+ def send_json(self, message: dict) -> None:
40
+ self.sent.append(message)
41
+
42
+
43
+ def test_metric_series_trims_to_window():
44
+ clock = FakeClock()
45
+ series = MetricSeries(10.0, clock=clock)
46
+ series.add(1.0)
47
+ clock.advance(5)
48
+ series.add(2.0)
49
+ clock.advance(6) # first sample is now 11 s old, second 6 s
50
+ assert [value for _t, value in series.samples()] == [2.0]
51
+ series.set_window(1.0) # tightening the window re-trims immediately
52
+ assert series.samples() == []
53
+
54
+
55
+ def test_monitor_samples_aggregate_bandwidth(qapp):
56
+ clock = FakeClock()
57
+ monitor = PerformanceMonitor(window_seconds=60, clock=clock)
58
+ first, second = FakeStream(), FakeStream()
59
+ monitor.add_stream(first)
60
+ monitor._on_tick() # first tick establishes the baseline; no rate sample
61
+ assert monitor.send_bps.latest() is None
62
+ first.bytes_sent += 1000
63
+ first.bytes_received += 500
64
+ clock.advance(2.0)
65
+ monitor._on_tick()
66
+ assert monitor.send_bps.latest() == 500.0
67
+ assert monitor.recv_bps.latest() == 250.0
68
+ # A second stream aggregates; removing it never yields a negative rate.
69
+ monitor.add_stream(second)
70
+ second.bytes_sent += 300
71
+ clock.advance(1.0)
72
+ monitor._on_tick()
73
+ assert monitor.send_bps.latest() == 300.0
74
+ monitor.remove_stream(second)
75
+ clock.advance(1.0)
76
+ monitor._on_tick()
77
+ assert monitor.send_bps.latest() == 0.0
78
+
79
+
80
+ def test_monitor_skips_rate_on_zero_elapsed(qapp):
81
+ monitor = PerformanceMonitor(clock=FakeClock()) # clock never advances
82
+ monitor.add_stream(FakeStream())
83
+ monitor._on_tick()
84
+ monitor._on_tick()
85
+ assert monitor.send_bps.latest() is None
86
+
87
+
88
+ def test_ping_pong_records_rtt_and_replies(qapp):
89
+ clock = FakeClock()
90
+ monitor = PerformanceMonitor(clock=clock)
91
+ stream = FakeStream()
92
+ monitor.add_stream(stream)
93
+ monitor._on_tick()
94
+ ping = stream.sent[-1]
95
+ assert ping["type"] == "ping"
96
+ assert "rtt" not in ping # nothing measured yet
97
+ clock.advance(0.05)
98
+ monitor.handle_message(stream, {"type": "pong", "id": ping["id"]})
99
+ assert monitor.rtt_ms.latest() == pytest.approx(50.0)
100
+ clock.advance(1.0)
101
+ monitor._on_tick()
102
+ assert stream.sent[-1]["rtt"] == pytest.approx(50.0) # piggybacked
103
+ # An incoming ping is answered with an echoing pong and records peer RTT.
104
+ monitor.handle_message(stream, {"type": "ping", "id": 99, "rtt": 12.5})
105
+ assert stream.sent[-1] == {"type": "pong", "id": 99}
106
+ assert monitor.peer_rtt_ms.latest() == 12.5
107
+
108
+
109
+ def test_stale_or_malformed_pongs_are_ignored(qapp):
110
+ monitor = PerformanceMonitor(clock=FakeClock())
111
+ stream = FakeStream()
112
+ monitor.add_stream(stream)
113
+ monitor.handle_message(stream, {"type": "pong", "id": 12345})
114
+ monitor.handle_message(stream, {"type": "pong", "id": "weird"})
115
+ monitor.handle_message(stream, {"type": "pong"})
116
+ assert monitor.rtt_ms.latest() is None
117
+
118
+
119
+ def test_reset_clears_everything_and_stops_timer(qapp):
120
+ clock = FakeClock()
121
+ monitor = PerformanceMonitor(clock=clock)
122
+ monitor.add_stream(FakeStream())
123
+ monitor._on_tick()
124
+ clock.advance(1.0)
125
+ monitor._on_tick()
126
+ assert monitor._timer.isActive()
127
+ monitor.reset()
128
+ assert not monitor._timer.isActive()
129
+ assert monitor.send_bps.samples() == []
130
+ assert monitor.rtt_ms.samples() == []
131
+ assert monitor._pending == {}
132
+
133
+
134
+ def test_message_stream_counts_framed_bytes(qapp):
135
+ listener = QTcpServer()
136
+ assert listener.listen(QHostAddress.SpecialAddress.LocalHost, 0)
137
+ out_sock = QTcpSocket()
138
+ out_sock.connectToHost("127.0.0.1", listener.serverPort())
139
+ pump(qapp, lambda: listener.hasPendingConnections())
140
+ in_sock = listener.nextPendingConnection()
141
+ sender, receiver = MessageStream(out_sock), MessageStream(in_sock)
142
+ got = []
143
+ receiver.jsonReceived.connect(got.append)
144
+ receiver.frameReceived.connect(got.append)
145
+ try:
146
+ sender.send_json({"a": 1})
147
+ sender.send_frame(b"xyz")
148
+ pump(qapp, lambda: len(got) == 2)
149
+ # 4-byte length + 1 kind byte per message, plus the payloads.
150
+ expected = (5 + len(json.dumps({"a": 1}).encode())) + (5 + 3)
151
+ assert sender.bytes_sent == expected
152
+ assert receiver.bytes_received == expected
153
+ finally:
154
+ out_sock.abort()
155
+ listener.close()
156
+
157
+
158
+ def make_pair(credentials, tmp_path, *, server_perf, client_perf):
159
+ server = ShareServer(
160
+ approve_client=lambda *_: True,
161
+ credentials=credentials,
162
+ paired=PairedClients(db.connect(tmp_path / "server.db")),
163
+ performance=server_perf,
164
+ )
165
+ assert server.listen(0)
166
+ client = ShareClient(
167
+ identity=IDENTITY,
168
+ known_servers=KnownServers(db.connect(tmp_path / "client.db")),
169
+ performance=client_perf,
170
+ )
171
+ return server, client
172
+
173
+
174
+ def test_rtt_and_bandwidth_measured_on_both_ends(qapp, credentials, tmp_path):
175
+ server_perf = PerformanceMonitor(interval_ms=50)
176
+ client_perf = PerformanceMonitor(interval_ms=50)
177
+ server, client = make_pair(
178
+ credentials, tmp_path, server_perf=server_perf, client_perf=client_perf
179
+ )
180
+ client.connect_to("127.0.0.1", server.port)
181
+ try:
182
+ # Both sides measure their own RTT ...
183
+ pump(qapp, lambda: client_perf.rtt_ms.latest() is not None)
184
+ pump(qapp, lambda: server_perf.rtt_ms.latest() is not None)
185
+ # ... and learn the peer's measurement from piggybacked pings.
186
+ pump(qapp, lambda: client_perf.peer_rtt_ms.latest() is not None)
187
+ pump(qapp, lambda: server_perf.peer_rtt_ms.latest() is not None)
188
+ for series in (client_perf.rtt_ms, client_perf.peer_rtt_ms):
189
+ assert all(value >= 0 for _t, value in series.samples())
190
+ # Bandwidth flows: frames server->client, pings both ways.
191
+ pump(qapp, lambda: client_perf.recv_bps.latest() is not None)
192
+ pump(qapp, lambda: server_perf.send_bps.latest() is not None)
193
+ assert any(value > 0 for _t, value in client_perf.recv_bps.samples())
194
+ finally:
195
+ client.close()
196
+ server.close()
197
+
198
+
199
+ def test_old_peer_leaves_rtt_series_empty(qapp, credentials, tmp_path):
200
+ # A performance-less server takes the exact code path a 0.7.0 server
201
+ # takes for ping messages: silently dropped, never answered.
202
+ client_perf = PerformanceMonitor(interval_ms=50)
203
+ server, client = make_pair(
204
+ credentials, tmp_path, server_perf=None, client_perf=client_perf
205
+ )
206
+ frames = []
207
+ client.frameReceived.connect(frames.append)
208
+ client.connect_to("127.0.0.1", server.port)
209
+ try:
210
+ pump(qapp, lambda: len(client_perf.recv_bps.samples()) >= 2)
211
+ assert client_perf.rtt_ms.latest() is None
212
+ assert client_perf.peer_rtt_ms.latest() is None
213
+ assert frames # unanswered pings don't harm the connection
214
+ finally:
215
+ client.close()
216
+ server.close()
217
+
218
+
219
+ def test_graph_widgets_render_headless(qapp):
220
+ seeded = PerformanceMonitor()
221
+ seeded.send_bps.add(100.0)
222
+ seeded.send_bps.add(2048.0)
223
+ seeded.recv_bps.add(50.0)
224
+ for monitor in (seeded, PerformanceMonitor()): # data and "no data" paths
225
+ tab = PerformanceTab(monitor)
226
+ tab.resize(400, 300)
227
+ pixmap = tab.grab()
228
+ assert not pixmap.isNull()
229
+ assert pixmap.width() > 0
230
+
231
+
232
+ def test_tab_schedules_no_paints_while_hidden(qapp, monkeypatch):
233
+ monitor = PerformanceMonitor()
234
+ _tab = PerformanceTab(monitor) # subscribes to monitor.updated
235
+ painted = []
236
+ monkeypatch.setattr(GraphWidget, "update", lambda self: painted.append(self))
237
+ monitor.updated.emit() # tab was never shown -> not visible
238
+ assert painted == []
239
+ monkeypatch.setattr(PerformanceTab, "isVisible", lambda self: True)
240
+ monitor.updated.emit()
241
+ assert len(painted) == 2 # both graphs refreshed
242
+
243
+
244
+ def test_tab_refreshes_on_show_event(qapp, monkeypatch):
245
+ monitor = PerformanceMonitor()
246
+ tab = PerformanceTab(monitor)
247
+ painted = []
248
+ monkeypatch.setattr(GraphWidget, "update", lambda self: painted.append(self))
249
+ QApplication.sendEvent(tab, QShowEvent())
250
+ assert len(painted) == 2
@@ -0,0 +1,31 @@
1
+ from remotedesktop import db
2
+ from remotedesktop.config import Settings
3
+ from remotedesktop.performance import PerformanceMonitor
4
+ from remotedesktop.preferences import (
5
+ PERFORMANCE_WINDOW_KEY,
6
+ PreferencesTab,
7
+ load_performance_window_seconds,
8
+ )
9
+
10
+
11
+ def test_default_and_invalid_values_fall_back(tmp_path):
12
+ settings = Settings(db.connect(tmp_path / "prefs.db"))
13
+ assert load_performance_window_seconds(settings) == 120
14
+ settings.set(PERFORMANCE_WINDOW_KEY, "garbage")
15
+ assert load_performance_window_seconds(settings) == 120
16
+ settings.set(PERFORMANCE_WINDOW_KEY, "-5")
17
+ assert load_performance_window_seconds(settings) == 120
18
+
19
+
20
+ def test_history_change_persists_and_applies(qapp, tmp_path):
21
+ connection = db.connect(tmp_path / "prefs.db")
22
+ settings = Settings(connection)
23
+ monitor = PerformanceMonitor()
24
+ tab = PreferencesTab(settings, monitor)
25
+ assert tab.history_minutes.value() == 2 # the 2-minute default
26
+ tab.history_minutes.setValue(5) # fires valueChanged
27
+ assert settings.get(PERFORMANCE_WINDOW_KEY) == "300"
28
+ assert monitor.window_seconds == 300.0
29
+ # A fresh tab (new window / restart) reads the persisted value.
30
+ tab2 = PreferencesTab(settings, PerformanceMonitor())
31
+ assert tab2.history_minutes.value() == 5
@@ -1,7 +1,9 @@
1
1
  import socket
2
+ import sys
2
3
 
4
+ from PySide6.QtCore import QProcess
3
5
  from PySide6.QtNetwork import QHostAddress, QTcpServer
4
- from PySide6.QtWidgets import QMessageBox
6
+ from PySide6.QtWidgets import QApplication, QMessageBox
5
7
 
6
8
  from remotedesktop import db
7
9
  from remotedesktop.autostart import Autostart
@@ -33,6 +35,10 @@ def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
33
35
  assert window._discoverable
34
36
  assert "Discoverable on this LAN" in window._summary.text()
35
37
  assert "Not sharing" in window._summary.text()
38
+ tabs = window.centralWidget()
39
+ labels = [tabs.tabText(i) for i in range(tabs.count())]
40
+ assert "Performance" in labels and "Preferences" in labels
41
+ assert not window.performance._timer.isActive() # idle: no periodic work
36
42
  finally:
37
43
  window.close()
38
44
 
@@ -85,6 +91,40 @@ def test_autostart_checkbox_toggles_registration(qapp, credentials, tmp_path):
85
91
  window.close()
86
92
 
87
93
 
94
+ def test_restart_declined_keeps_serving(qapp, credentials, tmp_path, monkeypatch):
95
+ monkeypatch.setattr(
96
+ QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)
97
+ )
98
+ launches = []
99
+ monkeypatch.setattr(QProcess, "startDetached", staticmethod(lambda *a: launches.append(a)))
100
+ window = make_window(credentials, tmp_path)
101
+ try:
102
+ window._restart_server()
103
+ assert launches == []
104
+ assert window.share_server._server.isListening()
105
+ assert window.responder is not None
106
+ finally:
107
+ window.close()
108
+
109
+
110
+ def test_restart_frees_ports_and_relaunches(qapp, credentials, tmp_path, monkeypatch):
111
+ monkeypatch.setattr(
112
+ QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
113
+ )
114
+ launches, quits = [], []
115
+ monkeypatch.setattr(
116
+ QProcess, "startDetached", staticmethod(lambda *a: launches.append(a) or True)
117
+ )
118
+ monkeypatch.setattr(QApplication, "quit", staticmethod(lambda: quits.append(True)))
119
+ window = make_window(credentials, tmp_path)
120
+ window._restart_server()
121
+ # Ports are freed before the new process is spawned, so it can bind them.
122
+ assert window.responder is None
123
+ assert not window.share_server._server.isListening()
124
+ assert launches == [(sys.executable, ["-m", "remotedesktop.server"])]
125
+ assert quits == [True]
126
+
127
+
88
128
  def stub_approval_prompt(monkeypatch, button):
89
129
  """Answer _ask_approval's stay-on-top QMessageBox without showing any UI.
90
130
 
File without changes
File without changes
File without changes