remotedesktop 0.10.0__tar.gz → 0.11.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.10.0 → remotedesktop-0.11.0}/PKG-INFO +1 -1
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/performance.py +79 -13
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/viewer.py +8 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_input.py +19 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_performance.py +27 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/.gitignore +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/CLAUDE.md +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/LICENSE +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/pyproject.toml +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/readme.md +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/run_claude.bat +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/run_client.bat +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/run_server.bat +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/server.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/sharing.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/conftest.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_client_window.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_config.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_server_window.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/tests/test_window_state.py +0 -0
- {remotedesktop-0.10.0 → remotedesktop-0.11.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Remote desktop client/server for Windows computers on the same LAN, with autodiscovery. Provides screen, keyboard, mouse, and clipboard sharing without RDP or Microsoft authentication.
|
|
5
5
|
Author-email: James Abel <j@abel.co>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,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,
|
|
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
|
-
|
|
347
|
+
raw_max = max(
|
|
317
348
|
(value for _l, _c, samples in data for _t, value in samples), default=1.0
|
|
318
349
|
)
|
|
319
|
-
|
|
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 +=
|
|
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):
|
|
@@ -364,6 +429,7 @@ class PerformanceTab(QWidget):
|
|
|
364
429
|
],
|
|
365
430
|
monitor,
|
|
366
431
|
format_rate,
|
|
432
|
+
tick_unit=rate_unit,
|
|
367
433
|
)
|
|
368
434
|
self.ping_graph = GraphWidget(
|
|
369
435
|
"Round-trip time",
|
|
@@ -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,36 @@ 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
|
+
|
|
328
354
|
def test_graph_widgets_render_headless(qapp):
|
|
329
355
|
seeded = PerformanceMonitor()
|
|
330
356
|
seeded.send_bps.add(100.0)
|
|
331
357
|
seeded.send_bps.add(2048.0)
|
|
332
358
|
seeded.recv_bps.add(50.0)
|
|
359
|
+
seeded.rtt_ms.add(12.5) # exercises the RTT graph's grid/axes path too
|
|
333
360
|
for monitor in (seeded, PerformanceMonitor()): # data and "no data" paths
|
|
334
361
|
tab = PerformanceTab(monitor)
|
|
335
362
|
tab.resize(400, 300)
|
|
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
|