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.
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/CLAUDE.md +1 -1
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/PKG-INFO +15 -1
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/pyproject.toml +6 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/readme.md +10 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/__init__.py +1 -1
- remotedesktop-0.19.0/src/remotedesktop/about.py +71 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/client.py +34 -4
- remotedesktop-0.19.0/src/remotedesktop/compat.py +38 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/server.py +18 -5
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/sharing.py +32 -3
- remotedesktop-0.19.0/tests/test_about.py +36 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_client_window.py +32 -0
- remotedesktop-0.19.0/tests/test_compat.py +29 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_performance.py +3 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_server_window.py +51 -4
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_sharing.py +26 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/uv.lock +11 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/.gitignore +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/LICENSE +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/docs/media/client-demo.gif +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/docs/media/server-demo.gif +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/run_claude.bat +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/run_client.bat +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/run_server.bat +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/conftest.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_config.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_input.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.18.0 → remotedesktop-0.19.0}/tests/test_window_state.py +0 -0
- {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.
|
|
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
|
|
@@ -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("<", "<").replace(">", ">")
|
|
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> ·
|
|
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.
|
|
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.
|
|
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 = (
|
|
65
|
-
_MS_COLUMNS = (
|
|
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(
|
|
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
|
-
{
|
|
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[:
|
|
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 = {
|
|
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,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|