remotedesktop 0.6.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.6.0 → remotedesktop-0.8.0}/CLAUDE.md +1 -0
  2. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/PKG-INFO +6 -2
  3. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/readme.md +5 -1
  4. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/__init__.py +1 -1
  5. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/client.py +12 -4
  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.6.0 → remotedesktop-0.8.0}/src/remotedesktop/protocol.py +6 -0
  9. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/server.py +54 -3
  10. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/sharing.py +42 -1
  11. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/viewer.py +22 -8
  12. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_client_window.py +4 -0
  13. remotedesktop-0.8.0/tests/test_performance.py +250 -0
  14. remotedesktop-0.8.0/tests/test_preferences.py +31 -0
  15. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_server_window.py +41 -1
  16. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_sharing.py +17 -1
  17. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_smoke.py +16 -0
  18. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/.github/workflows/ci.yml +0 -0
  19. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/.gitignore +0 -0
  20. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/LICENSE +0 -0
  21. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/pyproject.toml +0 -0
  22. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/run_claude.bat +0 -0
  23. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/run_client.bat +0 -0
  24. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/run_server.bat +0 -0
  25. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/scripts/make_coverage_badge.py +0 -0
  26. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/scripts/make_venv.bat +0 -0
  27. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/autostart.py +0 -0
  28. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/clipboard.py +0 -0
  29. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/config.py +0 -0
  30. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/db.py +0 -0
  31. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/discovery.py +0 -0
  32. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/frames.py +0 -0
  33. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/input_injection.py +0 -0
  34. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/inventory.py +0 -0
  35. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/logs.py +0 -0
  36. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/tls.py +0 -0
  37. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/src/remotedesktop/window_state.py +0 -0
  38. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/conftest.py +0 -0
  39. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_autostart.py +0 -0
  40. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_clipboard.py +0 -0
  41. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_config.py +0 -0
  42. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_discovery.py +0 -0
  43. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_frames.py +0 -0
  44. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_input.py +0 -0
  45. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_input_injection.py +0 -0
  46. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_inventory.py +0 -0
  47. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_logs.py +0 -0
  48. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_protocol.py +0 -0
  49. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_tls.py +0 -0
  50. {remotedesktop-0.6.0 → remotedesktop-0.8.0}/tests/test_window_state.py +0 -0
  51. {remotedesktop-0.6.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.6.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.6.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)
@@ -111,9 +119,8 @@ class ClientWindow(QMainWindow):
111
119
  self.connection_log = QPlainTextEdit(self)
112
120
  self.connection_log.setReadOnly(True)
113
121
  self.connection_log.setMaximumBlockCount(1000)
114
- log_dock = QDockWidget("Connection log", self)
115
- log_dock.setWidget(self.connection_log)
116
- self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, log_dock)
122
+ tabs.addTab(self.connection_log, "Connection log")
123
+ tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
117
124
 
118
125
  self.discovery_panel.serverActivated.connect(self._on_server_activated)
119
126
  self.discovery_panel.serversFound.connect(self._record_discovered)
@@ -130,7 +137,6 @@ class ClientWindow(QMainWindow):
130
137
  self._server_key = ""
131
138
  self._frame_count = 0
132
139
  self.statusBar().showMessage("Not connected")
133
- self._settings = Settings(self._db)
134
140
  window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
135
141
  self.log("Client started")
136
142
  # Tests pass auto_scan=False so window tests never broadcast on the LAN.
@@ -182,10 +188,12 @@ class ClientWindow(QMainWindow):
182
188
  self._server_key, "attempt", name=server.name,
183
189
  address=self._server_key, detail=self._server_key,
184
190
  )
191
+ self.performance.reset() # graphs show only the current connection
185
192
  client = ShareClient(
186
193
  identity=self._identity,
187
194
  known_servers=self._known_servers,
188
195
  clipboard=self._clipboard,
196
+ performance=self.performance,
189
197
  parent=self,
190
198
  )
191
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)
67
- status_layout.addWidget(self.connection_log, stretch=1)
77
+ status_layout.addWidget(self.restart_button, alignment=Qt.AlignmentFlag.AlignHCenter)
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,6 +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")
95
+ tabs.addTab(self.connection_log, "Connection log")
96
+ tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
78
97
  self.setCentralWidget(tabs)
79
98
 
80
99
  if credentials is None:
@@ -90,6 +109,7 @@ class ServerWindow(QMainWindow):
90
109
  credentials=credentials,
91
110
  paired=paired,
92
111
  clipboard=self._clipboard,
112
+ performance=self.performance,
93
113
  parent=self,
94
114
  )
95
115
  self.share_server.status.connect(self.log)
@@ -118,7 +138,6 @@ class ServerWindow(QMainWindow):
118
138
  f"(UDP port {discovery_port}, TCP port {self.share_server.port})"
119
139
  )
120
140
  self._update_summary(0)
121
- self._settings = Settings(self._db)
122
141
  window_state.restore_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
123
142
 
124
143
  def log(self, message: str) -> None:
@@ -168,6 +187,38 @@ class ServerWindow(QMainWindow):
168
187
  if answer == QMessageBox.StandardButton.Yes:
169
188
  self.share_server.revoke_client(client_id)
170
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
+
171
222
  def _ask_approval(self, client_id: str, client_name: str) -> bool:
172
223
  box = QMessageBox(
173
224
  QMessageBox.Icon.Question,