remotedesktop 0.10.0__tar.gz → 0.12.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 (55) hide show
  1. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/PKG-INFO +1 -1
  2. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/__init__.py +1 -1
  3. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/client.py +4 -1
  4. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/performance.py +92 -16
  5. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/server.py +4 -1
  6. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/viewer.py +8 -0
  7. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_input.py +19 -0
  8. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_performance.py +43 -0
  9. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/.github/workflows/ci.yml +0 -0
  10. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/.gitignore +0 -0
  11. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/CLAUDE.md +0 -0
  12. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/LICENSE +0 -0
  13. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/pyproject.toml +0 -0
  14. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/readme.md +0 -0
  15. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/run_claude.bat +0 -0
  16. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/run_client.bat +0 -0
  17. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/run_server.bat +0 -0
  18. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/scripts/make_coverage_badge.py +0 -0
  19. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/scripts/make_venv.bat +0 -0
  20. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/autostart.py +0 -0
  21. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/clipboard.py +0 -0
  22. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/config.py +0 -0
  23. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/db.py +0 -0
  24. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/discovery.py +0 -0
  25. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/frames.py +0 -0
  26. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/icon.py +0 -0
  27. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/input_injection.py +0 -0
  28. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/inventory.py +0 -0
  29. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/logs.py +0 -0
  30. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/modal_loop.py +0 -0
  31. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/preferences.py +0 -0
  32. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/protocol.py +0 -0
  33. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/sharing.py +0 -0
  34. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/tls.py +0 -0
  35. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/src/remotedesktop/window_state.py +0 -0
  36. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/conftest.py +0 -0
  37. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_autostart.py +0 -0
  38. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_client_window.py +0 -0
  39. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_clipboard.py +0 -0
  40. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_config.py +0 -0
  41. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_discovery.py +0 -0
  42. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_frames.py +0 -0
  43. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_icon.py +0 -0
  44. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_input_injection.py +0 -0
  45. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_inventory.py +0 -0
  46. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_logs.py +0 -0
  47. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_modal_loop.py +0 -0
  48. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_preferences.py +0 -0
  49. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_protocol.py +0 -0
  50. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_server_window.py +0 -0
  51. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_sharing.py +0 -0
  52. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_smoke.py +0 -0
  53. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_tls.py +0 -0
  54. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/tests/test_window_state.py +0 -0
  55. {remotedesktop-0.10.0 → remotedesktop-0.12.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.10.0
3
+ Version: 0.12.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.10.0"
3
+ __version__ = "0.12.0"
@@ -110,7 +110,10 @@ class ClientWindow(QMainWindow):
110
110
  InventoryTab(self.inventory, "Forget server", self._forget_server),
111
111
  "Servers on LAN",
112
112
  )
113
- tabs.addTab(PerformanceTab(self.performance), "Performance")
113
+ tabs.addTab(
114
+ PerformanceTab(self.performance, local="client", remote="server"),
115
+ "Performance",
116
+ )
114
117
  self.setCentralWidget(tabs)
115
118
 
116
119
  self.discovery_panel = DiscoveryPanel(self)
@@ -30,13 +30,14 @@ All timing uses time.monotonic(), injectable as `clock=` for tests.
30
30
 
31
31
  import itertools
32
32
  import logging
33
+ import math
33
34
  import time
34
35
  from collections import deque
35
36
  from collections.abc import Callable
36
37
  from typing import Protocol
37
38
 
38
39
  from PySide6.QtCore import QObject, QPointF, QTimer, Qt, Signal
39
- from PySide6.QtGui import QColor, QPainter, QPaintEvent, QPolygonF, QShowEvent
40
+ from PySide6.QtGui import QColor, QPainter, QPaintEvent, QPen, QPolygonF, QShowEvent
40
41
  from PySide6.QtWidgets import QVBoxLayout, QWidget
41
42
 
42
43
  _log = logging.getLogger("remotedesktop.performance")
@@ -58,6 +59,7 @@ DEFAULT_INTERVAL_MS = 1000
58
59
  DEFAULT_DEAD_AFTER_SECONDS = 10.0
59
60
  _PING_PRUNE_SECONDS = 30.0
60
61
  _HEARTBEAT_TICKS = 10 # one debug-log line per this many samples
62
+ _GRID_DIVISIONS = 4 # grid cells per axis, i.e. 5 ticks including both ends
61
63
 
62
64
 
63
65
  class MetricSeries:
@@ -270,6 +272,30 @@ def format_ms(ms: float) -> str:
270
272
  return f"{ms:.1f} ms"
271
273
 
272
274
 
