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.
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/CLAUDE.md +2 -2
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/PKG-INFO +15 -1
- remotedesktop-0.19.0/docs/media/client-demo.gif +0 -0
- remotedesktop-0.19.0/docs/media/server-demo.gif +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/pyproject.toml +6 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/readme.md +10 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/__init__.py +1 -1
- remotedesktop-0.19.0/src/remotedesktop/about.py +71 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/client.py +34 -4
- remotedesktop-0.19.0/src/remotedesktop/compat.py +38 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/modal_loop.py +52 -8
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/server.py +38 -9
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/sharing.py +32 -3
- remotedesktop-0.19.0/tests/test_about.py +36 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_client_window.py +32 -0
- remotedesktop-0.19.0/tests/test_compat.py +29 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_modal_loop.py +45 -1
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_performance.py +3 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_server_window.py +78 -4
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_sharing.py +26 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tools/make_demo_gifs.py +13 -7
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/uv.lock +11 -0
- remotedesktop-0.17.0/docs/media/client-demo.gif +0 -0
- remotedesktop-0.17.0/docs/media/server-demo.gif +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/.gitignore +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/LICENSE +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/run_claude.bat +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/run_client.bat +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/run_server.bat +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/conftest.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_config.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_input.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.17.0 → remotedesktop-0.19.0}/tests/test_tls.py +0 -0
- {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.
|
|
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
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
)
|
|
@@ -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
|
|
22
|
-
`timers` backend so no
|
|
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
|
-
|
|
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) ->
|
|
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 = (
|
|
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
|
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
@@ -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[:
|
|
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 = {
|
|
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,
|
|
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.
|
|
42
|
-
TOTAL_SECONDS =
|
|
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 = [
|
|
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
|
-
(
|
|
258
|
-
(
|
|
259
|
-
(
|
|
260
|
-
(
|
|
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"
|
|
Binary file
|
|
Binary file
|
|
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
|