remotedesktop 0.17.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 (66) hide show
  1. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/CLAUDE.md +2 -2
  2. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/PKG-INFO +15 -1
  3. remotedesktop-0.19.0/docs/media/client-demo.gif +0 -0
  4. remotedesktop-0.19.0/docs/media/server-demo.gif +0 -0
  5. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/pyproject.toml +6 -0
  6. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/readme.md +10 -0
  7. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/__init__.py +1 -1
  8. remotedesktop-0.19.0/src/remotedesktop/about.py +71 -0
  9. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/client.py +34 -4
  10. remotedesktop-0.19.0/src/remotedesktop/compat.py +38 -0
  11. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/modal_loop.py +52 -8
  12. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/server.py +38 -9
  13. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/sharing.py +32 -3
  14. remotedesktop-0.19.0/tests/test_about.py +36 -0
  15. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_client_window.py +32 -0
  16. remotedesktop-0.19.0/tests/test_compat.py +29 -0
  17. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_modal_loop.py +45 -1
  18. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_performance.py +3 -0
  19. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_server_window.py +78 -4
  20. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_sharing.py +26 -0
  21. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tools/make_demo_gifs.py +13 -7
  22. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/uv.lock +11 -0
  23. remotedesktop-0.17.0/docs/media/client-demo.gif +0 -0
  24. remotedesktop-0.17.0/docs/media/server-demo.gif +0 -0
  25. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/.github/workflows/ci.yml +0 -0
  26. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/.gitignore +0 -0
  27. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/LICENSE +0 -0
  28. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/run_claude.bat +0 -0
  29. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/run_client.bat +0 -0
  30. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/run_server.bat +0 -0
  31. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/scripts/make_coverage_badge.py +0 -0
  32. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/scripts/make_venv.bat +0 -0
  33. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/autostart.py +0 -0
  34. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/clipboard.py +0 -0
  35. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/config.py +0 -0
  36. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/db.py +0 -0
  37. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/discovery.py +0 -0
  38. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/dxgi.py +0 -0
  39. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/frames.py +0 -0
  40. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/icon.py +0 -0
  41. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/input_injection.py +0 -0
  42. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/inventory.py +0 -0
  43. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/logs.py +0 -0
  44. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/performance.py +0 -0
  45. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/preferences.py +0 -0
  46. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/protocol.py +0 -0
  47. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/tls.py +0 -0
  48. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/viewer.py +0 -0
  49. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/window_state.py +0 -0
  50. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/conftest.py +0 -0
  51. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_autostart.py +0 -0
  52. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_clipboard.py +0 -0
  53. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_config.py +0 -0
  54. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_discovery.py +0 -0
  55. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_dxgi.py +0 -0
  56. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_frames.py +0 -0
  57. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_icon.py +0 -0
  58. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_input.py +0 -0
  59. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_input_injection.py +0 -0
  60. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_inventory.py +0 -0
  61. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_logs.py +0 -0
  62. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_preferences.py +0 -0
  63. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_protocol.py +0 -0
  64. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_smoke.py +0 -0
  65. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_tls.py +0 -0
  66. {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_window_state.py +0 -0
@@ -47,13 +47,13 @@ Managed with `uv` (hatchling build backend, src layout):
47
47
  - **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; the callback gets the selected peer's key. The server's tab wires "Revoke access" → `ShareServer.revoke_client(client_id)` (removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` reports state "revoked"; a `_revoked` set distinguishes it from a plain disconnect). The client's tab wires "Forget server" → `KnownServers.forget(key)` + disconnect. After either, the next connection needs a fresh approval. Server and client each use a *separate table* (`server_peers` / `client_peers`, selected via the `table=` arg) so a machine running both apps doesn't commingle them; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
48
48
  - Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
49
49
  - **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
50
- - **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for the server this deadlocked when a *remote* click grabbed the server window's own title bar (the mouse-up sat unread on the socket). `ServerWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). Tests inject a fake `timers` backend — never let them create real Win32 timers.
50
+ - **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for the server this deadlocked when a *remote* click grabbed the server window's own title bar (the mouse-up sat unread on the socket). `ServerWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). The caption buttons (min/max/close) are the exception — their tracking loop dispatches only mouse messages, so no timer can pump inside it; the pump instead performs those via its `caption_action` callback on press and consumes the message (nativeEvent must return True when `handle_native_event` does), so DefWindowProc's button tracking never starts. Tests inject a fake `timers` backend — never let them create real Win32 timers.
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
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.17.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.17.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
+ )
@@ -17,9 +17,18 @@ callback pumps Qt events (user input excluded) every few milliseconds. A
17
17
  side benefit: screen sharing keeps streaming while the local user drags
