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.
Files changed (57) hide show
  1. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/CLAUDE.md +1 -1
  2. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/PKG-INFO +1 -1
  3. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/__init__.py +1 -1
  4. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/performance.py +138 -19
  5. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/server.py +94 -3
  6. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/sharing.py +50 -1
  7. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_performance.py +119 -0
  8. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_server_window.py +71 -0
  9. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/.github/workflows/ci.yml +0 -0
  10. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/.gitignore +0 -0
  11. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/LICENSE +0 -0
  12. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/pyproject.toml +0 -0
  13. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/readme.md +0 -0
  14. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/run_claude.bat +0 -0
  15. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/run_client.bat +0 -0
  16. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/run_server.bat +0 -0
  17. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/scripts/make_coverage_badge.py +0 -0
  18. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/scripts/make_venv.bat +0 -0
  19. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/autostart.py +0 -0
  20. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/client.py +0 -0
  21. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/clipboard.py +0 -0
  22. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/config.py +0 -0
  23. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/db.py +0 -0
  24. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/discovery.py +0 -0
  25. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/dxgi.py +0 -0
  26. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/frames.py +0 -0
  27. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/icon.py +0 -0
  28. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/input_injection.py +0 -0
  29. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/inventory.py +0 -0
  30. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/logs.py +0 -0
  31. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/modal_loop.py +0 -0
  32. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/preferences.py +0 -0
  33. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/protocol.py +0 -0
  34. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/tls.py +0 -0
  35. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/viewer.py +0 -0
  36. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/src/remotedesktop/window_state.py +0 -0
  37. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/conftest.py +0 -0
  38. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_autostart.py +0 -0
  39. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_client_window.py +0 -0
  40. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_clipboard.py +0 -0
  41. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_config.py +0 -0
  42. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_discovery.py +0 -0
  43. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_dxgi.py +0 -0
  44. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_frames.py +0 -0
  45. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_icon.py +0 -0
  46. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_input.py +0 -0
  47. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_input_injection.py +0 -0
  48. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_inventory.py +0 -0
  49. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_logs.py +0 -0
  50. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_modal_loop.py +0 -0
  51. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_preferences.py +0 -0
  52. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_protocol.py +0 -0
  53. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_sharing.py +0 -0
  54. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_smoke.py +0 -0
  55. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_tls.py +0 -0
  56. {remotedesktop-0.14.0 → remotedesktop-0.16.0}/tests/test_window_state.py +0 -0
  57. {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.14.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
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.16.0"
@@ -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
- sent = self._pending.pop(ping_id, None)
208
- if sent is not None:
209
- self.rtt_ms.add((self._clock() - sent) * 1000.0)
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
- delta_sent += stream.bytes_sent - base_sent
217
- delta_received += stream.bytes_received - base_received
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 self._last_tick is not None and (elapsed := now - self._last_tick) > 0:
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
- if self._active is not None:
227
- for ping_id in [i for i, t in self._pending.items() if now - t > _PING_PRUNE_SECONDS]:
228
- del self._pending[ping_id]
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
- if (rtt := self.rtt_ms.latest()) is not None:
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
- self._active.send_json(ping)
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
- self.setMinimumSize(300, 140) # room for the axis-label rows
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
- graph = self.rect().adjusted(left, 24, -12, -(2 * metrics.height() + 12))
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
- grid_pen = QPen(grid_color, 0, Qt.PenStyle.DotLine)
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
- self.rect().bottom() - 6,
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 along the bottom edge.
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), self.rect().bottom() - 6, text)
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
- return f"{sock.peerAddress().toString()}:{sock.peerPort()}"
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