275
+ def rate_unit(bytes_per_second: float) -> float:
276
+ """The divisor format_rate will display this value in (B/KB/MB per s),
277
+ so axis ticks can be computed in the displayed unit and land on round
278
+ numbers there."""
279
+ if bytes_per_second >= 1024 * 1024:
280
+ return 1024.0 * 1024.0
281
+ if bytes_per_second >= 1024:
282
+ return 1024.0
283
+ return 1.0
284
+
285
+
286
+ def nice_ceiling(value: float) -> float:
287
+ """Smallest 1/2/5 x 10^k that is >= value, so axis ticks land on round
288
+ numbers instead of the raw data maximum."""
289
+ if value <= 0:
290
+ return 1.0
291
+ exponent = math.floor(math.log10(value))
292
+ for multiplier in (1.0, 2.0, 5.0, 10.0):
293
+ candidate = multiplier * 10.0**exponent
294
+ if candidate >= value:
295
+ return candidate
296
+ raise AssertionError("unreachable: 10 * 10**floor(log10(v)) >= v")
297
+
298
+
273
299
  class GraphWidget(QWidget):
274
300
  """A rolling line graph over the monitor's window, painted with QPainter.
275
301
 
@@ -284,6 +310,7 @@ class GraphWidget(QWidget):
284
310
  monitor: PerformanceMonitor,
285
311
  format_value: Callable[[float], str],
286
312
  *,
313
+ tick_unit: Callable[[float], float] = lambda _value: 1.0,
287
314
  clock: Callable[[], float] = time.monotonic,
288
315
  parent: QWidget | None = None,
289
316
  ) -> None:
@@ -292,8 +319,12 @@ class GraphWidget(QWidget):
292
319
  self._series = series
293
320
  self._monitor = monitor
294
321
  self._format_value = format_value
322
+ # Maps the data maximum to the unit format_value displays it in, so
323
+ # the axis ceiling is a round number of that unit (100 KB/s, not the
324
+ # 97.7 KB/s that a round number of bytes/s would render as).
325
+ self._tick_unit = tick_unit
295
326
  self._clock = clock
296
- self.setMinimumSize(300, 120)
327
+ self.setMinimumSize(300, 140) # room for the axis-label rows
297
328
 
298
329
  def paintEvent(self, event: QPaintEvent) -> None:
299
330
  painter = QPainter(self)
@@ -313,12 +344,52 @@ class GraphWidget(QWidget):
313
344
  self.rect(), Qt.AlignmentFlag.AlignCenter, "no data"
314
345
  )
315
346
  return
316
- y_max = max(
347
+ raw_max = max(
317
348
  (value for _l, _c, samples in data for _t, value in samples), default=1.0
318
349
  )
319
- y_max = max(y_max, 1.0)
350
+ unit = self._tick_unit(raw_max)
351
+ y_max = unit * nice_ceiling(raw_max / unit)
352
+
353
+ # Y tick labels (with units, via the formatter) size the left margin;
354
+ # the bottom leaves a row for X tick labels and one for the legend.
355
+ metrics = painter.fontMetrics()
356
+ y_ticks = [y_max * i / _GRID_DIVISIONS for i in range(_GRID_DIVISIONS + 1)]
357
+ y_labels = [self._format_value(v) for v in y_ticks]
358
+ left = 8 + max(metrics.horizontalAdvance(t) for t in y_labels) + 6
359
+ graph = self.rect().adjusted(left, 24, -12, -(2 * metrics.height() + 12))
360
+
361
+ grid_color = palette.color(palette.ColorRole.Mid)
362
+ grid_pen = QPen(grid_color, 0, Qt.PenStyle.DotLine)
363
+ text_color = palette.color(palette.ColorRole.Text)
364
+ for value, label in zip(y_ticks, y_labels):
365
+ y = graph.bottom() - graph.height() * (value / y_max)
366
+ painter.setPen(grid_pen)
367
+ painter.drawLine(graph.left(), round(y), graph.right(), round(y))
368
+ painter.setPen(text_color)
369
+ painter.drawText(
370
+ graph.left() - 6 - metrics.horizontalAdvance(label),
371
+ round(y) + metrics.ascent() // 2 - 1,
372
+ label,
373
+ )
374
+ x_label_baseline = graph.bottom() + metrics.ascent() + 4
375
+ for i in range(_GRID_DIVISIONS + 1):
376
+ seconds_ago = window * (1 - i / _GRID_DIVISIONS)
377
+ x = graph.left() + graph.width() * i / _GRID_DIVISIONS
378
+ painter.setPen(grid_pen)
379
+ painter.drawLine(round(x), graph.top(), round(x), graph.bottom())
380
+ label = f"{seconds_ago:g}"
381
+ width = metrics.horizontalAdvance(label)
382
+ # Center on the tick, but keep the edge labels inside the graph.
383
+ text_x = min(max(x - width / 2, graph.left()), graph.right() - width)
384
+ painter.setPen(text_color)
385
+ painter.drawText(round(text_x), x_label_baseline, label)
386
+ caption = "seconds ago"
387
+ painter.drawText(
388
+ self.rect().right() - 8 - metrics.horizontalAdvance(caption),
389
+ self.rect().bottom() - 6,
390
+ caption,
391
+ )
320
392
 
321
- graph = self.rect().adjusted(8, 24, -8, -20)
322
393
  for label, color, samples in data:
323
394
  if not samples:
324
395
  continue
@@ -336,14 +407,8 @@ class GraphWidget(QWidget):
336
407
  latest = samples[-1][1] if samples else None
337
408
  text = f"{label} {self._format_value(latest)}" if latest is not None else f"{label} —"
338
409
  painter.setPen(color)
339
- painter.drawText(x, self.rect().bottom() - 6, text)
340
- x += painter.fontMetrics().horizontalAdvance(text) + 16
341
- painter.setPen(palette.color(palette.ColorRole.Text))
342
- painter.drawText(
343
- self.rect().right() - 8 - painter.fontMetrics().horizontalAdvance(self._format_value(y_max)),
344
- 16,
345
- self._format_value(y_max),
346
- )
410
+ painter.drawText(round(x), self.rect().bottom() - 6, text)
411
+ x += metrics.horizontalAdvance(text) + 16
347
412
 
348
413
 
349
414
  class PerformanceTab(QWidget):
@@ -353,7 +418,14 @@ class PerformanceTab(QWidget):
353
418
  monitor regardless, but a background tab schedules zero paint work.
354
419
  """
