remotedesktop 0.18.0__tar.gz → 0.20.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/CLAUDE.md +2 -2
  2. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/PKG-INFO +75 -4
  3. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/pyproject.toml +6 -0
  4. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/readme.md +70 -3
  5. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/__init__.py +1 -1
  6. remotedesktop-0.20.0/src/remotedesktop/about.py +71 -0
  7. remotedesktop-0.20.0/src/remotedesktop/client.py +452 -0
  8. remotedesktop-0.20.0/src/remotedesktop/compat.py +38 -0
  9. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/server.py +18 -5
  10. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/sharing.py +32 -3
  11. remotedesktop-0.20.0/tests/test_about.py +36 -0
  12. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_client_window.py +122 -18
  13. remotedesktop-0.20.0/tests/test_compat.py +29 -0
  14. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_performance.py +3 -0
  15. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_server_window.py +51 -4
  16. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_sharing.py +26 -0
  17. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_smoke.py +2 -2
  18. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tools/make_demo_gifs.py +1 -1
  19. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/uv.lock +11 -0
  20. remotedesktop-0.18.0/src/remotedesktop/client.py +0 -305
  21. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/.github/workflows/ci.yml +0 -0
  22. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/.gitignore +0 -0
  23. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/LICENSE +0 -0
  24. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/docs/media/client-demo.gif +0 -0
  25. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/docs/media/server-demo.gif +0 -0
  26. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/run_claude.bat +0 -0
  27. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/run_client.bat +0 -0
  28. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/run_server.bat +0 -0
  29. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/scripts/make_coverage_badge.py +0 -0
  30. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/scripts/make_venv.bat +0 -0
  31. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/autostart.py +0 -0
  32. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/clipboard.py +0 -0
  33. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/config.py +0 -0
  34. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/db.py +0 -0
  35. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/discovery.py +0 -0
  36. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/dxgi.py +0 -0
  37. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/frames.py +0 -0
  38. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/icon.py +0 -0
  39. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/input_injection.py +0 -0
  40. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/inventory.py +0 -0
  41. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/logs.py +0 -0
  42. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/modal_loop.py +0 -0
  43. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/performance.py +0 -0
  44. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/preferences.py +0 -0
  45. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/protocol.py +0 -0
  46. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/tls.py +0 -0
  47. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/viewer.py +0 -0
  48. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/src/remotedesktop/window_state.py +0 -0
  49. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/conftest.py +0 -0
  50. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_autostart.py +0 -0
  51. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_clipboard.py +0 -0
  52. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_config.py +0 -0
  53. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_discovery.py +0 -0
  54. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_dxgi.py +0 -0
  55. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_frames.py +0 -0
  56. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_icon.py +0 -0
  57. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_input.py +0 -0
  58. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_input_injection.py +0 -0
  59. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_inventory.py +0 -0
  60. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_logs.py +0 -0
  61. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_modal_loop.py +0 -0
  62. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_preferences.py +0 -0
  63. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_protocol.py +0 -0
  64. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_tls.py +0 -0
  65. {remotedesktop-0.18.0 → remotedesktop-0.20.0}/tests/test_window_state.py +0 -0
@@ -36,7 +36,7 @@ Managed with `uv` (hatchling build backend, src layout):
36
36
  ## Architecture
37
37
 
38
38
  - Both apps are PySide6 GUI applications. `src/remotedesktop/client.py` (`ClientWindow`) and `src/remotedesktop/server.py` (`ServerWindow`) hold the app entry points; `main()` in each is wired to the `remotedesktop-client` / `remotedesktop-server` GUI scripts in `pyproject.toml`.