18
18
  the server window.
19
19
 
20
+ The caption buttons (minimize/maximize/close) are the exception: their
21
+ tracking loop dispatches only mouse messages, so no timer — and therefore
22
+ no pump — can run inside it, and a remote click on the server's own
23
+ minimize button deadlocked just like the title-bar drag once did. Those
24
+ presses are instead handled here directly (`caption_action` callback) and
25
+ the message is consumed, so DefWindowProc's button tracking never starts:
26
+ the action happens on press, for local and remote clicks alike.
27
+
20
28
  Feed every message from the window's `nativeEvent` to
21
- `handle_native_event`; the pump is inert off Windows. Tests inject a fake
22
- `timers` backend so no native timer is ever created.
29
+ `handle_native_event` and consume the message when it returns True; the
30
+ pump is inert off Windows. Tests inject a fake `timers` backend so no
31
+ native timer is ever created.
23
32
  """
24
33
 
25
34
  import ctypes
@@ -33,9 +42,15 @@ from PySide6.QtCore import QCoreApplication, QEventLoop
33
42
  _log = logging.getLogger("remotedesktop.modal_loop")
34
43
 
35
44
  WM_NCLBUTTONDOWN = 0x00A1
45
+ WM_NCLBUTTONUP = 0x00A2
36
46
  WM_CAPTURECHANGED = 0x0215
37
47
  WM_ENTERSIZEMOVE = 0x0231
38
48
  WM_EXITSIZEMOVE = 0x0232
49
+ # WM_NCLBUTTONDOWN/-UP hit-test codes for the caption buttons.
50
+ HTMINBUTTON = 8
51
+ HTMAXBUTTON = 9
52
+ HTCLOSE = 20
53
+ _CAPTION_BUTTONS = (HTMINBUTTON, HTMAXBUTTON, HTCLOSE)
39
54
  _TIMER_ID = 0x5244 # arbitrary but stable; scoped to the window's hwnd
40
55
  _TIMER_INTERVAL_MS = 15 # comfortably under the 33 ms capture tick
41
56
 
@@ -65,21 +80,49 @@ class _NativeTimers:
65
80
 
66
81
  class ModalLoopPump:
67
82
  """Runs a native timer that pumps Qt events while a window of ours sits
68
- in the modal move/size loop."""
69
-
70
- def __init__(self, *, pump: Callable[[], None] | None = None, timers=None) -> None:
83
+ in the modal move/size loop, and short-circuits caption-button clicks
84
+ (whose tracking loop the timer cannot penetrate) to `caption_action`."""
85
+
86
+ def __init__(
87
+ self,
88
+ *,
89
+ pump: Callable[[], None] | None = None,
90
+ caption_action: Callable[[int], None] | None = None,
91
+ timers=None,
92
+ ) -> None:
71
93
  self._pump = pump if pump is not None else _pump_qt
94
+ self._caption_action = caption_action
72
95
  if timers is None and sys.platform == "win32":
73
96
  timers = _NativeTimers(self._on_timer)
74
97
  self._timers = timers # None: inert (non-Windows)
75
98
  self._hwnd: int | None = None
76
99
  self._pumping = False
77
100
 
