remotedesktop 0.6.0__tar.gz → 0.7.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 (47) hide show
  1. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/PKG-INFO +1 -1
  2. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/__init__.py +1 -1
  3. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/client.py +1 -3
  4. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/server.py +2 -1
  5. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/sharing.py +11 -1
  6. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/viewer.py +22 -8
  7. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_sharing.py +17 -1
  8. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_smoke.py +16 -0
  9. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/.github/workflows/ci.yml +0 -0
  10. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/.gitignore +0 -0
  11. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/CLAUDE.md +0 -0
  12. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/LICENSE +0 -0
  13. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/pyproject.toml +0 -0
  14. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/readme.md +0 -0
  15. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/run_claude.bat +0 -0
  16. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/run_client.bat +0 -0
  17. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/run_server.bat +0 -0
  18. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/scripts/make_coverage_badge.py +0 -0
  19. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/scripts/make_venv.bat +0 -0
  20. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/autostart.py +0 -0
  21. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/clipboard.py +0 -0
  22. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/config.py +0 -0
  23. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/db.py +0 -0
  24. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/discovery.py +0 -0
  25. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/frames.py +0 -0
  26. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/input_injection.py +0 -0
  27. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/inventory.py +0 -0
  28. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/logs.py +0 -0
  29. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/protocol.py +0 -0
  30. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/tls.py +0 -0
  31. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/src/remotedesktop/window_state.py +0 -0
  32. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/conftest.py +0 -0
  33. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_autostart.py +0 -0
  34. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_client_window.py +0 -0
  35. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_clipboard.py +0 -0
  36. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_config.py +0 -0
  37. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_discovery.py +0 -0
  38. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_frames.py +0 -0
  39. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_input.py +0 -0
  40. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_input_injection.py +0 -0
  41. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_inventory.py +0 -0
  42. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_logs.py +0 -0
  43. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_protocol.py +0 -0
  44. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_server_window.py +0 -0
  45. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_tls.py +0 -0
  46. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/tests/test_window_state.py +0 -0
  47. {remotedesktop-0.6.0 → remotedesktop-0.7.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.6.0
3
+ Version: 0.7.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.6.0"
3
+ __version__ = "0.7.0"
@@ -111,9 +111,7 @@ class ClientWindow(QMainWindow):
111
111
  self.connection_log = QPlainTextEdit(self)
112
112
  self.connection_log.setReadOnly(True)
113
113
  self.connection_log.setMaximumBlockCount(1000)
114
- log_dock = QDockWidget("Connection log", self)
115
- log_dock.setWidget(self.connection_log)
116
- self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, log_dock)
114
+ tabs.addTab(self.connection_log, "Connection log")
117
115
 
118
116
  self.discovery_panel.serverActivated.connect(self._on_server_activated)
119
117
  self.discovery_panel.serversFound.connect(self._record_discovered)
@@ -64,7 +64,7 @@ class ServerWindow(QMainWindow):
64
64
  status_layout = QVBoxLayout(status_tab)
65
65
  status_layout.addWidget(self._summary)
66
66
  status_layout.addWidget(self.autostart_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
67
- status_layout.addWidget(self.connection_log, stretch=1)
67
+ status_layout.addStretch(1)
68
68
 
69
69
  # Tests inject a connection to a temp database; the app uses the default.
70
70
  self._db = connection if connection is not None else db.connect(default_db_path())
@@ -75,6 +75,7 @@ class ServerWindow(QMainWindow):
75
75
  InventoryTab(self.inventory, "Revoke access", self._revoke_client),
76
76
  "Clients on LAN",
77
77
  )
78
+ tabs.addTab(self.connection_log, "Connection log")
78
79
  self.setCentralWidget(tabs)
79
80
 
80
81
  if credentials is None:
@@ -22,6 +22,7 @@ from typing import cast
22
22
  from PySide6.QtCore import QObject, QTimer, Signal
23
23
  from PySide6.QtGui import QGuiApplication, QImage