355
420
 
356
- def __init__(self, monitor: PerformanceMonitor, parent: QWidget | None = None) -> None:
421
+ def __init__(
422
+ self,
423
+ monitor: PerformanceMonitor,
424
+ *,
425
+ local: str = "client",
426
+ remote: str = "server",
427
+ parent: QWidget | None = None,
428
+ ) -> None:
357
429
  super().__init__(parent)
358
430
  self._monitor = monitor
359
431
  self.bandwidth_graph = GraphWidget(
@@ -364,12 +436,16 @@ class PerformanceTab(QWidget):
364
436
  ],
365
437
  monitor,
366
438
  format_rate,
439
+ tick_unit=rate_unit,
367
440
  )
441
+ # Each line is named for the side that sent the ping, so the same
442
+ # line has the same name in both apps ("client → server" is the
443
+ # client-measured round trip on either end).
368
444
  self.ping_graph = GraphWidget(
369
445
  "Round-trip time",
370
446
  [
371
- ("this side", QColor("#ff9800"), monitor.rtt_ms),
372
- ("peer", QColor("#9c27b0"), monitor.peer_rtt_ms),
447
+ (f"{local} → {remote}", QColor("#ff9800"), monitor.rtt_ms),
448
+ (f"{remote} → {local}", QColor("#9c27b0"), monitor.peer_rtt_ms),
373
449
  ],
374
450
  monitor,
375
451
  format_ms,
@@ -108,7 +108,10 @@ class ServerWindow(QMainWindow):
108
108
  InventoryTab(self.inventory, "Revoke access", self._revoke_client),
109
109
  "Clients on LAN",
110
110
  )
111
- tabs.addTab(PerformanceTab(self.performance), "Performance")
111
+ tabs.addTab(
112
+ PerformanceTab(self.performance, local="server", remote="client"),
113
+ "Performance",
114
+ )
112
115
  tabs.addTab(log_tab, "Connection log")
113
116
  tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
114
117
  self.setCentralWidget(tabs)
@@ -145,6 +145,14 @@ class ViewerWidget(QWidget):
145
145
  self._pressed_keys.discard(vk)
146
146
  self.inputEvent.emit({"action": "key", "vk": vk, "pressed": pressed})
147
147
 
148
+ def focusNextPrevChild(self, next: bool) -> bool:
149
+ # Qt consumes Tab/Shift+Tab for focus traversal before keyPressEvent
150
+ # ever runs; declining here makes them arrive as ordinary key events
151
+ # so they reach the remote desktop instead of moving local focus.
152
+ if self._frame is not None:
153
+ return False
154
+ return super().focusNextPrevChild(next)
155
+
148
156
  def keyPressEvent(self, event: QKeyEvent) -> None:
149
157
  self._key_event(event, True)
150
158
 
@@ -207,6 +207,25 @@ def test_viewer_forwards_wheel_and_key_release(qapp):
207
207
  assert events[-1]["pressed"] is False
208
208
 
209
209
 