78
- def handle_native_event(self, event_type, message) -> None:
79
- """Call from QWidget.nativeEvent with its arguments verbatim."""
101
+ def handle_native_event(self, event_type, message) -> bool:
102
+ """Call from QWidget.nativeEvent with its arguments verbatim.
103
+
104
+ Returns True when the message was handled here and must be consumed
105
+ (returned True from nativeEvent) instead of reaching DefWindowProc.
106
+ """
80
107
  if self._timers is None or bytes(event_type) != b"windows_generic_MSG":
81
- return
108
+ return False
82
109
  msg = wintypes.MSG.from_address(int(message))
110
+ # Minimize/maximize/close presses must never reach DefWindowProc:
111
+ # its caption-button tracking loop dispatches only mouse messages,
112
+ # so no timer can pump Qt inside it, and a remote press deadlocks
113
+ # exactly like the title-bar drag once did. Perform the action on
114
+ # press and swallow the message (the matching -UP too).
115
+ if (
116
+ self._caption_action is not None
117
+ and msg.message in (WM_NCLBUTTONDOWN, WM_NCLBUTTONUP)
118
+ and msg.wParam in _CAPTION_BUTTONS
119
+ ):
120
+ if msg.message == WM_NCLBUTTONDOWN:
121
+ _log.debug(
122
+ "Caption button %d pressed — handled without native tracking", msg.wParam
123
+ )
124
+ self._caption_action(int(msg.wParam))
125
+ return True
83
126
  # WM_ENTERSIZEMOVE alone is not enough: a press-and-hold on the title
84
127
  # bar that never moves blocks the thread inside DefWindowProc's click
85
128
  # tracking WITHOUT ever sending WM_ENTERSIZEMOVE, so the pump must
@@ -90,6 +133,7 @@ class ModalLoopPump:
90
133
  self._enter(msg.hWnd or 0)
91
134
  elif msg.message in (WM_CAPTURECHANGED, WM_EXITSIZEMOVE):
92
135
  self._exit()
136
+ return False
93
137
 
94
138
  def _enter(self, hwnd: int) -> None:
95
139
  if self._hwnd == hwnd: # NCLBUTTONDOWN then ENTERSIZEMOVE: already armed
@@ -7,7 +7,7 @@ import sqlite3
7
7
  import sys
8
8
  import time
9
9
 
10
- from PySide6.QtCore import QProcess, Qt
10
+ from PySide6.QtCore import QProcess, Qt, QTimer
11
11
  from PySide6.QtGui import QCloseEvent, QShowEvent