24
24
  from PySide6.QtNetwork import (
25
+ QAbstractSocket,
25
26
  QHostAddress,
26
27
  QSslCertificate,
27
28
  QSslKey,
@@ -44,7 +45,10 @@ from remotedesktop import db, tls
44
45
 
45
46
  _log = logging.getLogger("remotedesktop.sharing")
46
47
 
47
- DEFAULT_FPS = 10
48
+ # 30 fps keeps key-to-glyph latency low (~17 ms average sampling delay).
49
+ # Affordable since inter-frame compression: an unchanged screen costs only
50
+ # a capture and a memory compare per tick — nothing is encoded or sent.
51
+ DEFAULT_FPS = 30
48
52
  # Only for full frames to legacy (0.5.0) clients, which force-decode frames
49
53
  # as JPEG. Delta-capable clients get lossless PNG keyframes and delta bands.
50
54
  JPEG_QUALITY = 70
@@ -173,6 +177,9 @@ class ShareServer(QObject):
173
177
  while self._server.hasPendingConnections():
174
178
  # QSslServer hands out QSslSockets; the stubs say QTcpSocket.
175
179
  sock = cast(QSslSocket, self._server.nextPendingConnection())
180
+ # TCP_NODELAY: input events and delta frames are small messages;
181
+ # Nagle + delayed ACK would add tens to hundreds of ms of latency.
182
+ sock.setSocketOption(QAbstractSocket.SocketOption.LowDelayOption, 1)
176
183
  self.status.emit(
177
184
  f"Incoming TLS connection from {_peer(sock)} "
178
185
  f"(encrypted={sock.isEncrypted()}) — waiting for hello"
@@ -568,6 +575,9 @@ class ShareClient(QObject):
568
575
  self._socket.ignoreSslErrors()
569
576
 
570
577
  def _on_encrypted(self) -> None:
578
+ # TCP_NODELAY (settable only once connected): keystrokes are tiny
579
+ # messages; Nagle + delayed ACK would add tens to hundreds of ms.
580
+ self._socket.setSocketOption(QAbstractSocket.SocketOption.LowDelayOption, 1)
571
581
  cert = self._socket.peerCertificate()
572
582
  fingerprint = "" if cert.isNull() else tls.certificate_fingerprint(cert)
573
583
  self._server_fingerprint = fingerprint
@@ -1,6 +1,6 @@
1
1
  """Viewer widget that displays the remote desktop and forwards input to it."""
2
2
 
3
- from PySide6.QtCore import QPointF, QRectF, Qt, Signal
3
+ from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
4
4
  from PySide6.QtGui import (
5
5
  QFocusEvent,
6
6
  QImage,
@@ -32,7 +32,10 @@ class ViewerWidget(QWidget):
32
32
  # The frame stays at the server's full resolution; what is painted is
33
33
  # a SmoothTransformation-scaled copy (proper filtering, unlike the
34
34
  # nearest-neighbor scaling of drawPixmap into a rect), cached until
35
- # the frame or the display size changes.
35
+ # the frame or the display size changes. It is scaled to *device*
36
+ # pixels and stamped with the devicePixelRatio: sizing it in logical
37
+ # pixels would make Qt bilinear-upscale it again by the Windows
38
+ # display-scaling factor (125-150% on most monitors), blurring text.
36
39
  self._scaled: QPixmap | None = None
37
40
  self._message = "Not connected"
38
41
  # Buttons/keys currently held, so releases can be forwarded even when
@@ -175,10 +178,21 @@ class ViewerWidget(QWidget):
175
178
  painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self._message)
176
179
  return
177
180
  rect = self._display_rect().toRect()
178
- if self._scaled is None or self._scaled.size() != rect.size():
179
- self._scaled = self._frame.scaled(
180
- rect.size(),
181
- Qt.AspectRatioMode.IgnoreAspectRatio, # rect is already aspect-correct
182
- Qt.TransformationMode.SmoothTransformation,
183
- )
181
+ dpr = self.devicePixelRatioF()
182
+ target = QSize(round(rect.width() * dpr), round(rect.height() * dpr))
183
+ if (
184
+ self._scaled is None
185
+ or self._scaled.size() != target
186
+ or self._scaled.devicePixelRatio() != dpr
187
+ ):
188
+ if self._frame.size() == target:
189
+ # Displayed at exactly 1:1 device pixels — no resample at all.
190
+ self._scaled = QPixmap(self._frame)
191
+ else:
192
+ self._scaled = self._frame.scaled(
193
+ target,
194
+ Qt.AspectRatioMode.IgnoreAspectRatio, # rect is already aspect-correct
195
+ Qt.TransformationMode.SmoothTransformation,
196
+ )
197
+ self._scaled.setDevicePixelRatio(dpr)
184
198
  painter.drawPixmap(rect.topLeft(), self._scaled)
@@ -3,7 +3,7 @@ import time
3
3
 
4
4
  from PySide6.QtCore import QEventLoop
5
5
  from PySide6.QtGui import QColor, QImage, QPainter
6
- from PySide6.QtNetwork import QSslSocket
6
+ from PySide6.QtNetwork import QAbstractSocket, QSslSocket
7
7
 
8
8
  from remotedesktop import db, tls
9
9
  from remotedesktop.config import KnownServers, PairedClients
@@ -399,6 +399,22 @@ def test_disconnected_client_paths_are_noops(qapp, tmp_path):
399
399
  client.close()
400
400
 
401
401
 
402
+ def test_low_delay_is_set_on_both_ends(qapp, credentials, tmp_path):
403
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
404
+ client = make_client(tmp_path)
405
+ names = []
406
+ client.connected.connect(names.append)
407
+ client.connect_to("127.0.0.1", server.port)
408
+ try:
409
+ pump(qapp, lambda: names and server._streams)
410
+ option = QAbstractSocket.SocketOption.LowDelayOption
411
+ assert client._socket.socketOption(option) == 1
412
+ assert server._streams[0].socket.socketOption(option) == 1
413
+ finally:
414
+ client.close()
415
+ server.close()
416
+
417
+
402
418
  def solid_image(color, *, width=64, height=128):
403
419
  image = QImage(width, height, QImage.Format.Format_RGB32)
404
420
  image.fill(QColor(color))
@@ -56,3 +56,19 @@ def test_viewer_widget_shows_and_clears_frames(qapp) -> None:
56
56
  assert viewer.has_frame
57
57
  viewer.clear("gone")
58
58
  assert not viewer.has_frame
59
+
60
+
61
+ def test_viewer_scales_frames_in_device_pixels(qapp) -> None:
62
+ from PySide6.QtGui import QImage
63
+
64
+ viewer = ViewerWidget()
65
+ viewer.resize(320, 240)
66
+ viewer.show_frame(QImage(640, 480, QImage.Format.Format_RGB32)) # same 4:3 aspect
67
+ viewer.grab() # forces a paint pass without showing a window
68
+ assert viewer._scaled is not None
69
+ dpr = viewer.devicePixelRatioF()
70
+ # Scaled to physical pixels and stamped with the ratio, so painting it
71
+ # never resamples a second time.
72
+ assert viewer._scaled.devicePixelRatio() == dpr
73
+ assert viewer._scaled.size().width() == round(320 * dpr)
74
+ assert viewer._scaled.size().height() == round(240 * dpr)
File without changes
File without changes
File without changes
File without changes
File without changes