remotedesktop 0.18.0__tar.gz → 0.19.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 (64) hide show
  1. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/CLAUDE.md +1 -1
  2. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/PKG-INFO +15 -1
  3. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/pyproject.toml +6 -0
  4. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/readme.md +10 -0
  5. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/__init__.py +1 -1
  6. remotedesktop-0.19.0/src/remotedesktop/about.py +71 -0
  7. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/client.py +34 -4
  8. remotedesktop-0.19.0/src/remotedesktop/compat.py +38 -0
  9. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/server.py +18 -5
  10. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/sharing.py +32 -3
  11. remotedesktop-0.19.0/tests/test_about.py +36 -0
  12. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_client_window.py +32 -0
  13. remotedesktop-0.19.0/tests/test_compat.py +29 -0
  14. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_performance.py +3 -0
  15. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_server_window.py +51 -4
  16. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_sharing.py +26 -0
  17. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/uv.lock +11 -0
  18. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/.github/workflows/ci.yml +0 -0
  19. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/.gitignore +0 -0
  20. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/LICENSE +0 -0
  21. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/docs/media/client-demo.gif +0 -0
  22. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/docs/media/server-demo.gif +0 -0
  23. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/run_claude.bat +0 -0
  24. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/run_client.bat +0 -0
  25. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/run_server.bat +0 -0
  26. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/scripts/make_coverage_badge.py +0 -0
  27. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/scripts/make_venv.bat +0 -0
  28. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/autostart.py +0 -0
  29. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/clipboard.py +0 -0
  30. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/config.py +0 -0
  31. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/db.py +0 -0
  32. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/discovery.py +0 -0
  33. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/dxgi.py +0 -0
  34. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/frames.py +0 -0
  35. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/icon.py +0 -0
  36. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/input_injection.py +0 -0
  37. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/inventory.py +0 -0
  38. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/logs.py +0 -0
  39. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/modal_loop.py +0 -0
  40. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/performance.py +0 -0
  41. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/preferences.py +0 -0
  42. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/protocol.py +0 -0
  43. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/tls.py +0 -0
  44. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/viewer.py +0 -0
  45. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/window_state.py +0 -0
  46. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/conftest.py +0 -0
  47. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_autostart.py +0 -0
  48. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_clipboard.py +0 -0
  49. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_config.py +0 -0
  50. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_discovery.py +0 -0
  51. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_dxgi.py +0 -0
  52. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_frames.py +0 -0
  53. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_icon.py +0 -0
  54. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_input.py +0 -0
  55. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_input_injection.py +0 -0
  56. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_inventory.py +0 -0
  57. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_logs.py +0 -0
  58. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_modal_loop.py +0 -0
  59. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_preferences.py +0 -0
  60. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_protocol.py +0 -0
  61. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_smoke.py +0 -0
  62. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_tls.py +0 -0
  63. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_window_state.py +0 -0
  64. {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tools/make_demo_gifs.py +0 -0
@@ -53,7 +53,7 @@ Managed with `uv` (hatchling build backend, src layout):
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
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
- - 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.
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. **Versioning is strict semver** (`compat.py`, using the `semver` package): major = client/server compatibility contract, minor = backward-compatible features, patch = bug fixes — bump the major when the wire protocol or behavior breaks older peers. Both sides compare majors on every connection (hello/welcome carry `app_version`) and show a strong warning on mismatch (server status log + ⚠ in the viewers table's Version column; client log + non-modal dialog + status-bar marker) but never block the connection. Peers too old to report a version produce no warning.
57
57
  - Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
58
58
 
59
59
  ## Environment Notes
@@ -1,7 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.18.0
3
+ Version: 0.19.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
+ Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
6
+ Project-URL: Repository, https://github.com/jamesabel/remotedesktop
7
+ Project-URL: PyPI, https://pypi.org/project/remotedesktop/
5
8
  Author-email: James Abel <j@abel.co>
6
9
  License-Expression: MIT
7
10
  License-File: LICENSE
@@ -15,6 +18,7 @@ Requires-Python: >=3.14
15
18
  Requires-Dist: cryptography>=49.0.0
16
19
  Requires-Dist: platformdirs>=4.10.0
17
20
  Requires-Dist: pyside6>=6.11.1
21
+ Requires-Dist: semver>=3.0
18
22
  Description-Content-Type: text/markdown
19
23
 
20
24
  # Remote Desktop
@@ -111,6 +115,16 @@ handshake messages until the server user admits them; access can be revoked
111
115
  at any time from the server's *Clients on LAN* tab. There is no dependency
112
116
  on Windows RDP or any Microsoft-based authentication.
113
117
 
118
+ ## Versioning
119
+
120
+ The apps follow [semantic versioning](https://semver.org): the **major**
121
+ version is the client/server compatibility contract (same major →
122
+ guaranteed to interoperate), **minor** versions add backward-compatible
123
+ features, **patch** versions fix bugs. On every connection each side
124
+ compares its major version with the peer's; a mismatch shows a strong
125
+ warning in both GUIs — connecting is still allowed, but the experience is
126
+ not guaranteed. Keep both computers on the same version for best results.
127
+
114
128
  ## Requirements
115
129
 
116
130
  - Windows
@@ -18,8 +18,14 @@ dependencies = [
18
18
  "cryptography>=49.0.0",
19
19
  "platformdirs>=4.10.0",
20
20
  "pyside6>=6.11.1",
21
+ "semver>=3.0",
21
22
  ]
22
23
 
24
+ [project.urls]
25
+ Homepage = "https://github.com/jamesabel/remotedesktop"
26
+ Repository = "https://github.com/jamesabel/remotedesktop"
27
+ PyPI = "https://pypi.org/project/remotedesktop/"
28
+
23
29
  [project.gui-scripts]
24
30
  remotedesktop-client = "remotedesktop.client:main"
25
31
  remotedesktop-server = "remotedesktop.server:main"
@@ -92,6 +92,16 @@ handshake messages until the server user admits them; access can be revoked
92
92
  at any time from the server's *Clients on LAN* tab. There is no dependency
93
93
  on Windows RDP or any Microsoft-based authentication.
94
94
 
95
+ ## Versioning
96
+
97
+ The apps follow [semantic versioning](https://semver.org): the **major**
98
+ version is the client/server compatibility contract (same major →
99
+ guaranteed to interoperate), **minor** versions add backward-compatible
100
+ features, **patch** versions fix bugs. On every connection each side
101
+ compares its major version with the peer's; a mismatch shows a strong
102
+ warning in both GUIs — connecting is still allowed, but the experience is
103
+ not guaranteed. Keep both computers on the same version for best results.
104
+
95
105
  ## Requirements
96
106
 
97
107
  - Windows
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.18.0"
3
+ __version__ = "0.19.0"
@@ -0,0 +1,71 @@
1
+ """The "About" tab, shared by the client and server windows.
2
+
3
+ Shows the same metadata the PyPI page shows — summary, author, license,
4
+ Python requirement, links — read from the installed package's metadata via
5
+ importlib.metadata, with static fallbacks so the tab still renders in odd
6
+ environments (the version always comes from `remotedesktop.__version__`,
7
+ the single source hatchling packages)."""
8
+
9
+ from importlib import metadata
10
+
11
+ from PySide6.QtCore import Qt
12
+ from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
13
+
14
+ from remotedesktop import __version__, icon
15
+
16
+ _HOMEPAGE = "https://github.com/jamesabel/remotedesktop"
17
+ _PYPI = "https://pypi.org/project/remotedesktop/"
18
+ _FALLBACKS = {
19
+ "Summary": (
20
+ "Remote desktop client/server for Windows computers on the same LAN, "
21
+ "with autodiscovery. Provides screen, keyboard, mouse, and clipboard "
22
+ "sharing without RDP or Microsoft authentication."
23
+ ),
24
+ "Author-email": "James Abel <j@abel.co>",
25
+ "License-Expression": "MIT",
26
+ "Requires-Python": ">=3.14",
27
+ }
28
+
29
+
30
+ def _field(name: str) -> str:
31
+ try:
32
+ value = metadata.metadata("remotedesktop").get(name)
33
+ except metadata.PackageNotFoundError:
34
+ value = None
35
+ return str(value) if value else _FALLBACKS.get(name, "")
36
+
37
+
38
+ class AboutTab(QWidget):
39
+ """Package metadata as a rich-text page with clickable links."""
40
+
41
+ def __init__(self, parent: QWidget | None = None) -> None:
42
+ super().__init__(parent)
43
+ author = _field("Author-email").replace("<", "&lt;").replace(">", "&gt;")
44
+ body = QLabel(
45
+ f"""
46
+ <h2 style="margin-bottom:2px">Remote Desktop {__version__}</h2>
47
+ <p>{_field("Summary")}</p>
48
+ <table cellspacing="4">
49
+ <tr><td><b>Author</b></td><td>{author}</td></tr>
50
+ <tr><td><b>License</b></td><td>{_field("License-Expression")}</td></tr>
51
+ <tr><td><b>Python</b></td><td>{_field("Requires-Python")}</td></tr>
52
+ <tr><td><b>Package</b></td><td>remotedesktop</td></tr>
53
+ </table>
54
+ <p>
55
+ <a href="{_HOMEPAGE}">GitHub</a> &nbsp;·&nbsp;
56
+ <a href="{_PYPI}">PyPI</a>
57
+ </p>
58
+ """
59
+ )
60
+ body.setTextFormat(Qt.TextFormat.RichText)
61
+ body.setOpenExternalLinks(True)
62
+ body.setWordWrap(True)
63
+ body.setTextInteractionFlags(
64
+ Qt.TextInteractionFlag.TextBrowserInteraction
65
+ )
66
+ logo = QLabel()
67
+ logo.setPixmap(icon.app_icon("client").pixmap(48, 48))
68
+ layout = QVBoxLayout(self)
69
+ layout.addWidget(logo)
70
+ layout.addWidget(body)
71
+ layout.addStretch(1)
@@ -23,7 +23,8 @@ from PySide6.QtWidgets import (
23
23
  QWidget,
24
24
  )
25
25
 
26
- from remotedesktop import db, icon, logs, window_state
26
+ from remotedesktop import __version__, compat, db, icon, logs, window_state
27
+ from remotedesktop.about import AboutTab
27
28
  from remotedesktop.logs import PeerLogDialog, read_log_tail
28
29
  from remotedesktop.clipboard import ClipboardSync
29
30
  from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
@@ -93,7 +94,7 @@ class ClientWindow(QMainWindow):
93
94
  self, *, connection: sqlite3.Connection | None = None, auto_scan: bool = True
94
95
  ) -> None:
95
96
  super().__init__()
96
- self.setWindowTitle("Remote Desktop Client")
97
+ self.setWindowTitle(f"Remote Desktop Client {__version__}")
97
98
  self.setWindowIcon(icon.app_icon("client"))
98
99
  # Tests inject a connection to a temp database; the app uses the default.
99
100
  self._db = connection if connection is not None else db.connect(default_db_path())
@@ -133,6 +134,7 @@ class ClientWindow(QMainWindow):
133
134
  log_layout.addWidget(self.connection_log)
134
135
  tabs.addTab(log_tab, "Connection log")
135
136
  tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
137
+ tabs.addTab(AboutTab(), "About")
136
138
 
137
139
  self.discovery_panel.serverActivated.connect(self._on_server_activated)
138
140
  self.discovery_panel.serversFound.connect(self._record_discovered)
@@ -145,6 +147,7 @@ class ClientWindow(QMainWindow):
145
147
  self._client: ShareClient | None = None
146
148
  self._connected = False
147
149
  self._denied = False
150
+ self._version_mismatch = False
148
151
  self._server_name = ""
149
152
  self._server_key = ""
150
153
  self._frame_count = 0
@@ -196,6 +199,7 @@ class ClientWindow(QMainWindow):
196
199
  self._frame_count = 0
197
200
  self._connected = False
198
201
  self._denied = False
202
+ self._version_mismatch = False
199
203
  self.inventory.record(
200
204
  self._server_key, "attempt", name=server.name,
201
205
  address=self._server_key, detail=self._server_key,
@@ -230,13 +234,39 @@ class ClientWindow(QMainWindow):
230
234
  f"Waiting for the user on {self._server_name} to approve this computer …"
231
235
  )
232
236
 
237
+ def _server_label(self) -> str:
238
+ """The server's name with its app version when it reported one,
239
+ e.g. 'DEN-PC (0.19.0)' — flagged when its major version differs."""
240
+ version = self._client.server_app_version if self._client is not None else ""
241
+ if not version:
242
+ return self._server_name
243
+ marker = " ⚠ VERSION MISMATCH" if self._version_mismatch else ""
244
+ return f"{self._server_name} ({version}{marker})"
245
+
233
246
  def _on_connected(self, server_name: str) -> None:
234
247
  self._server_name = server_name or self._server_name
235
248
  self._connected = True
249
+ # Semver policy: matching majors are the compatibility contract. A
250
+ # mismatch warns loudly (log, dialog, status bar) but never blocks —
251
+ # the user may still try, with no guarantees.
252
+ server_version = self._client.server_app_version if self._client is not None else ""
253
+ warning = compat.mismatch_warning(__version__, server_version, "server")
254
+ self._version_mismatch = warning is not None
255
+ if warning:
256
+ self.log(warning)
257
+ box = QMessageBox(
258
+ QMessageBox.Icon.Warning,
259
+ "Version mismatch",
260
+ warning,
261
+ QMessageBox.StandardButton.Ok,
262
+ self,
263
+ )
264
+ box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
265
+ box.show() # non-modal: streaming continues behind it
236
266
  self.inventory.record(self._server_key, "connected", name=self._server_name)
237
267
  self.viewer.setFocus()
238
268
  self.statusBar().showMessage(
239
- f"Connected to {self._server_name} — waiting for first frame "
269
+ f"Connected to {self._server_label()} — waiting for first frame "
240
270
  "(click the view to control it)"
241
271
  )
242
272
 
@@ -279,7 +309,7 @@ class ClientWindow(QMainWindow):
279
309
  self._frame_count += 1
280
310
  self.viewer.show_frame(image)
281
311
  self.statusBar().showMessage(
282
- f"Viewing {self._server_name} — {image.width()}x{image.height()} — "
312
+ f"Viewing {self._server_label()} — {image.width()}x{image.height()} — "
283
313
  f"{self._frame_count} frames received"
284
314
  )
285
315
 
@@ -0,0 +1,38 @@
1
+ """Semantic-version compatibility policy for client/server connections.
2
+
3
+ The apps follow semver (https://semver.org): the MAJOR version is the
4
+ client/server compatibility contract — two apps with the same major are
5
+ expected to interoperate, different majors may not. MINOR versions add
6
+ backward-compatible features, PATCH versions fix bugs.
7
+
8
+ On every connection each side compares its own major against the peer's
9
+ (the hello and welcome messages carry `app_version`) and shows a strong
10
+ warning on a mismatch. The connection is still allowed — the user may try —
11
+ but nothing about it is guaranteed. Peers too old to report a version
12
+ (pre-0.19) can't be compared and produce no warning.
13
+ """
14
+
15
+ import semver
16
+
17
+
18
+ def major_of(version: str) -> int | None:
19
+ """The semver major of `version`, or None when it isn't valid semver
20
+ (including the empty string an old peer implies)."""
21
+ try:
22
+ return semver.Version.parse(version).major
23
+ except (ValueError, TypeError):
24
+ return None
25
+
26
+
27
+ def mismatch_warning(mine: str, theirs: str, peer: str) -> str | None:
28
+ """A strong user-facing warning when the major versions differ, else
29
+ None. `peer` names the other side ("client" or "server")."""
30
+ ours, others = major_of(mine), major_of(theirs)
31
+ if ours is None or others is None or ours == others:
32
+ return None
33
+ return (
34
+ f"WARNING: version mismatch — this app is {mine} but the {peer} is "
35
+ f"{theirs}. Different major versions are not guaranteed to work "
36
+ f"together: you can still connect, but features may fail or behave "
37
+ f"incorrectly. Update both computers to the same version."
38
+ )
@@ -26,7 +26,8 @@ from PySide6.QtWidgets import (
26
26
  QWidget,
27
27
  )
28
28
 
29
- from remotedesktop import db, icon, logs, tls, window_state
29
+ from remotedesktop import __version__, compat, db, icon, logs, tls, window_state
30
+ from remotedesktop.about import AboutTab
30
31
  from remotedesktop.autostart import Autostart
31
32
  from remotedesktop.clipboard import ClipboardSync
32
33
  from remotedesktop.config import PairedClients, Settings, default_config_dir, default_db_path
@@ -55,14 +56,14 @@ class ViewersTable(QTableWidget):
55
56
  """
56
57
 
57
58
  _COLUMNS = [
58
- "Name", "Address", "User", "Computer", "OS", "Send", "Receive",
59
+ "Name", "Address", "User", "Computer", "OS", "Version", "Send", "Receive",
59
60
  "RTT", "RTT mean", "RTT min", "RTT max", "RTT p99", "RTT jitter",
60
61
  ]
61
62
  # Metric cells change text every tick, so their columns get a constant
62
63
  # width (sized to the widest plausible value) instead of
63
64
  # 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
65
+ _RATE_COLUMNS = (6, 7) # Send / Receive
66
+ _MS_COLUMNS = (8, 9, 10, 11, 12, 13) # RTT latest + window statistics
66
67
  _METRIC_COLUMNS = _RATE_COLUMNS + _MS_COLUMNS
67
68
 
68
69
  def __init__(self, share_server, performance: PerformanceMonitor, parent=None) -> None:
@@ -98,6 +99,16 @@ class ViewersTable(QTableWidget):
98
99
  super().showEvent(event)
99
100
  self.refresh() # catch up on metrics accrued while hidden
100
101
 
102
+ @staticmethod
103
+ def _version_cell(version: str) -> str:
104
+ """The viewer's version, flagged when its semver major differs from
105
+ ours (compatibility is only guaranteed within a major)."""
106
+ if not version:
107
+ return "—"
108
+ if compat.mismatch_warning(__version__, version, "client") is not None:
109
+ return f"{version} ⚠"
110
+ return version
111
+
101
112
  def refresh(self) -> None:
102
113
  viewers = self._share_server.viewers()
103
114
  self.setRowCount(len(viewers))
@@ -111,6 +122,7 @@ class ViewersTable(QTableWidget):
111
122
  viewer["user"] or "—",
112
123
  viewer["host"] or "—",
113
124
  viewer["os"] or "—",
125
+ self._version_cell(viewer["app_version"]),
114
126
  format_rate(send) if send is not None else "—",
115
127
  format_rate(recv) if recv is not None else "—",
116
128
  format_ms(rtt) if rtt is not None else "—",
@@ -141,7 +153,7 @@ class ServerWindow(QMainWindow):
141
153
  autostart: Autostart | None = None,
142
154
  ) -> None:
143
155
  super().__init__()
144
- self.setWindowTitle("Remote Desktop Server")
156
+ self.setWindowTitle(f"Remote Desktop Server {__version__}")
145
157
  self.setWindowIcon(icon.app_icon("server"))
146
158
  self._name = socket.gethostname()
147
159
 
@@ -205,6 +217,7 @@ class ServerWindow(QMainWindow):
205
217
  )
206
218
  tabs.addTab(log_tab, "Connection log")
207
219
  tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
220
+ tabs.addTab(AboutTab(), "About")
208
221
  self.setCentralWidget(tabs)
209
222
 
210
223
  if credentials is None:
@@ -34,6 +34,7 @@ from PySide6.QtNetwork import (
34
34
  QTcpSocket,
35
35
  )
36
36
 
37
+ from remotedesktop import __version__, compat
37
38
  from remotedesktop.config import (
38
39
  KnownServers,
39
40
  PairedClients,
@@ -298,7 +299,7 @@ class ShareServer(QObject):
298
299
  info = dict(self._viewer_info.get(stream, {}))
299
300
  info.setdefault("name", "")
300
301
  info.setdefault("address", _peer(stream.socket))
301
- for field in ("user", "host", "os"):
302
+ for field in ("user", "host", "os", "app_version"):
302
303
  info.setdefault(field, "")
303
304
  info["stream"] = stream
304
305
  result.append(info)
@@ -351,7 +352,16 @@ class ShareServer(QObject):
351
352
  "user": str(message.get("user", "")),
352
353
  "host": str(message.get("host", "")),
353
354
  "os": str(message.get("os", "")),
355
+ "app_version": str(message.get("app_version", "")),
354
356
  }
357
+ # Semver policy: majors must match for guaranteed interoperability.
358
+ # A mismatch is loudly reported but never blocks the connection.
359
+ warning = compat.mismatch_warning(
360
+ __version__, self._viewer_info[stream]["app_version"], "client"
361
+ )
362
+ if warning:
363
+ self.status.emit(warning)
364
+ _log.warning("%s", warning)
355
365
  if isinstance(client_id, str) and client_id:
356
366
  self._stream_key[stream] = (client_id, client_name, peer)
357
367
  self._emit_peer(stream, "attempt")
@@ -375,7 +385,15 @@ class ShareServer(QObject):
375
385
  self.status.emit(f'"{client_name}" authenticated with its paired token')
376
386
  self._revoked.discard(client_id)
377
387
  self._emit_peer(stream, "authenticated")
378
- self._admit(stream, client_name, {"type": "welcome", "name": socket_module.gethostname()})
388
+ self._admit(
389
+ stream,
390
+ client_name,
391
+ {
392
+ "type": "welcome",
393
+ "name": socket_module.gethostname(),
394
+ "app_version": __version__,
395
+ },
396
+ )
379
397
  return
380
398
 
381
399
  if existing:
@@ -411,7 +429,12 @@ class ShareServer(QObject):
411
429
  self._admit(
412
430
  stream,
413
431
  client_name,
414
- {"type": "welcome", "name": socket_module.gethostname(), "token": token},
432
+ {
433
+ "type": "welcome",
434
+ "name": socket_module.gethostname(),
435
+ "token": token,
436
+ "app_version": __version__,
437
+ },
415
438
  )
416
439
 
417
440
  def _admit(self, stream: MessageStream, client_name: str, welcome: dict) -> None:
@@ -681,6 +704,7 @@ class ShareClient(QObject):
681
704
  self._server_key = ""
682
705
  self._server_fingerprint = ""
683
706
  self._server_token: str | None = None
707
+ self.server_app_version = ""
684
708
  self._frame_count = 0
685
709
  self._last_image: QImage | None = None # delta patches build on this
686
710
  self._socket = QSslSocket(self)
@@ -707,6 +731,7 @@ class ShareClient(QObject):
707
731
  self._got_first_frame = False
708
732
  self._frame_count = 0
709
733
  self._last_image = None
734
+ self.server_app_version = ""
710
735
  self._server_key = f"{host}:{port}"
711
736
  record = self._known.get(self._server_key) if self._known else None
712
737
  self._server_token = record.get("token") if record else None
@@ -762,6 +787,7 @@ class ShareClient(QObject):
762
787
  "client_id": self._client_id,
763
788
  "name": self._name,
764
789
  "delta": True, # we understand inter-frame delta messages
790
+ "app_version": __version__,
765
791
  **_client_details(),
766
792
  }
767
793
  if self._server_token:
@@ -794,6 +820,9 @@ class ShareClient(QObject):
794
820
  match message.get("type"):
795
821
  case "welcome":
796
822
  name = str(message.get("name", ""))
823
+ # The server's app version, for display next to its name
824
+ # (empty from pre-0.19 servers).
825
+ self.server_app_version = str(message.get("app_version", ""))
797
826
  token = message.get("token")
798
827
  if self._known is not None:
799
828
  if isinstance(token, str):
@@ -0,0 +1,36 @@
1
+ from PySide6.QtWidgets import QLabel
2
+
3
+ from remotedesktop import __version__
4
+ from remotedesktop.about import AboutTab
5
+
6
+
7
+ def tab_text(tab: AboutTab) -> str:
8
+ return "\n".join(label.text() for label in tab.findChildren(QLabel))
9
+
10
+
11
+ def test_about_tab_shows_pypi_metadata(qapp):
12
+ tab = AboutTab()
13
+ text = tab_text(tab)
14
+ assert f"Remote Desktop {__version__}" in text
15
+ assert "MIT" in text
16
+ assert "James Abel" in text
17
+ assert "3.14" in text
18
+ assert "https://github.com/jamesabel/remotedesktop" in text
19
+ assert "https://pypi.org/project/remotedesktop/" in text
20
+ assert "screen, keyboard, mouse, and clipboard" in text
21
+
22
+
23
+ def test_both_windows_have_an_about_tab(qapp, credentials, tmp_path):
24
+ from test_client_window import make_window as make_client_window
25
+ from test_server_window import make_window as make_server_window
26
+
27
+ server_window = make_server_window(credentials, tmp_path)
28
+ client_window = make_client_window(tmp_path)
29
+ try:
30
+ for window in (server_window, client_window):
31
+ tabs = window.centralWidget()
32
+ labels = [tabs.tabText(i) for i in range(tabs.count())]
33
+ assert labels[-1] == "About"
34
+ finally:
35
+ client_window.close()
36
+ server_window.close()
@@ -58,6 +58,9 @@ def test_window_starts_disconnected(qapp, tmp_path):
58
58
  assert "Performance" in labels and "Preferences" in labels
59
59
  assert not window.performance._timer.isActive() # idle: no periodic work
60
60
  assert not window.windowIcon().isNull()
61
+ from remotedesktop import __version__
62
+
63
+ assert window.windowTitle() == f"Remote Desktop Client {__version__}"
61
64
  window.close()
62
65
 
63
66
 
@@ -80,6 +83,10 @@ def test_connect_view_and_disconnect_full_flow(qapp, credentials, tmp_path):
80
83
  pump(qapp, lambda: window.viewer.has_frame)
81
84
  assert window._connected
82
85
  assert "Viewing" in window.statusBar().currentMessage()
86
+ # The welcome carried the server's app version for display.
87
+ from remotedesktop import __version__
88
+
89
+ assert f"({__version__})" in window.statusBar().currentMessage()
83
90
  key = f"127.0.0.1:{server.port}"
84
91
  assert window.inventory._peers[key].state == "connected"
85
92
 
@@ -179,6 +186,31 @@ def test_forget_server_disconnects_and_forgets(qapp, credentials, tmp_path, monk
179
186
  server.close()
180
187
 
181
188
 
189
+ def test_version_mismatch_warns_but_still_connects(qapp, credentials, tmp_path, monkeypatch):
190
+ shown = []
191
+ monkeypatch.setattr(QMessageBox, "show", lambda self: shown.append(self.windowTitle()))
192
+ server = make_share_server(credentials, tmp_path)
193
+ window = make_window(tmp_path)
194
+ try:
195
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
196
+ pump(qapp, lambda: window.viewer.has_frame)
197
+ assert not window._version_mismatch # same build on both ends
198
+ assert shown == []
199
+ # Re-run the connected handler as if the server had reported a
200
+ # different major version.
201
+ assert window._client is not None
202
+ window._client.server_app_version = "99.0.0"
203
+ window._on_connected("box")
204
+ assert window._version_mismatch
205
+ assert shown == ["Version mismatch"] # strong, but non-modal
206
+ assert "WARNING: version mismatch" in window.connection_log.toPlainText()
207
+ assert "99.0.0 ⚠ VERSION MISMATCH" in window.statusBar().currentMessage()
208
+ assert window._connected # the user may still use the connection
209
+ finally:
210
+ window.close()
211
+ server.close()
212
+
213
+
182
214
  def test_discovery_panel_lists_scan_results(qapp, monkeypatch):
183
215
  info = ServerInfo(name="box", host="10.0.0.7", port=1234)
184
216
  monkeypatch.setattr(client_module, "discover_servers", lambda: [info])
@@ -0,0 +1,29 @@
1
+ from remotedesktop import __version__
2
+ from remotedesktop.compat import major_of, mismatch_warning
3
+
4
+
5
+ def test_major_of_parses_semver():
6
+ assert major_of("0.18.0") == 0
7
+ assert major_of("1.2.3") == 1
8
+ assert major_of("10.0.0-rc.1") == 10
9
+ assert major_of("") is None
10
+ assert major_of("banana") is None
11
+ assert major_of("1.2") is None # not full semver
12
+
13
+
14
+ def test_same_major_or_unknown_produces_no_warning():
15
+ assert mismatch_warning("1.2.3", "1.9.0", "server") is None
16
+ assert mismatch_warning("0.18.0", "0.1.0", "client") is None
17
+ assert mismatch_warning("1.0.0", "", "server") is None # pre-0.19 peer
18
+ assert mismatch_warning("", "1.0.0", "server") is None
19
+ assert mismatch_warning(__version__, __version__, "server") is None
20
+
21
+
22
+ def test_major_mismatch_produces_a_strong_warning():
23
+ warning = mismatch_warning("1.2.3", "2.0.0", "server")
24
+ assert warning is not None
25
+ assert "WARNING" in warning
26
+ assert "1.2.3" in warning and "2.0.0" in warning
27
+ assert "server" in warning
28
+ assert "not guaranteed" in warning
29
+ assert "still connect" in warning # the user may attempt anyway
@@ -284,6 +284,9 @@ def test_server_viewers_snapshot_carries_hello_details(qapp, credentials, tmp_pa
284
284
  viewer = viewers[0]
285
285
  assert viewer["name"] and viewer["user"] and viewer["host"]
286
286
  assert viewer["os"].startswith("Windows")
287
+ from remotedesktop import __version__
288
+
289
+ assert viewer["app_version"] == __version__
287
290
  assert "127.0.0.1" in viewer["address"]
288
291
  # The stream key links the snapshot to per-viewer metrics.
289
292
  pump(qapp, lambda: server_perf.metrics_for(viewer["stream"])["rtt_ms"] is not None)
@@ -114,6 +114,9 @@ def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
114
114
  assert "Performance" in labels and "Preferences" in labels
115
115
  assert not window.performance._timer.isActive() # idle: no periodic work
116
116
  assert not window.windowIcon().isNull()
117
+ from remotedesktop import __version__
118
+
119
+ assert window.windowTitle() == f"Remote Desktop Server {__version__}"
117
120
  finally:
118
121
  window.close()
119
122
 
@@ -244,11 +247,14 @@ def test_viewers_table_lists_connected_client_details(qapp, credentials, tmp_pat
244
247
  item = window.viewers_table.item(0, column)
245
248
  assert item is not None
246
249
  row.append(item.text())
247
- name, address, user, host, os_info, send, recv, rtt = row[:8]
248
- # The hello carried this machine's real login/host/OS details.
250
+ name, address, user, host, os_info, version, send, recv, rtt = row[:9]
251
+ # The hello carried this machine's real login/host/OS/version details.
249
252
  assert name and user != "—" and host != "—"
250
253
  assert "127.0.0.1" in address and "::ffff:" not in address
251
254
  assert os_info.startswith("Windows")
255
+ from remotedesktop import __version__
256
+
257
+ assert version == __version__
252
258
  # No monitor tick has necessarily run yet; metrics are dashes or values.
253
259
  assert send and recv and rtt
254
260
  client.close()
@@ -277,7 +283,10 @@ def test_viewers_table_metric_columns_keep_constant_width(qapp):
277
283
 
278
284
  stream = FakeStream()
279
285
  monitor = PerformanceMonitor()
280
- viewer = {"name": "n", "address": "a", "user": "u", "host": "h", "os": "o", "stream": stream}
286
+ viewer = {
287
+ "name": "n", "address": "a", "user": "u", "host": "h", "os": "o",
288
+ "app_version": "1.0", "stream": stream,
289
+ }
281
290
  table = ViewersTable(FakeShareServer([viewer]), monitor)
282
291
  widths = [table.columnWidth(c) for c in ViewersTable._METRIC_COLUMNS]
283
292
  assert all(w > 0 for w in widths)
@@ -295,10 +304,48 @@ def test_viewers_table_metric_columns_keep_constant_width(qapp):
295
304
  rtt_series.add(1234.5)
296
305
  table.refresh()
297
306
  assert [table.columnWidth(c) for c in ViewersTable._METRIC_COLUMNS] == widths
298
- send_item = table.item(0, 5)
307
+ send_item = table.item(0, ViewersTable._RATE_COLUMNS[0])
299
308
  assert send_item is not None and send_item.text() == "250.0 MB/s"
300
309
 
301
310
 
311
+ def test_viewers_table_flags_a_major_version_mismatch(qapp):
312
+ from PySide6.QtCore import QObject, Signal
313
+
314
+ from remotedesktop import __version__
315
+ from remotedesktop.performance import PerformanceMonitor
316
+ from remotedesktop.server import ViewersTable
317
+ from test_performance import FakeStream
318
+
319
+ class FakeShareServer(QObject):
320
+ clientCountChanged = Signal(int)
321
+
322
+ def __init__(self, viewers):
323
+ super().__init__()
324
+ self._viewers = viewers
325
+
326
+ def viewers(self):
327
+ return self._viewers
328
+
329
+ def viewer(version, stream):
330
+ return {
331
+ "name": "n", "address": "a", "user": "u", "host": "h", "os": "o",
332
+ "app_version": version, "stream": stream,
333
+ }
334
+
335
+ same, other, old = FakeStream(), FakeStream(), FakeStream()
336
+ table = ViewersTable(
337
+ FakeShareServer([viewer(__version__, same), viewer("99.0.0", other), viewer("", old)]),
338
+ PerformanceMonitor(),
339
+ )
340
+ column = ViewersTable._COLUMNS.index("Version")
341
+ cells = []
342
+ for row in range(3):
343
+ item = table.item(row, column)
344
+ assert item is not None
345
+ cells.append(item.text())
346
+ assert cells == [__version__, "99.0.0 ⚠", "—"]
347
+
348
+
302
349
  def test_refused_approval_denies_client(qapp, credentials, tmp_path, monkeypatch):
303
350
  stub_approval_prompt(monkeypatch, QMessageBox.StandardButton.No)
304
351
  window = make_window(credentials, tmp_path)
@@ -47,6 +47,32 @@ def make_client(tmp_path, *, clipboard=None, log_provider=None):
47
47
  )
48
48
 
49
49
 
50
+ def test_server_status_reports_version_mismatch(qapp, credentials, tmp_path, monkeypatch):
51
+ # Both ends share this process's __version__, so a real mismatch can't
52
+ # occur here; patch the policy function to prove the hello wiring emits
53
+ # its warning into the status log (and never blocks the connection).
54
+ from remotedesktop import sharing
55
+
56
+ monkeypatch.setattr(
57
+ sharing.compat,
58
+ "mismatch_warning",
59
+ lambda mine, theirs, peer: f"WARNING: version mismatch — test ({peer} {theirs})",
60
+ )
61
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
62
+ statuses = []
63
+ server.status.connect(statuses.append)
64
+ client = make_client(tmp_path)
65
+ connected = []
66
+ client.connected.connect(connected.append)
67
+ client.connect_to("127.0.0.1", server.port)
68
+ try:
69
+ pump(qapp, lambda: connected) # still admitted despite the warning
70
+ assert any("WARNING: version mismatch" in s for s in statuses)
71
+ finally:
72
+ client.close()
73
+ server.close()
74
+
75
+
50
76
  def test_first_connection_prompts_pairs_and_streams(qapp, credentials, tmp_path):
51
77
  prompts = []
52
78
  server = make_server(
@@ -348,6 +348,7 @@ dependencies = [
348
348
  { name = "cryptography" },
349
349
  { name = "platformdirs" },
350
350
  { name = "pyside6" },
351
+ { name = "semver" },
351
352
  ]
352
353
 
353
354
  [package.dev-dependencies]
@@ -362,6 +363,7 @@ requires-dist = [
362
363
  { name = "cryptography", specifier = ">=49.0.0" },
363
364
  { name = "platformdirs", specifier = ">=4.10.0" },
364
365
  { name = "pyside6", specifier = ">=6.11.1" },
366
+ { name = "semver", specifier = ">=3.0" },
365
367
  ]
366
368
 
367
369
  [package.metadata.requires-dev]
@@ -371,6 +373,15 @@ dev = [
371
373
  { name = "pytest-cov", specifier = ">=7.1.0" },
372
374
  ]
373
375
 
376
+ [[package]]
377
+ name = "semver"
378
+ version = "3.0.4"
379
+ source = { registry = "https://pypi.org/simple" }
380
+ sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" }
381
+ wheels = [
382
+ { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" },
383
+ ]
384
+
374
385
  [[package]]
375
386
  name = "shiboken6"
376
387
  version = "6.11.1"
File without changes