39
- - The client-side remote desktop view is a widget, `ViewerWidget` in `src/remotedesktop/viewer.py`, hosted as `ClientWindow`'s central widget. Screen display and keyboard/mouse/clipboard forwarding belong in this widget, not in the window.
39
+ - The client-side remote desktop view is a widget, `ViewerWidget` in `src/remotedesktop/viewer.py`. Screen display and keyboard/mouse/clipboard forwarding belong in this widget, not in the window. **The client supports multiple simultaneous server connections**: each is a `ServerSession` in `client.py` (its own `ShareClient` + `ViewerWidget`), shown as a closable tab at the front of `ClientWindow`'s central `QTabWidget`, titled with the server's reported hostname (fixed tabs get their close buttons stripped). Sessions are keyed by `host:port`; activating an already-known server reconnects in its existing tab, activating a connected one just focuses it. The window title lists the connected server names (`_update_window_title`), and the status bar follows the currently selected session tab (`status_text` per session). The `PerformanceMonitor`, `ClipboardSync`, inventory, and Connection log are shared across sessions — the monitor aggregates all attached streams, and `performance.reset()` is only called when no *other* session is connected (resetting detaches every stream). Per-session status log lines are prefixed `[name]`.
40
40
  - **Autodiscovery** (`src/remotedesktop/discovery.py`) is a stdlib-only UDP probe/response protocol, deliberately not mDNS: the client broadcasts a JSON probe to `DISCOVERY_PORT` (48653) and servers reply with `{name, port}`; datagrams with the wrong magic/version/type are dropped. The server runs a `DiscoveryResponder` thread while its window is open; the client's `DiscoveryPanel` (dock in `ClientWindow`) calls the blocking `discover_servers()` on a worker thread and delivers results to the GUI via a queued signal. `DEFAULT_CONNECT_PORT` (48654) is reserved for the future desktop connection.
41
41
  - Discovery tests run over loopback with ephemeral ports (`bind_host`/`discovery_port`/`broadcast_hosts` parameters exist for this), so they never touch the real LAN or fixed ports.
42
42
  - **Screen sharing** (`sharing.py` + `protocol.py` + `frames.py`) runs entirely on the Qt event loop — no threads or locks. `MessageStream` frames messages over a QTcpSocket (4-byte length + kind byte; JSON control messages, full frames, or inter-frame deltas; malformed input aborts the socket). `ShareServer` owns one QTimer that grabs the primary screen at full resolution via `_capture()` (a seam tests override to drive deterministic frame content) and fans out to all clients, skipping clients whose send buffer is backlogged; the timer only runs while clients are connected. Capture is DXGI desktop duplication (`dxgi.py`, hand-rolled ctypes COM — ~10 ms per changed 4K frame, ~0 when idle, and "unchanged" is signalled by returning the same QImage object so the diff is skipped) with automatic fallback to `QScreen.grabWindow` (~96 ms) whenever duplication is unavailable or lost (secure desktop, RDP, display-mode change, CI); unit tests fake the duplication (`FakeDuplication` in `tests/test_dxgi.py`) and the real-hardware test skips where DXGI is unavailable. **Frame transfer is lossless and inter-frame compressed** (`frames.py`): consecutive captures are diffed in 64-row bands (memoryview compares), and only changed bands ship as PNG in a delta payload the client patches onto its previous frame; an unchanged screen sends nothing. Full PNG keyframes go to clients that just joined, just recovered from a send backlog, or requested one (a `{"type": "keyframe"}` message — sent when a delta fails to apply, so desync self-heals). The capability is negotiated via `"delta": true` in the client hello; clients without it (0.5.0) get full JPEG frames every tick because they force-decode frames as JPEG — never send them PNG. Each variant (legacy JPEG / keyframe PNG / delta) is encoded at most once per tick and shared across clients. `ShareClient` decodes/patches to QImage for the viewer, which displays it scaled with a cached `SmoothTransformation` copy (full resolution is always delivered; scaling happens client-side only).
@@ -53,7 +53,7 @@ Managed with `uv` (hatchling build backend, src layout):
53
53
  - **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** — a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
54
54
  - **Performance monitoring** (`performance.py` + `preferences.py`): `PerformanceMonitor` is an opt-in collaborator (`performance=` on `ShareServer`/`ShareClient`, the `clipboard=` pattern — plain sharing tests leave it off). While admitted streams are attached, a 1 s QTimer samples `MessageStream.bytes_sent/bytes_received` counters into rolling `MetricSeries` (bandwidth, aggregated across streams via per-stream baselines) and pings **every** attached stream (each ping id maps to its stream; a pong is honored only on the stream it was sent to). The graphs' RTT series follow only the *active* (most recently admitted) stream; per-stream send/recv/RTT snapshots come from `metrics_for(stream)` and feed the server's Status-tab `ViewersTable` (`server.py`), which joins them with `ShareServer.viewers()` — identity fields (`user`/`host`/`os`) that the client hello reports (`_client_details()`; missing on old clients, shown as dashes). The ping is: `{"type": "ping", "id", "rtt"}` / `{"type": "pong", "id"}` — each side measures its own RTT and piggybacks it on its next ping so both ends graph both directions; peers without the feature ignore pings harmlessly (RTT graphs show "no data"). Ping/pong is admitted-streams-only on the server, like input/clipboard. Both windows have a "Performance" tab (`PerformanceTab`, custom QPainter graphs) that schedules **no paint work while hidden** (gated on `isVisible()`, repaint on `showEvent`) and a "Preferences" tab whose history-window setting persists via the `Settings` store (`performance_window_seconds`, default 120). The monitor's timer runs only while streams are attached; all timing is `time.monotonic()` with an injectable `clock=` for tests — never monkeypatch `time.monotonic` (the test `pump()` helper depends on it).