12
12
  from PySide6.QtWidgets import (
13
13
  QAbstractItemView,
@@ -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
@@ -37,7 +38,7 @@ from remotedesktop.discovery import (
37
38
  )
38
39
  from remotedesktop.inventory import ConnectionInventory, InventoryTab
39
40
  from remotedesktop.logs import PeerLogDialog, read_log_tail
40
- from remotedesktop.modal_loop import ModalLoopPump
41
+ from remotedesktop.modal_loop import HTCLOSE, HTMAXBUTTON, HTMINBUTTON, ModalLoopPump
41
42
  from remotedesktop.performance import PerformanceMonitor, PerformanceTab, format_ms, format_rate
42
43
  from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
43
44
  from remotedesktop.sharing import ShareServer
@@ -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
 
@@ -167,7 +179,9 @@ class ServerWindow(QMainWindow):
167
179
  # drag — including one driven by an injected remote click), Qt stops
168
180
  # running; the pump keeps sockets and timers serviced so a remote
169
181
  # mouse-up can still arrive and end the drag instead of deadlocking.
170
- self._modal_pump = ModalLoopPump()
182
+ # Caption-button presses (minimize/maximize/close) are handled by
183
+ # the pump directly — their native tracking loop cannot be pumped.
184
+ self._modal_pump = ModalLoopPump(caption_action=self._on_caption_button)
171
185
  self.restart_button = QPushButton("Restart server")
172
186
  self.restart_button.setToolTip(
173
187
  "Relaunch this app (e.g. after updating the software). It can be "
@@ -203,6 +217,7 @@ class ServerWindow(QMainWindow):
203
217
  )
204
218
  tabs.addTab(log_tab, "Connection log")
205
219
  tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
220
+ tabs.addTab(AboutTab(), "About")
206
221
  self.setCentralWidget(tabs)
207
222
 
208
223
  if credentials is None:
@@ -339,8 +354,22 @@ class ServerWindow(QMainWindow):
339
354
  title = f'Log from client "{client_name}"' if client_name else "Log from client"
340
355
  PeerLogDialog(title, text, self).show()
341
356
 
357
+ def _on_caption_button(self, hit_code: int) -> None:
358
+ # Deferred: the action (especially close) must not run inside the
359
+ # native message handler that reported the press.
360
+ if hit_code == HTMINBUTTON:
361
+ action = self.showMinimized
362
+ elif hit_code == HTMAXBUTTON:
363
+ action = self.showNormal if self.isMaximized() else self.showMaximized
364
+ elif hit_code == HTCLOSE:
365
+ action = self.close
366
+ else:
367
+ return
368
+ QTimer.singleShot(0, action)
369
+
342
370
  def nativeEvent(self, event_type, message):
343
- self._modal_pump.handle_native_event(event_type, message)
371
+ if self._modal_pump.handle_native_event(event_type, message):
372
+ return True, 0
344
373
  return super().nativeEvent(event_type, message)
345
374
 
346
375
  def _ask_approval(self, client_id: str, client_name: str) -> bool:
@@ -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
@@ -8,10 +8,13 @@ import ctypes
8
8
  from ctypes import wintypes
9
9
 
10
10
  from remotedesktop.modal_loop import (
11
+ HTMAXBUTTON,
12
+ HTMINBUTTON,
11
13
  WM_CAPTURECHANGED,
12
14
  WM_ENTERSIZEMOVE,
13
15
  WM_EXITSIZEMOVE,
14
16
  WM_NCLBUTTONDOWN,
17
+ WM_NCLBUTTONUP,
15
18
  ModalLoopPump,
16
19
  )
17
20
 
@@ -27,14 +30,19 @@ class FakeTimers:
27
30
  self.calls.append(("stop", hwnd))
28
31
 
29
32
 
30
- def native_message(message_id, hwnd=0xBEEF):
33
+ def native_message(message_id, hwnd=0xBEEF, wparam=0):
31
34
  """A wintypes.MSG whose address stands in for Qt's nativeEvent pointer."""
32
35
  msg = wintypes.MSG()
33
36
  msg.hWnd = hwnd
34
37
  msg.message = message_id
38
+ msg.wParam = wparam
35
39
  return msg
36
40
 
37
41
 
42
+ def send(pump, msg):
43
+ return pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(msg))
44
+
45
+
38
46
  def test_enter_and_exit_bound_the_timer():
39
47
  timers = FakeTimers()
40
48
  pump = ModalLoopPump(pump=lambda: None, timers=timers)
@@ -102,6 +110,42 @@ def test_capture_change_without_press_is_ignored():
102
110
  assert timers.calls == []
103
111
 
104
112
 
