remotedesktop 0.14.0__tar.gz → 0.16.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/CLAUDE.md +1 -1
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/PKG-INFO +1 -1
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/performance.py +138 -19
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/server.py +94 -3
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/sharing.py +50 -1
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_performance.py +119 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_server_window.py +71 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/.gitignore +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/LICENSE +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/pyproject.toml +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/readme.md +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/run_claude.bat +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/run_client.bat +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/run_server.bat +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/conftest.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_client_window.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_config.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_input.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_window_state.py +0 -0
- {remotedesktop-0.14.0 → remotedesktop-0.16.0}/uv.lock +0 -0
|
@@ -51,7 +51,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
51
51
|
- **Log exchange**: either side can fetch the peer's debug log over the connection (`{"type": "log_request"}` → `{"type": "log", "text": ...}`, admitted streams only). `ShareServer`/`ShareClient` take a `log_provider=` callable (the windows pass `logs.read_log_tail("server"/"client")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. Both windows have a "Get client log"/"Get server log" button in the Connection log tab; the reply opens a `logs.PeerLogDialog`. The server requests from the most recently admitted stream.
|
|
52
52
|
- **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.
|
|
53
53
|
- **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.
|
|
54
|
-
- **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).
|
|
54
|
+
- **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 **every** attached stream (each ping id maps to its stream; a pong is honored only on the stream it was sent to). The graphs' RTT series follow only the *active* (most recently admitted) stream; per-stream send/recv/RTT snapshots come from `metrics_for(stream)` and feed the server's Status-tab `ViewersTable` (`server.py`), which joins them with `ShareServer.viewers()` — identity fields (`user`/`host`/`os`) that the client hello reports (`_client_details()`; missing on old clients, shown as dashes). The ping is: `{"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).
|
|
55
55
|
- **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.
|
|
56
56
|
- 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.
|
|
57
57
|
- 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.
|
|
3
|
+
Version: 0.16.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
|
|
@@ -93,6 +93,35 @@ class MetricSeries:
|
|
|
93
93
|
while self._samples and self._samples[0][0] < cutoff:
|
|
94
94
|
self._samples.popleft()
|
|
95
95
|
|
|
96
|
+
def statistics(self) -> dict | None:
|
|
97
|
+
"""`sample_statistics` over the samples still in the window."""
|
|
98
|
+
return sample_statistics([value for _t, value in self.samples()])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sample_statistics(values: list[float]) -> dict | None:
|
|
102
|
+
"""Summary statistics for a sample list, or None when it is empty:
|
|
103
|
+
{"count", "mean", "min", "max", "p99", "jitter"}. Jitter is the mean
|
|
104
|
+
absolute difference of *consecutive* samples (the classic packet-jitter
|
|
105
|
+
definition), so a steady 100 ms link has zero jitter while one bouncing
|
|
106
|
+
1↔100 ms scores high; p99 is nearest-rank."""
|
|
107
|
+
if not values:
|
|
108
|
+
return None
|
|
109
|
+
ordered = sorted(values)
|
|
110
|
+
count = len(values)
|
|
111
|
+
jitter = (
|
|
112
|
+
sum(abs(b - a) for a, b in zip(values, values[1:])) / (count - 1)
|
|
113
|
+
if count > 1
|
|
114
|
+
else 0.0
|
|
115
|
+
)
|
|
116
|
+
return {
|
|
117
|
+
"count": count,
|
|
118
|
+
"mean": sum(values) / count,
|
|
119
|
+
"min": ordered[0],
|
|
120
|
+
"max": ordered[-1],
|
|
121
|
+
"p99": ordered[max(0, math.ceil(0.99 * count) - 1)],
|
|
122
|
+
"jitter": jitter,
|
|
123
|
+
}
|
|
124
|
+
|
|
96
125
|
|
|
97
126
|
class PerformanceMonitor(QObject):
|
|
98
127
|
"""Samples bandwidth and measures RTT for the attached streams.
|
|
@@ -107,6 +136,10 @@ class PerformanceMonitor(QObject):
|
|
|
107
136
|
updated = Signal() # one emit per sample tick
|
|
108
137
|
connectionLost = Signal(object) # the active stream whose responsive peer went silent
|
|
109
138
|
|
|
139
|
+
# Graphs aggregate bandwidth across streams and plot the *active* (most
|
|
140
|
+
# recently attached) stream's RTT as a single coherent line; per-stream
|
|
141
|
+
# numbers for all attached streams are available via metrics_for().
|
|
142
|
+
|
|
110
143
|
def __init__(
|
|
111
144
|
self,
|
|
112
145
|
*,
|
|
@@ -126,6 +159,14 @@ class PerformanceMonitor(QObject):
|
|
|
126
159
|
self.peer_rtt_ms = MetricSeries(window_seconds, clock=clock)
|
|
127
160
|
self._streams: list[StreamLike] = []
|
|
128
161
|
self._baselines: dict[StreamLike, tuple[int, int]] = {}
|
|
162
|
+
# Per-stream numbers (for the server's viewers table); the rolling
|
|
163
|
+
# series above stay aggregate/active-stream only. RTT keeps a full
|
|
164
|
+
# window-following series per stream so the table can show window
|
|
165
|
+
# statistics, not just the latest sample.
|
|
166
|
+
self._stream_send_bps: dict[StreamLike, float] = {}
|
|
167
|
+
self._stream_recv_bps: dict[StreamLike, float] = {}
|
|
168
|
+
self._stream_rtt: dict[StreamLike, MetricSeries] = {}
|
|
169
|
+
self._stream_peer_rtt: dict[StreamLike, float] = {}
|
|
129
170
|
self._active: StreamLike | None = None
|
|
130
171
|
# Dead-connection detection, all scoped to the active stream: when its
|
|
131
172
|
# bytes_received counter last grew, whether its peer ever answered a
|
|
@@ -133,7 +174,7 @@ class PerformanceMonitor(QObject):
|
|
|
133
174
|
self._last_data: float | None = None
|
|
134
175
|
self._peer_responsive = False
|
|
135
176
|
self._lost_reported = False
|
|
136
|
-
self._pending: dict[int, float] = {}
|
|
177
|
+
self._pending: dict[int, tuple[StreamLike, float]] = {}
|
|
137
178
|
self._ids = itertools.count(1)
|
|
138
179
|
self._last_tick: float | None = None
|
|
139
180
|
self._tick_count = 0
|
|
@@ -153,6 +194,8 @@ class PerformanceMonitor(QObject):
|
|
|
153
194
|
self._window = window_seconds
|
|
154
195
|
for series in (self.send_bps, self.recv_bps, self.rtt_ms, self.peer_rtt_ms):
|
|
155
196
|
series.set_window(window_seconds)
|
|
197
|
+
for series in self._stream_rtt.values():
|
|
198
|
+
series.set_window(window_seconds)
|
|
156
199
|
|
|
157
200
|
def add_stream(self, stream: StreamLike) -> None:
|
|
158
201
|
if stream not in self._baselines:
|
|
@@ -168,6 +211,15 @@ class PerformanceMonitor(QObject):
|
|
|
168
211
|
return
|
|
169
212
|
self._streams.remove(stream)
|
|
170
213
|
del self._baselines[stream]
|
|
214
|
+
for per_stream in (
|
|
215
|
+
self._stream_send_bps,
|
|
216
|
+
self._stream_recv_bps,
|
|
217
|
+
self._stream_rtt,
|
|
218
|
+
self._stream_peer_rtt,
|
|
219
|
+
):
|
|
220
|
+
per_stream.pop(stream, None)
|
|
221
|
+
for ping_id in [i for i, (s, _t) in self._pending.items() if s is stream]:
|
|
222
|
+
del self._pending[ping_id]
|
|
171
223
|
if self._active is stream:
|
|
172
224
|
self._activate(self._streams[-1] if self._streams else None)
|
|
173
225
|
if not self._streams:
|
|
@@ -175,6 +227,20 @@ class PerformanceMonitor(QObject):
|
|
|
175
227
|
self._pending.clear()
|
|
176
228
|
self._last_tick = None
|
|
177
229
|
|
|
230
|
+
def metrics_for(self, stream: StreamLike) -> dict:
|
|
231
|
+
"""Numbers for one attached stream (None where not yet measured):
|
|
232
|
+
{"send_bps", "recv_bps", "rtt_ms", "peer_rtt_ms", "rtt_stats"} —
|
|
233
|
+
rtt_ms is the latest sample, rtt_stats the `sample_statistics`
|
|
234
|
+
summary over the rolling window."""
|
|
235
|
+
rtt_series = self._stream_rtt.get(stream)
|
|
236
|
+
return {
|
|
237
|
+
"send_bps": self._stream_send_bps.get(stream),
|
|
238
|
+
"recv_bps": self._stream_recv_bps.get(stream),
|
|
239
|
+
"rtt_ms": rtt_series.latest() if rtt_series is not None else None,
|
|
240
|
+
"peer_rtt_ms": self._stream_peer_rtt.get(stream),
|
|
241
|
+
"rtt_stats": rtt_series.statistics() if rtt_series is not None else None,
|
|
242
|
+
}
|
|
243
|
+
|
|
178
244
|
def _activate(self, stream: StreamLike | None) -> None:
|
|
179
245
|
self._active = stream
|
|
180
246
|
self._last_data = self._clock() if stream is not None else None
|
|
@@ -193,6 +259,8 @@ class PerformanceMonitor(QObject):
|
|
|
193
259
|
case "ping":
|
|
194
260
|
stream.send_json({"type": "pong", "id": message.get("id")})
|
|
195
261
|
rtt = message.get("rtt")
|
|
262
|
+
if isinstance(rtt, (int, float)):
|
|
263
|
+
self._stream_peer_rtt[stream] = float(rtt)
|
|
196
264
|
if stream is self._active:
|
|
197
265
|
# A peer that pings us runs this feature too — that arms
|
|
198
266
|
# the silence detector just as well as a pong does.
|
|
@@ -204,35 +272,57 @@ class PerformanceMonitor(QObject):
|
|
|
204
272
|
self._peer_responsive = True
|
|
205
273
|
ping_id = message.get("id")
|
|
206
274
|
if isinstance(ping_id, int):
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
275
|
+
pending = self._pending.pop(ping_id, None)
|
|
276
|
+
# Only honor a pong arriving on the stream it was sent to.
|
|
277
|
+
if pending is not None and pending[0] is stream:
|
|
278
|
+
rtt_ms = (self._clock() - pending[1]) * 1000.0
|
|
279
|
+
series = self._stream_rtt.get(stream)
|
|
280
|
+
if series is None:
|
|
281
|
+
series = self._stream_rtt[stream] = MetricSeries(
|
|
282
|
+
self._window, clock=self._clock
|
|
283
|
+
)
|
|
284
|
+
series.add(rtt_ms)
|
|
285
|
+
if stream is self._active:
|
|
286
|
+
self.rtt_ms.add(rtt_ms)
|
|
210
287
|
|
|
211
288
|
def _on_tick(self) -> None:
|
|
212
289
|
now = self._clock()
|
|
290
|
+
elapsed = now - self._last_tick if self._last_tick is not None else None
|
|
213
291
|
delta_sent = delta_received = 0
|
|
214
292
|
for stream in self._streams:
|
|
215
293
|
base_sent, base_received = self._baselines[stream]
|
|
216
|
-
|
|
217
|
-
|
|
294
|
+
sent = stream.bytes_sent - base_sent
|
|
295
|
+
received = stream.bytes_received - base_received
|
|
296
|
+
delta_sent += sent
|
|
297
|
+
delta_received += received
|
|
298
|
+
if elapsed is not None and elapsed > 0:
|
|
299
|
+
self._stream_send_bps[stream] = sent / elapsed
|
|
300
|
+
self._stream_recv_bps[stream] = received / elapsed
|
|
218
301
|
if stream is self._active and stream.bytes_received > base_received:
|
|
219
302
|
self._last_data = now
|
|
220
303
|
self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
|
|
221
|
-
if
|
|
304
|
+
if elapsed is not None and elapsed > 0:
|
|
222
305
|
self.send_bps.add(delta_sent / elapsed)
|
|
223
306
|
self.recv_bps.add(delta_received / elapsed)
|
|
224
307
|
self._last_tick = now
|
|
225
308
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
309
|
+
for ping_id in [
|
|
310
|
+
i for i, (_s, t) in self._pending.items() if now - t > _PING_PRUNE_SECONDS
|
|
311
|
+
]:
|
|
312
|
+
del self._pending[ping_id]
|
|
313
|
+
# Every stream gets its own ping so the viewers table can show
|
|
314
|
+
# per-viewer RTT; each ping carries our latest measurement for that
|
|
315
|
+
# same link, so both ends see both directions.
|
|
316
|
+
for stream in self._streams:
|
|
229
317
|
ping_id = next(self._ids)
|
|
230
|
-
self._pending[ping_id] = now
|
|
318
|
+
self._pending[ping_id] = (stream, now)
|
|
231
319
|
ping: dict = {"type": "ping", "id": ping_id}
|
|
232
|
-
|
|
320
|
+
series = self._stream_rtt.get(stream)
|
|
321
|
+
if series is not None and (rtt := series.latest()) is not None:
|
|
233
322
|
ping["rtt"] = rtt
|
|
234
|
-
|
|
323
|
+
stream.send_json(ping)
|
|
235
324
|
|
|
325
|
+
if self._active is not None:
|
|
236
326
|
if (
|
|
237
327
|
self._peer_responsive
|
|
238
328
|
and not self._lost_reported
|
|
@@ -311,6 +401,7 @@ class GraphWidget(QWidget):
|
|
|
311
401
|
format_value: Callable[[float], str],
|
|
312
402
|
*,
|
|
313
403
|
tick_unit: Callable[[float], float] = lambda _value: 1.0,
|
|
404
|
+
show_stats: bool = False,
|
|
314
405
|
clock: Callable[[], float] = time.monotonic,
|
|
315
406
|
parent: QWidget | None = None,
|
|
316
407
|
) -> None:
|
|
@@ -323,8 +414,12 @@ class GraphWidget(QWidget):
|
|
|
323
414
|
# the axis ceiling is a round number of that unit (100 KB/s, not the
|
|
324
415
|
# 97.7 KB/s that a round number of bytes/s would render as).
|
|
325
416
|
self._tick_unit = tick_unit
|
|
417
|
+
# One extra bottom row per series with window statistics
|
|
418
|
+
# (mean/min/max/p99/jitter) — used by the round-trip-time graph.
|
|
419
|
+
self._show_stats = show_stats
|
|
326
420
|
self._clock = clock
|
|
327
|
-
|
|
421
|
+
extra_rows = len(series) if show_stats else 0
|
|
422
|
+
self.setMinimumSize(300, 140 + extra_rows * self.fontMetrics().height())
|
|
328
423
|
|
|
329
424
|
def paintEvent(self, event: QPaintEvent) -> None:
|
|
330
425
|
painter = QPainter(self)
|
|
@@ -356,10 +451,16 @@ class GraphWidget(QWidget):
|
|
|
356
451
|
y_ticks = [y_max * i / _GRID_DIVISIONS for i in range(_GRID_DIVISIONS + 1)]
|
|
357
452
|
y_labels = [self._format_value(v) for v in y_ticks]
|
|
358
453
|
left = 8 + max(metrics.horizontalAdvance(t) for t in y_labels) + 6
|
|
359
|
-
|
|
454
|
+
# Bottom rows: X tick labels, legend, then (optionally) one
|
|
455
|
+
# statistics line per series.
|
|
456
|
+
bottom_rows = 2 + (len(self._series) if self._show_stats else 0)
|
|
457
|
+
graph = self.rect().adjusted(left, 24, -12, -(bottom_rows * metrics.height() + 10))
|
|
360
458
|
|
|
459
|
+
# Solid but translucent: dotted lines all but vanished on high-DPI
|
|
460
|
+
# displays, where the dots land between device pixels.
|
|
361
461
|
grid_color = palette.color(palette.ColorRole.Mid)
|
|
362
|
-
|
|
462
|
+
grid_color.setAlpha(150)
|
|
463
|
+
grid_pen = QPen(grid_color, 0)
|
|
363
464
|
text_color = palette.color(palette.ColorRole.Text)
|
|
364
465
|
for value, label in zip(y_ticks, y_labels):
|
|
365
466
|
y = graph.bottom() - graph.height() * (value / y_max)
|
|
@@ -383,10 +484,11 @@ class GraphWidget(QWidget):
|
|
|
383
484
|
text_x = min(max(x - width / 2, graph.left()), graph.right() - width)
|
|
384
485
|
painter.setPen(text_color)
|
|
385
486
|
painter.drawText(round(text_x), x_label_baseline, label)
|
|
487
|
+
legend_baseline = x_label_baseline + metrics.height()
|
|
386
488
|
caption = "seconds ago"
|
|
387
489
|
painter.drawText(
|
|
388
490
|
self.rect().right() - 8 - metrics.horizontalAdvance(caption),
|
|
389
|
-
|
|
491
|
+
legend_baseline,
|
|
390
492
|
caption,
|
|
391
493
|
)
|
|
392
494
|
|
|
@@ -401,14 +503,30 @@ class GraphWidget(QWidget):
|
|
|
401
503
|
painter.setPen(color)
|
|
402
504
|
painter.drawPolyline(polyline)
|
|
403
505
|
|
|
404
|
-
# Legend with latest values
|
|
506
|
+
# Legend with latest values, then one statistics row per series.
|
|
405
507
|
x = graph.left()
|
|
406
508
|
for label, color, samples in data:
|
|
407
509
|
latest = samples[-1][1] if samples else None
|
|
408
510
|
text = f"{label} {self._format_value(latest)}" if latest is not None else f"{label} —"
|
|
409
511
|
painter.setPen(color)
|
|
410
|
-
painter.drawText(round(x),
|
|
512
|
+
painter.drawText(round(x), legend_baseline, text)
|
|
411
513
|
x += metrics.horizontalAdvance(text) + 16
|
|
514
|
+
if self._show_stats:
|
|
515
|
+
baseline = legend_baseline
|
|
516
|
+
fmt = self._format_value
|
|
517
|
+
for label, color, samples in data:
|
|
518
|
+
baseline += metrics.height()
|
|
519
|
+
stats = sample_statistics([value for _t, value in samples])
|
|
520
|
+
if stats is None:
|
|
521
|
+
text = f"{label}: no data in window"
|
|
522
|
+
else:
|
|
523
|
+
text = (
|
|
524
|
+
f"{label}: mean {fmt(stats['mean'])} · min {fmt(stats['min'])}"
|
|
525
|
+
f" · max {fmt(stats['max'])} · p99 {fmt(stats['p99'])}"
|
|
526
|
+
f" · jitter {fmt(stats['jitter'])} · n={stats['count']}"
|
|
527
|
+
)
|
|
528
|
+
painter.setPen(color)
|
|
529
|
+
painter.drawText(graph.left(), baseline, text)
|
|
412
530
|
|
|
413
531
|
|
|
414
532
|
class PerformanceTab(QWidget):
|
|
@@ -450,6 +568,7 @@ class PerformanceTab(QWidget):
|
|
|
450
568
|
],
|
|
451
569
|
monitor,
|
|
452
570
|
format_ms,
|
|
571
|
+
show_stats=True,
|
|
453
572
|
)
|
|
454
573
|
layout = QVBoxLayout(self)
|
|
455
574
|
layout.addWidget(self.bandwidth_graph, stretch=1)
|
|
@@ -8,15 +8,19 @@ import sys
|
|
|
8
8
|
import time
|
|
9
9
|
|
|
10
10
|
from PySide6.QtCore import QProcess, Qt
|
|
11
|
-
from PySide6.QtGui import QCloseEvent
|
|
11
|
+
from PySide6.QtGui import QCloseEvent, QShowEvent
|
|
12
12
|
from PySide6.QtWidgets import (
|
|
13
|
+
QAbstractItemView,
|
|
13
14
|
QApplication,
|
|
14
15
|
QCheckBox,
|
|
16
|
+
QHeaderView,
|
|
15
17
|
QLabel,
|
|
16
18
|
QMainWindow,
|
|
17
19
|
QMessageBox,
|
|
18
20
|
QPlainTextEdit,
|
|
19
21
|
QPushButton,
|
|
22
|
+
QTableWidget,
|
|
23
|
+
QTableWidgetItem,
|
|
20
24
|
QTabWidget,
|
|
21
25
|
QVBoxLayout,
|
|
22
26
|
QWidget,
|
|
@@ -34,13 +38,97 @@ from remotedesktop.discovery import (
|
|
|
34
38
|
from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
35
39
|
from remotedesktop.logs import PeerLogDialog, read_log_tail
|
|
36
40
|
from remotedesktop.modal_loop import ModalLoopPump
|
|
37
|
-
from remotedesktop.performance import PerformanceMonitor, PerformanceTab
|
|
41
|
+
from remotedesktop.performance import PerformanceMonitor, PerformanceTab, format_ms, format_rate
|
|
38
42
|
from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
|
|
39
43
|
from remotedesktop.sharing import ShareServer
|
|
40
44
|
|
|
41
45
|
_log = logging.getLogger("remotedesktop.server")
|
|
42
46
|
|
|
43
47
|
|
|
48
|
+
class ViewersTable(QTableWidget):
|
|
49
|
+
"""Connected viewers with who/what they are and live per-viewer metrics.
|
|
50
|
+
|
|
51
|
+
Identity fields come from each viewer's hello (`ShareServer.viewers()`);
|
|
52
|
+
Send/Receive/Round trip come from the performance monitor's per-stream
|
|
53
|
+
numbers. Rows refresh when the viewer count changes and once per monitor
|
|
54
|
+
tick — but only while visible (background tabs schedule no work).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
_COLUMNS = [
|
|
58
|
+
"Name", "Address", "User", "Computer", "OS", "Send", "Receive",
|
|
59
|
+
"RTT", "RTT mean", "RTT min", "RTT max", "RTT p99", "RTT jitter",
|
|
60
|
+
]
|
|
61
|
+
# Metric cells change text every tick, so their columns get a constant
|
|
62
|
+
# width (sized to the widest plausible value) instead of
|
|
63
|
+
# ResizeToContents — otherwise the columns visibly jitter each second.
|
|
64
|
+
_RATE_COLUMNS = (5, 6) # Send / Receive
|
|
65
|
+
_MS_COLUMNS = (7, 8, 9, 10, 11, 12) # RTT latest + window statistics
|
|
66
|
+
_METRIC_COLUMNS = _RATE_COLUMNS + _MS_COLUMNS
|
|
67
|
+
|
|
68
|
+
def __init__(self, share_server, performance: PerformanceMonitor, parent=None) -> None:
|
|
69
|
+
# One extra headerless column takes the stretch so the data columns
|
|
70
|
+
# stay sized to their contents (the InventoryTab spacer pattern).
|
|
71
|
+
super().__init__(0, len(self._COLUMNS) + 1, parent)
|
|
72
|
+
self.setHorizontalHeaderLabels(self._COLUMNS + [""])
|
|
73
|
+
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
74
|
+
self.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
|
75
|
+
self.verticalHeader().setVisible(False)
|
|
76
|
+
header = self.horizontalHeader()
|
|
77
|
+
header.setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
|
78
|
+
header.setStretchLastSection(True)
|
|
79
|
+
rate_width = self.fontMetrics().horizontalAdvance("9999.9 MB/s") + 24
|
|
80
|
+
ms_width = self.fontMetrics().horizontalAdvance("9999.9 ms") + 24
|
|
81
|
+
for column in self._METRIC_COLUMNS:
|
|
82
|
+
header.setSectionResizeMode(column, QHeaderView.ResizeMode.Fixed)
|
|
83
|
+
self.setColumnWidth(column, rate_width if column in self._RATE_COLUMNS else ms_width)
|
|
84
|
+
self._share_server = share_server
|
|
85
|
+
self._performance = performance
|
|
86
|
+
share_server.clientCountChanged.connect(self._on_count_changed)
|
|
87
|
+
performance.updated.connect(self._on_monitor_tick)
|
|
88
|
+
self.refresh()
|
|
89
|
+
|
|
90
|
+
def _on_count_changed(self, _count: int) -> None:
|
|
91
|
+
self.refresh()
|
|
92
|
+
|
|
93
|
+
def _on_monitor_tick(self) -> None:
|
|
94
|
+
if self.isVisible():
|
|
95
|
+
self.refresh()
|
|
96
|
+
|
|
97
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
98
|
+
super().showEvent(event)
|
|
99
|
+
self.refresh() # catch up on metrics accrued while hidden
|
|
100
|
+
|
|
101
|
+
def refresh(self) -> None:
|
|
102
|
+
viewers = self._share_server.viewers()
|
|
103
|
+
self.setRowCount(len(viewers))
|
|
104
|
+
for row, viewer in enumerate(viewers):
|
|
105
|
+
metrics = self._performance.metrics_for(viewer["stream"])
|
|
106
|
+
send, recv, rtt = metrics["send_bps"], metrics["recv_bps"], metrics["rtt_ms"]
|
|
107
|
+
stats = metrics["rtt_stats"] or {}
|
|
108
|
+
values = [
|
|
109
|
+
viewer["name"] or "(unknown)",
|
|
110
|
+
viewer["address"],
|
|
111
|
+
viewer["user"] or "—",
|
|
112
|
+
viewer["host"] or "—",
|
|
113
|
+
viewer["os"] or "—",
|
|
114
|
+
format_rate(send) if send is not None else "—",
|
|
115
|
+
format_rate(recv) if recv is not None else "—",
|
|
116
|
+
format_ms(rtt) if rtt is not None else "—",
|
|
117
|
+
] + [
|
|
118
|
+
format_ms(stats[key]) if key in stats else "—"
|
|
119
|
+
for key in ("mean", "min", "max", "p99", "jitter")
|
|
120
|
+
]
|
|
121
|
+
for column, value in enumerate(values):
|
|
122
|
+
item = QTableWidgetItem(value)
|
|
123
|
+
if column in self._METRIC_COLUMNS:
|
|
124
|
+
# Right-aligned numbers change magnitude without the
|
|
125
|
+
# digits appearing to wander.
|
|
126
|
+
item.setTextAlignment(
|
|
127
|
+
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
|
128
|
+
)
|
|
129
|
+
self.setItem(row, column, item)
|
|
130
|
+
|
|
131
|
+
|
|
44
132
|
class ServerWindow(QMainWindow):
|
|
45
133
|
def __init__(
|
|
46
134
|
self,
|
|
@@ -90,9 +178,10 @@ class ServerWindow(QMainWindow):
|
|
|
90
178
|
status_tab = QWidget()
|
|
91
179
|
status_layout = QVBoxLayout(status_tab)
|
|
92
180
|
status_layout.addWidget(self._summary)
|
|
181
|
+
# The viewers table is inserted here (index 1) once share_server
|
|
182
|
+
# exists, taking the tab's spare space.
|
|
93
183
|
status_layout.addWidget(self.autostart_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
94
184
|
status_layout.addWidget(self.restart_button, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
95
|
-
status_layout.addStretch(1)
|
|
96
185
|
|
|
97
186
|
# Tests inject a connection to a temp database; the app uses the default.
|
|
98
187
|
self._db = connection if connection is not None else db.connect(default_db_path())
|
|
@@ -133,6 +222,8 @@ class ServerWindow(QMainWindow):
|
|
|
133
222
|
log_provider=lambda: read_log_tail("server"),
|
|
134
223
|
parent=self,
|
|
135
224
|
)
|
|
225
|
+
self.viewers_table = ViewersTable(self.share_server, self.performance)
|
|
226
|
+
status_layout.insertWidget(1, self.viewers_table, stretch=1)
|
|
136
227
|
self.share_server.status.connect(self.log)
|
|
137
228
|
self.share_server.clientCountChanged.connect(self._update_summary)
|
|
138
229
|
self.share_server.peerEvent.connect(self._record_peer)
|
|
@@ -13,8 +13,10 @@ Both classes emit human-readable `status` messages for every phase so the
|
|
|
13
13
|
GUIs can show a debug log.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
+
import getpass
|
|
16
17
|
import hmac
|
|
17
18
|
import logging
|
|
19
|
+
import platform
|
|
18
20
|
import socket as socket_module
|
|
19
21
|
import time
|
|
20
22
|
from collections.abc import Callable
|
|
@@ -66,7 +68,26 @@ _PREAUTH_MAX_PAYLOAD = 64 * 1024
|
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def _peer(sock: QTcpSocket) -> str:
|
|
69
|
-
|
|
71
|
+
host = sock.peerAddress().toString()
|
|
72
|
+
# The server listens dual-stack, so IPv4 peers arrive as IPv4-mapped
|
|
73
|
+
# IPv6 addresses ("::ffff:192.168.6.29") — show them as plain IPv4.
|
|
74
|
+
if host.startswith("::ffff:"):
|
|
75
|
+
host = host[len("::ffff:") :]
|
|
76
|
+
return f"{host}:{sock.peerPort()}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _client_details() -> dict:
|
|
80
|
+
"""Who and what this client machine is, shown in the server's viewers
|
|
81
|
+
table. Best-effort: a missing login name just yields an empty field."""
|
|
82
|
+
try:
|
|
83
|
+
user = getpass.getuser()
|
|
84
|
+
except OSError:
|
|
85
|
+
user = ""
|
|
86
|
+
return {
|
|
87
|
+
"user": user,
|
|
88
|
+
"host": socket_module.gethostname(),
|
|
89
|
+
"os": f"{platform.system()} {platform.release()} ({platform.version()})",
|
|
90
|
+
}
|
|
70
91
|
|
|
71
92
|
|
|
72
93
|
def _coord(value) -> float:
|
|
@@ -129,6 +150,9 @@ class ShareServer(QObject):
|
|
|
129
150
|
# backlog, or asked for one), and the previous capture to diff.
|
|
130
151
|
self._delta_capable: set[MessageStream] = set()
|
|
131
152
|
self._needs_keyframe: set[MessageStream] = set()
|
|
153
|
+
# What each stream's hello said about the machine behind it
|
|
154
|
+
# (name/user/host/os), for the server UI's viewers table.
|
|
155
|
+
self._viewer_info: dict[MessageStream, dict] = {}
|
|
132
156
|
self._previous_frame: QImage | None = None
|
|
133
157
|
# DXGI desktop duplication (~10 ms per changed 4K frame, ~0 when the
|
|
134
158
|
# screen is idle, vs ~96 ms for grabWindow). Created lazily on the
|
|
@@ -184,6 +208,7 @@ class ShareServer(QObject):
|
|
|
184
208
|
self._backlogged.clear()
|
|
185
209
|
self._delta_capable.clear()
|
|
186
210
|
self._needs_keyframe.clear()
|
|
211
|
+
self._viewer_info.clear()
|
|
187
212
|
self._previous_frame = None
|
|
188
213
|
if self._dxgi is not None:
|
|
189
214
|
self._dxgi.close()
|
|
@@ -264,6 +289,21 @@ class ShareServer(QObject):
|
|
|
264
289
|
return
|
|
265
290
|
self._handle_hello(stream, message)
|
|
266
291
|
|
|
292
|
+
def viewers(self) -> list[dict]:
|
|
293
|
+
"""One entry per admitted stream for the server UI's viewers table:
|
|
294
|
+
{name, address, user, host, os, stream} — the stream is the key for
|
|
295
|
+
PerformanceMonitor.metrics_for."""
|
|
296
|
+
result = []
|
|
297
|
+
for stream in self._streams:
|
|
298
|
+
info = dict(self._viewer_info.get(stream, {}))
|
|
299
|
+
info.setdefault("name", "")
|
|
300
|
+
info.setdefault("address", _peer(stream.socket))
|
|
301
|
+
for field in ("user", "host", "os"):
|
|
302
|
+
info.setdefault(field, "")
|
|
303
|
+
info["stream"] = stream
|
|
304
|
+
result.append(info)
|
|
305
|
+
return result
|
|
306
|
+
|
|
267
307
|
def revoke_client(self, client_id: str) -> None:
|
|
268
308
|
"""Remove a client's pairing and disconnect it if currently connected.
|
|
269
309
|
|
|
@@ -305,6 +345,13 @@ class ShareServer(QObject):
|
|
|
305
345
|
self.status.emit(f'Hello from "{client_name}" ({client_id}) at {peer}')
|
|
306
346
|
if message.get("delta"):
|
|
307
347
|
self._delta_capable.add(stream)
|
|
348
|
+
self._viewer_info[stream] = {
|
|
349
|
+
"name": client_name,
|
|
350
|
+
"address": peer,
|
|
351
|
+
"user": str(message.get("user", "")),
|
|
352
|
+
"host": str(message.get("host", "")),
|
|
353
|
+
"os": str(message.get("os", "")),
|
|
354
|
+
}
|
|
308
355
|
if isinstance(client_id, str) and client_id:
|
|
309
356
|
self._stream_key[stream] = (client_id, client_name, peer)
|
|
310
357
|
self._emit_peer(stream, "attempt")
|
|
@@ -471,6 +518,7 @@ class ShareServer(QObject):
|
|
|
471
518
|
self._backlogged.discard(stream)
|
|
472
519
|
self._delta_capable.discard(stream)
|
|
473
520
|
self._needs_keyframe.discard(stream)
|
|
521
|
+
self._viewer_info.pop(stream, None)
|
|
474
522
|
self._release_input(stream)
|
|
475
523
|
final = stream in self._final
|
|
476
524
|
self._final.discard(stream)
|
|
@@ -714,6 +762,7 @@ class ShareClient(QObject):
|
|
|
714
762
|
"client_id": self._client_id,
|
|
715
763
|
"name": self._name,
|
|
716
764
|
"delta": True, # we understand inter-frame delta messages
|
|
765
|
+
**_client_details(),
|
|
717
766
|
}
|
|
718
767
|
if self._server_token:
|
|
719
768
|
hello["token"] = self._server_token
|
|
@@ -54,6 +54,44 @@ def test_metric_series_trims_to_window():
|
|
|
54
54
|
assert series.samples() == []
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
def test_sample_statistics_summarize_the_window():
|
|
58
|
+
from remotedesktop.performance import sample_statistics
|
|
59
|
+
|
|
60
|
+
assert sample_statistics([]) is None
|
|
61
|
+
single = sample_statistics([5.0])
|
|
62
|
+
assert single == {
|
|
63
|
+
"count": 1, "mean": 5.0, "min": 5.0, "max": 5.0, "p99": 5.0, "jitter": 0.0,
|
|
64
|
+
}
|
|
65
|
+
stats = sample_statistics([2.0, 4.0, 2.0, 12.0])
|
|
66
|
+
assert stats is not None
|
|
67
|
+
assert stats["count"] == 4
|
|
68
|
+
assert stats["mean"] == 5.0
|
|
69
|
+
assert stats["min"] == 2.0 and stats["max"] == 12.0
|
|
70
|
+
assert stats["p99"] == 12.0 # nearest-rank on a small sample = max
|
|
71
|
+
# Jitter: mean |consecutive delta| = (2 + 2 + 10) / 3
|
|
72
|
+
assert stats["jitter"] == pytest.approx(14.0 / 3.0)
|
|
73
|
+
# A steady series has zero jitter no matter its level.
|
|
74
|
+
steady = sample_statistics([100.0] * 50)
|
|
75
|
+
assert steady is not None and steady["jitter"] == 0.0
|
|
76
|
+
# p99 excludes a single outlier once there are >= 100 samples.
|
|
77
|
+
many = sample_statistics([10.0] * 99 + [500.0])
|
|
78
|
+
assert many is not None and many["p99"] == 10.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_metric_series_statistics_follow_the_window():
|
|
82
|
+
clock = FakeClock()
|
|
83
|
+
series = MetricSeries(10.0, clock=clock)
|
|
84
|
+
assert series.statistics() is None
|
|
85
|
+
series.add(100.0) # will age out of the window
|
|
86
|
+
clock.advance(8.0)
|
|
87
|
+
for value in (2.0, 4.0):
|
|
88
|
+
series.add(value)
|
|
89
|
+
clock.advance(3.0) # first sample is now 11 s old
|
|
90
|
+
stats = series.statistics()
|
|
91
|
+
assert stats is not None
|
|
92
|
+
assert stats["count"] == 2 and stats["max"] == 4.0 # 100.0 aged out
|
|
93
|
+
|
|
94
|
+
|
|
57
95
|
def test_monitor_samples_aggregate_bandwidth(qapp):
|
|
58
96
|
clock = FakeClock()
|
|
59
97
|
monitor = PerformanceMonitor(window_seconds=60, clock=clock)
|
|
@@ -108,6 +146,61 @@ def test_ping_pong_records_rtt_and_replies(qapp):
|
|
|
108
146
|
assert monitor.peer_rtt_ms.latest() == 12.5
|
|
109
147
|
|
|
110
148
|
|
|
149
|
+
def test_per_stream_metrics_track_each_viewer(qapp):
|
|
150
|
+
clock = FakeClock()
|
|
151
|
+
monitor = PerformanceMonitor(clock=clock)
|
|
152
|
+
first, second = FakeStream(), FakeStream()
|
|
153
|
+
monitor.add_stream(first)
|
|
154
|
+
monitor.add_stream(second)
|
|
155
|
+
monitor._on_tick() # baselines; every attached stream gets its own ping
|
|
156
|
+
assert [m["type"] for m in first.sent] == ["ping"]
|
|
157
|
+
assert [m["type"] for m in second.sent] == ["ping"]
|
|
158
|
+
first.bytes_sent += 1000
|
|
159
|
+
second.bytes_sent += 500
|
|
160
|
+
clock.advance(2.0)
|
|
161
|
+
monitor._on_tick()
|
|
162
|
+
assert monitor.metrics_for(first)["send_bps"] == 500.0
|
|
163
|
+
assert monitor.metrics_for(second)["send_bps"] == 250.0
|
|
164
|
+
# Each stream's pong records that stream's RTT; the graph series only
|
|
165
|
+
# follows the active (most recently attached) stream.
|
|
166
|
+
clock.advance(0.05)
|
|
167
|
+
monitor.handle_message(first, {"type": "pong", "id": first.sent[-1]["id"]})
|
|
168
|
+
assert monitor.metrics_for(first)["rtt_ms"] == pytest.approx(50.0)
|
|
169
|
+
assert monitor.rtt_ms.latest() is None # first is not the active stream
|
|
170
|
+
# An incoming ping's piggybacked measurement lands on its own stream.
|
|
171
|
+
monitor.handle_message(first, {"type": "ping", "id": 99, "rtt": 12.5})
|
|
172
|
+
assert monitor.metrics_for(first)["peer_rtt_ms"] == 12.5
|
|
173
|
+
assert monitor.peer_rtt_ms.latest() is None
|
|
174
|
+
# A pong answered on the wrong stream is ignored.
|
|
175
|
+
monitor.handle_message(first, {"type": "pong", "id": second.sent[-1]["id"]})
|
|
176
|
+
assert monitor.metrics_for(second)["rtt_ms"] is None
|
|
177
|
+
# Removing a stream clears its numbers.
|
|
178
|
+
monitor.remove_stream(first)
|
|
179
|
+
assert monitor.metrics_for(first) == {
|
|
180
|
+
"send_bps": None, "recv_bps": None, "rtt_ms": None, "peer_rtt_ms": None,
|
|
181
|
+
"rtt_stats": None,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_per_stream_rtt_statistics_cover_the_window(qapp):
|
|
186
|
+
clock = FakeClock()
|
|
187
|
+
monitor = PerformanceMonitor(clock=clock)
|
|
188
|
+
stream = FakeStream()
|
|
189
|
+
monitor.add_stream(stream)
|
|
190
|
+
for rtt_s in (0.010, 0.020, 0.090):
|
|
191
|
+
monitor._on_tick()
|
|
192
|
+
clock.advance(rtt_s)
|
|
193
|
+
monitor.handle_message(stream, {"type": "pong", "id": stream.sent[-1]["id"]})
|
|
194
|
+
clock.advance(1.0)
|
|
195
|
+
stats = monitor.metrics_for(stream)["rtt_stats"]
|
|
196
|
+
assert stats is not None
|
|
197
|
+
assert stats["count"] == 3
|
|
198
|
+
assert stats["min"] == pytest.approx(10.0)
|
|
199
|
+
assert stats["max"] == pytest.approx(90.0)
|
|
200
|
+
assert stats["mean"] == pytest.approx(40.0)
|
|
201
|
+
assert monitor.metrics_for(stream)["rtt_ms"] == pytest.approx(90.0)
|
|
202
|
+
|
|
203
|
+
|
|
111
204
|
def test_stale_or_malformed_pongs_are_ignored(qapp):
|
|
112
205
|
monitor = PerformanceMonitor(clock=FakeClock())
|
|
113
206
|
stream = FakeStream()
|
|
@@ -173,6 +266,32 @@ def make_pair(credentials, tmp_path, *, server_perf, client_perf):
|
|
|
173
266
|
return server, client
|
|
174
267
|
|
|
175
268
|
|
|
269
|
+
def test_server_viewers_snapshot_carries_hello_details(qapp, credentials, tmp_path):
|
|
270
|
+
server_perf = PerformanceMonitor(interval_ms=50)
|
|
271
|
+
server, client = make_pair(
|
|
272
|
+
credentials,
|
|
273
|
+
tmp_path,
|
|
274
|
+
server_perf=server_perf,
|
|
275
|
+
client_perf=PerformanceMonitor(interval_ms=50), # answers the pings
|
|
276
|
+
)
|
|
277
|
+
connected = []
|
|
278
|
+
client.connected.connect(connected.append)
|
|
279
|
+
client.connect_to("127.0.0.1", server.port)
|
|
280
|
+
try:
|
|
281
|
+
pump(qapp, lambda: connected)
|
|
282
|
+
viewers = server.viewers()
|
|
283
|
+
assert len(viewers) == 1
|
|
284
|
+
viewer = viewers[0]
|
|
285
|
+
assert viewer["name"] and viewer["user"] and viewer["host"]
|
|
286
|
+
assert viewer["os"].startswith("Windows")
|
|
287
|
+
assert "127.0.0.1" in viewer["address"]
|
|
288
|
+
# The stream key links the snapshot to per-viewer metrics.
|
|
289
|
+
pump(qapp, lambda: server_perf.metrics_for(viewer["stream"])["rtt_ms"] is not None)
|
|
290
|
+
finally:
|
|
291
|
+
client.close()
|
|
292
|
+
server.close()
|
|
293
|
+
|
|
294
|
+
|
|
176
295
|
def test_rtt_and_bandwidth_measured_on_both_ends(qapp, credentials, tmp_path):
|
|
177
296
|
server_perf = PerformanceMonitor(interval_ms=50)
|
|
178
297
|
client_perf = PerformanceMonitor(interval_ms=50)
|
|
@@ -201,6 +201,77 @@ def test_approval_prompt_pairs_client_and_updates_summary(qapp, credentials, tmp
|
|
|
201
201
|
window.close()
|
|
202
202
|
|
|
203
203
|
|
|
204
|
+
def test_viewers_table_lists_connected_client_details(qapp, credentials, tmp_path, monkeypatch):
|
|
205
|
+
stub_approval_prompt(monkeypatch, QMessageBox.StandardButton.Yes)
|
|
206
|
+
window = make_window(credentials, tmp_path)
|
|
207
|
+
client = make_client(tmp_path)
|
|
208
|
+
names = []
|
|
209
|
+
client.connected.connect(names.append)
|
|
210
|
+
assert window.viewers_table.rowCount() == 0
|
|
211
|
+
client.connect_to("127.0.0.1", window.share_server.port)
|
|
212
|
+
try:
|
|
213
|
+
pump(qapp, lambda: names)
|
|
214
|
+
pump(qapp, lambda: window.viewers_table.rowCount() == 1)
|
|
215
|
+
row = []
|
|
216
|
+
for column in range(len(window.viewers_table._COLUMNS)):
|
|
217
|
+
item = window.viewers_table.item(0, column)
|
|
218
|
+
assert item is not None
|
|
219
|
+
row.append(item.text())
|
|
220
|
+
name, address, user, host, os_info, send, recv, rtt = row[:8]
|
|
221
|
+
# The hello carried this machine's real login/host/OS details.
|
|
222
|
+
assert name and user != "—" and host != "—"
|
|
223
|
+
assert "127.0.0.1" in address and "::ffff:" not in address
|
|
224
|
+
assert os_info.startswith("Windows")
|
|
225
|
+
# No monitor tick has necessarily run yet; metrics are dashes or values.
|
|
226
|
+
assert send and recv and rtt
|
|
227
|
+
client.close()
|
|
228
|
+
pump(qapp, lambda: window.viewers_table.rowCount() == 0)
|
|
229
|
+
finally:
|
|
230
|
+
client.close()
|
|
231
|
+
window.close()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_viewers_table_metric_columns_keep_constant_width(qapp):
|
|
235
|
+
from PySide6.QtCore import QObject, Signal
|
|
236
|
+
|
|
237
|
+
from remotedesktop.performance import PerformanceMonitor
|
|
238
|
+
from remotedesktop.server import ViewersTable
|
|
239
|
+
from test_performance import FakeStream
|
|
240
|
+
|
|
241
|
+
class FakeShareServer(QObject):
|
|
242
|
+
clientCountChanged = Signal(int)
|
|
243
|
+
|
|
244
|
+
def __init__(self, viewers):
|
|
245
|
+
super().__init__()
|
|
246
|
+
self._viewers = viewers
|
|
247
|
+
|
|
248
|
+
def viewers(self):
|
|
249
|
+
return self._viewers
|
|
250
|
+
|
|
251
|
+
stream = FakeStream()
|
|
252
|
+
monitor = PerformanceMonitor()
|
|
253
|
+
viewer = {"name": "n", "address": "a", "user": "u", "host": "h", "os": "o", "stream": stream}
|
|
254
|
+
table = ViewersTable(FakeShareServer([viewer]), monitor)
|
|
255
|
+
widths = [table.columnWidth(c) for c in ViewersTable._METRIC_COLUMNS]
|
|
256
|
+
assert all(w > 0 for w in widths)
|
|
257
|
+
# Values swinging from B/s to hundreds of MB/s must not move the columns.
|
|
258
|
+
from remotedesktop.performance import MetricSeries
|
|
259
|
+
|
|
260
|
+
rtt_series = MetricSeries(120.0)
|
|
261
|
+
rtt_series.add(2.1)
|
|
262
|
+
monitor._stream_send_bps[stream] = 312.0
|
|
263
|
+
monitor._stream_recv_bps[stream] = 5.0
|
|
264
|
+
monitor._stream_rtt[stream] = rtt_series
|
|
265
|
+
table.refresh()
|
|
266
|
+
assert [table.columnWidth(c) for c in ViewersTable._METRIC_COLUMNS] == widths
|
|
267
|
+
monitor._stream_send_bps[stream] = 250.0 * 1024 * 1024
|
|
268
|
+
rtt_series.add(1234.5)
|
|
269
|
+
table.refresh()
|
|
270
|
+
assert [table.columnWidth(c) for c in ViewersTable._METRIC_COLUMNS] == widths
|
|
271
|
+
send_item = table.item(0, 5)
|
|
272
|
+
assert send_item is not None and send_item.text() == "250.0 MB/s"
|
|
273
|
+
|
|
274
|
+
|
|
204
275
|
def test_refused_approval_denies_client(qapp, credentials, tmp_path, monkeypatch):
|
|
205
276
|
stub_approval_prompt(monkeypatch, QMessageBox.StandardButton.No)
|
|
206
277
|
window = make_window(credentials, tmp_path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|