55
55
  - **Clipboard sync is bidirectional** (`clipboard.py` `ClipboardSync`): client copy → server and server copy → all clients, as `{"type": "clipboard", "text"/"image_png"}` messages. It's opt-in — `ShareServer`/`ShareClient` take a `clipboard=` param and do nothing with clipboard if it's None, so only the GUIs (and clipboard tests) enable it; **plain sharing/input tests must leave it off to avoid touching the real OS clipboard.** Echo/loop prevention is by *content signature*, not just a guard flag, because Windows fires `QClipboard.dataChanged` asynchronously (after a flag would be cleared); image signatures hash canonical RGBA pixels so a re-encoded PNG round trip doesn't loop. Server only accepts clipboard from streams past the approval handshake. Tests use a `FakeClipboard(QObject)` with a `changed` signal and recording `apply()` so two independent clipboards exist in one process.
56
- - The package version lives only in `src/remotedesktop/__init__.py` (`__version__`); hatchling reads it from there (`[tool.hatch.version]`), so bump it in that one place.
56
+ - The package version lives only in `src/remotedesktop/__init__.py` (`__version__`); hatchling reads it from there (`[tool.hatch.version]`), so bump it in that one place. **Versioning is strict semver** (`compat.py`, using the `semver` package): major = client/server compatibility contract, minor = backward-compatible features, patch = bug fixes — bump the major when the wire protocol or behavior breaks older peers. Both sides compare majors on every connection (hello/welcome carry `app_version`) and show a strong warning on mismatch (server status log + ⚠ in the viewers table's Version column; client log + non-modal dialog + status-bar marker) but never block the connection. Peers too old to report a version produce no warning.
57
57
  - Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
58
58
 
59
59
  ## Environment Notes
@@ -1,7 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.18.0
3
+ Version: 0.20.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
@@ -43,9 +47,11 @@ Microsoft accounts, no cloud: just two apps and your LAN.
43
47
  ![Client demo](https://raw.githubusercontent.com/jamesabel/remotedesktop/master/docs/media/client-demo.gif)
44
48
 
45
49
  The client discovers the server on the LAN, connects, and streams its
46
- desktop live in the *Remote Screen* tab — click into the view and your mouse
47
- and keyboard control the remote machine. The *Performance* tab graphs
48
- bandwidth and round-trip time with live statistics.
50
+ desktop live in a tab named after that computer — click into the view and
51
+ your mouse and keyboard control the remote machine. Connect to several
52
+ servers at once and each gets its own tab; the window title lists every
53
+ connected computer. The *Performance* tab graphs bandwidth and round-trip
54
+ time with live statistics.
49
55
 
50
56
  ### Server
51
57
 
@@ -58,6 +64,7 @@ time with mean/min/max/p99/jitter over the recent window).
58
64
  ## Features
59
65
 
60
66
  - 🔍 **Autodiscovery** — servers announce themselves over UDP; the client lists every server on the LAN, no addresses to type.
67
+ - 🗂️ **Multiple servers at once** — one client can view and control several servers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
61
68
  - 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
62
69
  - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys.
63
70
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other.
@@ -111,6 +118,70 @@ handshake messages until the server user admits them; access can be revoked
111
118
  at any time from the server's *Clients on LAN* tab. There is no dependency
112
119
  on Windows RDP or any Microsoft-based authentication.
113
120
 
121
+ ## How it works
122
+
123
+ All of this is pure Python — the GUIs are PySide6 (Qt), and the two places
124
+ that need to talk to Windows directly (screen capture and input injection)
125
+ call the Win32/COM APIs through `ctypes`, so there are no native extensions
126
+ to compile.
127
+
128
+ **Screen capture.** The server grabs the desktop with the **DXGI desktop
129
+ duplication API**, driven directly through `ctypes` COM calls. Desktop
130
+ duplication is the mechanism Windows provides for exactly this job: the
131
+ compositor hands over a GPU texture of the screen and tells you whether
132
+ anything changed, so a changed 4K frame costs about 10 ms to read back and
133
+ an idle screen costs essentially nothing. When duplication is unavailable
134
+ or gets lost — the secure desktop (UAC/logon screen), an RDP session, a
135
+ display-mode change — the server transparently falls back to Qt's
136
+ `QScreen.grabWindow` (~96 ms per frame) and keeps retrying duplication in
137
+ the background.
138
+
139
+ **Screen transfer.** Frames are captured at up to 30 fps and compared with
140
+ the previous capture in 64-row bands; only the bands that changed are
141
+ encoded — losslessly, as PNG — and sent as a delta the client patches onto
142
+ its last frame. An unchanged screen sends nothing at all. Full PNG
143
+ keyframes go to clients that just connected, fell behind (a client whose
144
+ socket backlog grows gets frames dropped, then a fresh keyframe once it
145
+ catches up), or asked for one because a delta failed to apply — so a
146
+ desynced stream heals itself. The frame always travels at the server's
147
+ full resolution; scaling to the viewer window happens on the client.
148
+
149
+ **Input injection.** The client's viewer widget captures your mouse and
150
+ keyboard events, maps mouse positions to coordinates normalized 0..1 over
151
+ the displayed frame (so window size and letterboxing don't matter), and
152
+ sends them as small JSON messages. The server injects them with the Win32
153
+ **`SendInput`** API: normalized coordinates map onto SendInput's 0..65535
154
+ absolute coordinate space over the primary monitor, and keystrokes carry
155
+ the client's native virtual-key codes, which are injected as-is — both
156
+ ends are Windows, so no key translation is needed. The server only injects
157
+ input from clients that have passed the approval handshake, and anything
158
+ still held down (a dragged button, a modifier key) is released
159
+ automatically if the viewer disconnects or loses focus.
160
+
161
+ **Clipboard.** Both sides watch their local clipboard via Qt and forward
162
+ copies (text, or images as PNG) over the same connection. Echo loops are
163
+ prevented by content signature — an image is hashed by its canonical
164
+ pixels, so a PNG that makes a round trip through the OS clipboard and
165
+ comes back re-encoded is still recognized and not sent again.
166
+
167
+ **Transport.** Each client talks to the server over a single TCP
168
+ connection: TLS via the Windows schannel backend with a self-signed
169
+ certificate the server generates and keeps, then simple length-prefixed
170
+ messages on top — JSON for control (hello/welcome, input, clipboard,
171
+ ping/pong for the round-trip-time graphs, log exchange) and binary
172
+ payloads for frames and deltas. Discovery is a UDP broadcast probe that
173
+ every server answers with its name and port (see below).
174
+
175
+ ## Versioning
176
+
177
+ The apps follow [semantic versioning](https://semver.org): the **major**
178
+ version is the client/server compatibility contract (same major →
179
+ guaranteed to interoperate), **minor** versions add backward-compatible
180
+ features, **patch** versions fix bugs. On every connection each side
181
+ compares its major version with the peer's; a mismatch shows a strong
182
+ warning in both GUIs — connecting is still allowed, but the experience is
183
+ not guaranteed. Keep both computers on the same version for best results.
184
+
114
185
  ## Requirements
115
186
 
116
187
  - 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"
@@ -24,9 +24,11 @@ Microsoft accounts, no cloud: just two apps and your LAN.
24
24
  ![Client demo](https://raw.githubusercontent.com/jamesabel/remotedesktop/master/docs/media/client-demo.gif)
25
25
 
26
26
  The client discovers the server on the LAN, connects, and streams its
27
- desktop live in the *Remote Screen* tab — click into the view and your mouse
28
- and keyboard control the remote machine. The *Performance* tab graphs
29
- bandwidth and round-trip time with live statistics.
27
+ desktop live in a tab named after that computer — click into the view and
28
+ your mouse and keyboard control the remote machine. Connect to several
29
+ servers at once and each gets its own tab; the window title lists every
30
+ connected computer. The *Performance* tab graphs bandwidth and round-trip
31
+ time with live statistics.
30
32
 
31
33
  ### Server
32
34
 
@@ -39,6 +41,7 @@ time with mean/min/max/p99/jitter over the recent window).
39
41
  ## Features
40
42
 
41
43
  - 🔍 **Autodiscovery** — servers announce themselves over UDP; the client lists every server on the LAN, no addresses to type.
44
+ - 🗂️ **Multiple servers at once** — one client can view and control several servers simultaneously, each in its own tab named for that computer; the window title shows who you're connected to, even minimized.
42
45
  - 🖥️ **Lossless screen sharing** — pixel-exact at full resolution, DXGI desktop-duplication capture (~10 ms per 4K frame), and inter-frame delta compression: an unchanged screen sends nothing.
43
46
  - ⌨️🖱️ **Full input control** — mouse, wheel, and keyboard forwarding that is safe against interruptions: anything still held down is released on the server if the viewer loses focus or disconnects, so no stuck keys.
44
47
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other.
@@ -92,6 +95,70 @@ handshake messages until the server user admits them; access can be revoked
92
95
  at any time from the server's *Clients on LAN* tab. There is no dependency
93
96
  on Windows RDP or any Microsoft-based authentication.
94
97
 
98
+ ## How it works
99
+
100
+ All of this is pure Python — the GUIs are PySide6 (Qt), and the two places
101
+ that need to talk to Windows directly (screen capture and input injection)
102
+ call the Win32/COM APIs through `ctypes`, so there are no native extensions
103
+ to compile.
104
+
105
+ **Screen capture.** The server grabs the desktop with the **DXGI desktop
106
+ duplication API**, driven directly through `ctypes` COM calls. Desktop
107
+ duplication is the mechanism Windows provides for exactly this job: the
108
+ compositor hands over a GPU texture of the screen and tells you whether
109
+ anything changed, so a changed 4K frame costs about 10 ms to read back and
110
+ an idle screen costs essentially nothing. When duplication is unavailable
111
+ or gets lost — the secure desktop (UAC/logon screen), an RDP session, a
112
+ display-mode change — the server transparently falls back to Qt's
113
+ `QScreen.grabWindow` (~96 ms per frame) and keeps retrying duplication in
114
+ the background.
115
+
116
+ **Screen transfer.** Frames are captured at up to 30 fps and compared with
117
+ the previous capture in 64-row bands; only the bands that changed are
118
+ encoded — losslessly, as PNG — and sent as a delta the client patches onto
119
+ its last frame. An unchanged screen sends nothing at all. Full PNG
120
+ keyframes go to clients that just connected, fell behind (a client whose
121
+ socket backlog grows gets frames dropped, then a fresh keyframe once it
122
+ catches up), or asked for one because a delta failed to apply — so a
123
+ desynced stream heals itself. The frame always travels at the server's
124
+ full resolution; scaling to the viewer window happens on the client.
125
+
126
+ **Input injection.** The client's viewer widget captures your mouse and
127
+ keyboard events, maps mouse positions to coordinates normalized 0..1 over
128
+ the displayed frame (so window size and letterboxing don't matter), and
129
+ sends them as small JSON messages. The server injects them with the Win32
130
+ **`SendInput`** API: normalized coordinates map onto SendInput's 0..65535
131
+ absolute coordinate space over the primary monitor, and keystrokes carry
132
+ the client's native virtual-key codes, which are injected as-is — both
133
+ ends are Windows, so no key translation is needed. The server only injects
134
+ input from clients that have passed the approval handshake, and anything
135
+ still held down (a dragged button, a modifier key) is released
136
+ automatically if the viewer disconnects or loses focus.
137
+
138
+ **Clipboard.** Both sides watch their local clipboard via Qt and forward
139
+ copies (text, or images as PNG) over the same connection. Echo loops are
140
+ prevented by content signature — an image is hashed by its canonical
141
+ pixels, so a PNG that makes a round trip through the OS clipboard and
142
+ comes back re-encoded is still recognized and not sent again.
143
+
144
+ **Transport.** Each client talks to the server over a single TCP
145
+ connection: TLS via the Windows schannel backend with a self-signed
146
+ certificate the server generates and keeps, then simple length-prefixed
147
+ messages on top — JSON for control (hello/welcome, input, clipboard,
148
+ ping/pong for the round-trip-time graphs, log exchange) and binary
149
+ payloads for frames and deltas. Discovery is a UDP broadcast probe that
150
+ every server answers with its name and port (see below).
151
+
152
+ ## Versioning
153
+
154
+ The apps follow [semantic versioning](https://semver.org): the **major**
155
+ version is the client/server compatibility contract (same major →
156
+ guaranteed to interoperate), **minor** versions add backward-compatible
157
+ features, **patch** versions fix bugs. On every connection each side
158
+ compares its major version with the peer's; a mismatch shows a strong
159
+ warning in both GUIs — connecting is still allowed, but the experience is
160
+ not guaranteed. Keep both computers on the same version for best results.
161
+
95
162
  ## Requirements
96
163
 
97
164
  - Windows
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.18.0"
3
+ __version__ = "0.20.0"
@@ -0,0 +1,71 @@
1
+ """The "About" tab, shared by the client and server windows.
2
+
3
+ Shows the same metadata the PyPI page shows — summary, author, license,
4
+ Python requirement, links — read from the installed package's metadata via
5
+ importlib.metadata, with static fallbacks so the tab still renders in odd
6
+ environments (the version always comes from `remotedesktop.__version__`,
7
+ the single source hatchling packages)."""
8
+
9
+ from importlib import metadata
10
+
11
+ from PySide6.QtCore import Qt
12
+ from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
13
+
14
+ from remotedesktop import __version__, icon
15
+
16
+ _HOMEPAGE = "https://github.com/jamesabel/remotedesktop"
17
+ _PYPI = "https://pypi.org/project/remotedesktop/"
18
+ _FALLBACKS = {
19
+ "Summary": (
20
+ "Remote desktop client/server for Windows computers on the same LAN, "
21
+ "with autodiscovery. Provides screen, keyboard, mouse, and clipboard "
22
+ "sharing without RDP or Microsoft authentication."
23
+ ),
24
+ "Author-email": "James Abel <j@abel.co>",
25
+ "License-Expression": "MIT",
26
+ "Requires-Python": ">=3.14",
27
+ }
28
+
29
+
30
+ def _field(name: str) -> str:
31
+ try:
32
+ value = metadata.metadata("remotedesktop").get(name)
33
+ except metadata.PackageNotFoundError:
34
+ value = None
35
+ return str(value) if value else _FALLBACKS.get(name, "")
36
+
37
+
38
+ class AboutTab(QWidget):
39
+ """Package metadata as a rich-text page with clickable links."""
40
+
41
+ def __init__(self, parent: QWidget | None = None) -> None:
42
+ super().__init__(parent)
43
+ author = _field("Author-email").replace("<", "&lt;").replace(">", "&gt;")
44
+ body = QLabel(
45
+ f"""
46
+ <h2 style="margin-bottom:2px">Remote Desktop {__version__}</h2>
47
+ <p>{_field("Summary")}</p>
48
+ <table cellspacing="4">
49
+ <tr><td><b>Author</b></td><td>{author}</td></tr>
50
+ <tr><td><b>License</b></td><td>{_field("License-Expression")}</td></tr>
51
+ <tr><td><b>Python</b></td><td>{_field("Requires-Python")}</td></tr>
52
+ <tr><td><b>Package</b></td><td>remotedesktop</td></tr>
53
+ </table>
54
+ <p>
55
+ <a href="{_HOMEPAGE}">GitHub</a> &nbsp;·&nbsp;
56
+ <a href="{_PYPI}">PyPI</a>
57
+ </p>
58
+ """
59
+ )
60
+ body.setTextFormat(Qt.TextFormat.RichText)
61
+ body.setOpenExternalLinks(True)
62
+ body.setWordWrap(True)
63
+ body.setTextInteractionFlags(
64
+ Qt.TextInteractionFlag.TextBrowserInteraction
65
+ )
66
+ logo = QLabel()
67
+ logo.setPixmap(icon.app_icon("client").pixmap(48, 48))
68
+ layout = QVBoxLayout(self)
69
+ layout.addWidget(logo)
70
+ layout.addWidget(body)
71
+ layout.addStretch(1)