210
+ def test_tab_key_is_forwarded_not_used_for_focus_traversal(qapp):
211
+ viewer = ViewerWidget()
212
+ viewer.resize(400, 400)
213
+ viewer.show_frame(QImage(200, 100, QImage.Format.Format_RGB32))
214
+ events: list[dict] = []
215
+ viewer.inputEvent.connect(events.append)
216
+ # Send Tab through the real event path: QWidget.event() offers it to
217
+ # focusNextPrevChild before keyPressEvent, which is what ate Tab.
218
+ tab = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier, 0, 0x09, 0)
219
+ qapp.sendEvent(viewer, tab)
220
+ assert events == [{"action": "key", "vk": 0x09, "pressed": True}]
221
+ # Shift+Tab arrives as Backtab and must be forwarded the same way.
222
+ backtab = QKeyEvent(
223
+ QEvent.Type.KeyPress, Qt.Key.Key_Backtab, Qt.KeyboardModifier.ShiftModifier, 0, 0x09, 0
224
+ )
225
+ qapp.sendEvent(viewer, backtab)
226
+ assert events[-1] == {"action": "key", "vk": 0x09, "pressed": True}
227
+
228
+
210
229
  def test_release_when_frame_vanished_mid_drag(qapp):
211
230
  viewer = ViewerWidget()
212
231
  viewer.resize(400, 400)
@@ -12,6 +12,8 @@ from remotedesktop.performance import (
12
12
  MetricSeries,
13
13
  PerformanceMonitor,
14
14
  PerformanceTab,
15
+ nice_ceiling,
16
+ rate_unit,
15
17
  )
16
18
  from remotedesktop.protocol import MessageStream
17
19
  from remotedesktop.sharing import ShareClient, ShareServer
@@ -325,11 +327,52 @@ def test_share_client_drops_the_connection_on_lost_signal(qapp, tmp_path):
325
327
  assert any("Connection lost" in s and "10 s" in s for s in statuses)
326
328
 
327
329
 
330
+ def test_nice_ceiling_rounds_up_to_1_2_5():
331
+ assert nice_ceiling(0.0) == 1.0
332
+ assert nice_ceiling(-3.0) == 1.0
333
+ assert nice_ceiling(0.7) == 1.0
334
+ assert nice_ceiling(1.0) == 1.0
335
+ assert nice_ceiling(1.5) == 2.0
336
+ assert nice_ceiling(3.0) == 5.0
337
+ assert nice_ceiling(42.0) == 50.0
338
+ assert nice_ceiling(50.0) == 50.0
339
+ assert nice_ceiling(99.0) == 100.0
340
+ assert nice_ceiling(2049.0) == 5000.0
341
+ assert nice_ceiling(0.03) == pytest.approx(0.05)
342
+
343
+
344
+ def test_rate_unit_matches_format_rate_unit():
345
+ assert rate_unit(500.0) == 1.0
346
+ assert rate_unit(2048.0) == 1024.0
347
+ assert rate_unit(3 * 1024 * 1024) == 1024.0 * 1024.0
348
+ # A round tick count in the displayed unit: 70000 B/s displays in KB/s,
349
+ # and the resulting ceiling is 100 KB/s exactly.
350
+ unit = rate_unit(70000.0)
351
+ assert unit * nice_ceiling(70000.0 / unit) == 100.0 * 1024.0
352
+
353
+
354
+ def test_rtt_lines_are_named_for_the_side_that_pings(qapp):
355
+ monitor = PerformanceMonitor()
356
+ client_tab = PerformanceTab(monitor, local="client", remote="server")
357
+ server_tab = PerformanceTab(monitor, local="server", remote="client")
358
+ # This side's own measurement (rtt_ms) is listed first; the same physical
359
+ # measurement carries the same name in both apps.
360
+ assert [label for label, _c, series in client_tab.ping_graph._series] == [
361
+ "client → server",
362
+ "server → client",
363
+ ]
364
+ assert [label for label, _c, series in server_tab.ping_graph._series] == [
365
+ "server → client",
366
+ "client → server",
367
+ ]
368
+
369
+
328
370
  def test_graph_widgets_render_headless(qapp):
329
371
  seeded = PerformanceMonitor()
330
372
  seeded.send_bps.add(100.0)
331
373
  seeded.send_bps.add(2048.0)
332
374
  seeded.recv_bps.add(50.0)
375
+ seeded.rtt_ms.add(12.5) # exercises the RTT graph's grid/axes path too
333
376
  for monitor in (seeded, PerformanceMonitor()): # data and "no data" paths
334
377
  tab = PerformanceTab(monitor)
335
378
  tab.resize(400, 300)
File without changes
File without changes
File without changes
File without changes