113
+ def test_caption_button_press_is_consumed_and_dispatched():
114
+ # Min/max/close tracking cannot be pumped (it dispatches only mouse
115
+ # messages), so the press must be handled directly and swallowed —
116
+ # the native tracking loop must never start.
117
+ timers = FakeTimers()
118
+ actions = []
119
+ pump = ModalLoopPump(pump=lambda: None, caption_action=actions.append, timers=timers)
120
+ assert send(pump, native_message(WM_NCLBUTTONDOWN, wparam=HTMINBUTTON)) is True
121
+ assert actions == [HTMINBUTTON]
122
+ assert timers.calls == [] # no tracking, so nothing to pump
123
+ assert send(pump, native_message(WM_NCLBUTTONUP, wparam=HTMINBUTTON)) is True
124
+ assert actions == [HTMINBUTTON] # the release performs nothing extra
125
+ assert send(pump, native_message(WM_NCLBUTTONDOWN, wparam=HTMAXBUTTON)) is True
126
+ assert actions == [HTMINBUTTON, HTMAXBUTTON]
127
+
128
+
129
+ def test_title_bar_press_still_arms_the_pump_not_the_caption_action():
130
+ timers = FakeTimers()
131
+ actions = []
132
+ pump = ModalLoopPump(pump=lambda: None, caption_action=actions.append, timers=timers)
133
+ # HTCAPTION (2): a title-bar press is not a caption button — the pump
134
+ # arms as before and the message passes through to DefWindowProc.
135
+ assert send(pump, native_message(WM_NCLBUTTONDOWN, wparam=2)) is False
136
+ assert actions == []
137
+ assert timers.calls == [("start", 0xBEEF)]
138
+
139
+
140
+ def test_caption_buttons_pass_through_without_a_caption_action():
141
+ timers = FakeTimers()
142
+ pump = ModalLoopPump(pump=lambda: None, timers=timers)
143
+ # No handler installed: behave exactly as before (arm the pump, don't
144
+ # consume) so the buttons keep working for pump users without one.
145
+ assert send(pump, native_message(WM_NCLBUTTONDOWN, wparam=HTMINBUTTON)) is False
146
+ assert timers.calls == [("start", 0xBEEF)]
147
+
148
+
105
149
  def test_timer_callback_pumps_but_never_reentrantly():
106
150
  pumped = []
107
151
  pump = ModalLoopPump(pump=lambda: pumped.append(True) or pump._on_timer(), timers=FakeTimers())
@@ -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)
@@ -75,6 +75,33 @@ def test_native_size_move_messages_drive_the_modal_pump(qapp, credentials, tmp_p
75
75
  window.close()
76
76
 
77
77
 
78
+ def test_caption_button_press_minimizes_without_native_tracking(qapp, credentials, tmp_path):
79
+ import ctypes
80
+ from ctypes import wintypes
81
+
82
+ from shiboken6 import VoidPtr
83
+
84
+ from remotedesktop.modal_loop import HTMINBUTTON, WM_NCLBUTTONDOWN, ModalLoopPump
85
+ from test_modal_loop import FakeTimers
86
+
87
+ window = make_window(credentials, tmp_path)
88
+ try:
89
+ timers = FakeTimers()
90
+ window._modal_pump = ModalLoopPump(
91
+ pump=lambda: None, caption_action=window._on_caption_button, timers=timers
92
+ )
93
+ msg = wintypes.MSG()
94
+ msg.hWnd, msg.message, msg.wParam = 0xBEEF, WM_NCLBUTTONDOWN, HTMINBUTTON
95
+ result = window.nativeEvent(b"windows_generic_MSG", VoidPtr(ctypes.addressof(msg)))
96
+ assert result[0] is True # consumed: DefWindowProc's tracking never starts
97
+ assert timers.calls == [] # and the pump never needed to arm
98
+ for _ in range(5):
99
+ qapp.processEvents() # the deferred action runs on the event loop
100
+ assert window.isMinimized()
101
+ finally:
102
+ window.close()
103
+
104
+
78
105
  def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
79
106
  window = make_window(credentials, tmp_path)
80
107
  try:
@@ -87,6 +114,9 @@ def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
87
114
  assert "Performance" in labels and "Preferences" in labels
88
115
  assert not window.performance._timer.isActive() # idle: no periodic work
89
116
  assert not window.windowIcon().isNull()
117
+ from remotedesktop import __version__
118
+
119
+ assert window.windowTitle() == f"Remote Desktop Server {__version__}"
90
120
  finally:
91
121
  window.close()
92
122
 
@@ -217,11 +247,14 @@ def test_viewers_table_lists_connected_client_details(qapp, credentials, tmp_pat
217
247
  item = window.viewers_table.item(0, column)
218
248
  assert item is not None
219
249
  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.
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.
222
252
  assert name and user != "—" and host != "—"
223
253
  assert "127.0.0.1" in address and "::ffff:" not in address
224
254
  assert os_info.startswith("Windows")
255
+ from remotedesktop import __version__
256
+
257
+ assert version == __version__
225
258
  # No monitor tick has necessarily run yet; metrics are dashes or values.
226
259
  assert send and recv and rtt
227
260
  client.close()
@@ -250,7 +283,10 @@ def test_viewers_table_metric_columns_keep_constant_width(qapp):
250
283
 
251
284
  stream = FakeStream()
252
285
  monitor = PerformanceMonitor()
253
- 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
+ }
254
290
  table = ViewersTable(FakeShareServer([viewer]), monitor)
255
291
  widths = [table.columnWidth(c) for c in ViewersTable._METRIC_COLUMNS]
256
292
  assert all(w > 0 for w in widths)
@@ -268,10 +304,48 @@ def test_viewers_table_metric_columns_keep_constant_width(qapp):
268
304
  rtt_series.add(1234.5)
269
305
  table.refresh()
270
306
  assert [table.columnWidth(c) for c in ViewersTable._METRIC_COLUMNS] == widths
271
- send_item = table.item(0, 5)
307
+ send_item = table.item(0, ViewersTable._RATE_COLUMNS[0])
272
308
  assert send_item is not None and send_item.text() == "250.0 MB/s"
273
309
 
274
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
+
275
349
  def test_refused_approval_denies_client(qapp, credentials, tmp_path, monkeypatch):
276
350
  stub_approval_prompt(monkeypatch, QMessageBox.StandardButton.No)
277
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(
@@ -38,8 +38,9 @@ from remotedesktop import client as client_module
38
38
 
39
39
  SCREEN_W, SCREEN_H = 1280, 800
40
40
  GIF_WIDTH = 900
41
- CAPTURE_INTERVAL = 0.15 # seconds per frame, also the GIF frame duration
42
- TOTAL_SECONDS = 11.0
41
+ CAPTURE_INTERVAL = 0.18 # seconds per frame, also the GIF frame duration
42
+ TOTAL_SECONDS = 26.5
43
+ _GIF_COLORS = 96 # adaptive-palette size; the UI + fake desktop quantize well
43
44
  _CODE_LINES = [
44
45
  "def changed_bands(previous, current):",
45
46
  ' """Bands of `current` that differ from `previous`."""',
@@ -190,7 +191,9 @@ def to_gif_frame(window) -> Image.Image:
190
191
 
191
192
 
192
193
  def save_gif(frames: list[Image.Image], path: Path) -> None:
193
- quantized = [f.convert("P", palette=Image.Palette.ADAPTIVE, colors=128) for f in frames]
194
+ quantized = [
195
+ f.convert("P", palette=Image.Palette.ADAPTIVE, colors=_GIF_COLORS) for f in frames
196
+ ]
194
197
  quantized[0].save(
195
198
  path,
196
199
  save_all=True,
@@ -251,13 +254,16 @@ def main() -> None:
251
254
  server_tabs = server_window.centralWidget()
252
255
  assert isinstance(client_tabs, QTabWidget) and isinstance(server_tabs, QTabWidget)
253
256
 
257
+ # The live remote-screen section is the product's whole point, so it
258
+ # gets the lion's share of the runtime (~19 s of streaming before
259
+ # the brief Performance-tab detour, plus the closing shot).
254
260
  script = [
255
261
  (0.8, lambda: client_window.discovery_panel.refresh()),
256
262
  (2.0, lambda: client_window.discovery_panel.serverActivated.emit(info)),
257
- (6.8, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Performance"))),
258
- (7.2, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Performance"))),
259
- (9.2, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Remote Screen"))),
260
- (9.6, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Status"))),
263
+ (21.2, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Performance"))),
264
+ (21.6, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Performance"))),
265
+ (23.6, lambda: client_tabs.setCurrentIndex(tab_index(client_tabs, "Remote Screen"))),
266
+ (24.0, lambda: server_tabs.setCurrentIndex(tab_index(server_tabs, "Status"))),
261
267
  ]
262
268
  client_frames: list[Image.Image] = []
263
269
  server_frames: list[Image.Image] = []
@@ -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