remotedesktop 0.19.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.19.0 → remotedesktop-0.20.0}/CLAUDE.md +1 -1
  2. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/PKG-INFO +61 -4
  3. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/readme.md +60 -3
  4. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/__init__.py +1 -1
  5. remotedesktop-0.20.0/src/remotedesktop/client.py +452 -0
  6. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_client_window.py +97 -25
  7. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_smoke.py +2 -2
  8. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tools/make_demo_gifs.py +1 -1
  9. remotedesktop-0.19.0/src/remotedesktop/client.py +0 -335
  10. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/.github/workflows/ci.yml +0 -0
  11. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/.gitignore +0 -0
  12. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/LICENSE +0 -0
  13. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/docs/media/client-demo.gif +0 -0
  14. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/docs/media/server-demo.gif +0 -0
  15. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/pyproject.toml +0 -0
  16. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/run_claude.bat +0 -0
  17. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/run_client.bat +0 -0
  18. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/run_server.bat +0 -0
  19. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/scripts/make_coverage_badge.py +0 -0
  20. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/scripts/make_venv.bat +0 -0
  21. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/about.py +0 -0
  22. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/autostart.py +0 -0
  23. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/clipboard.py +0 -0
  24. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/compat.py +0 -0
  25. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/config.py +0 -0
  26. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/db.py +0 -0
  27. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/discovery.py +0 -0
  28. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/dxgi.py +0 -0
  29. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/frames.py +0 -0
  30. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/icon.py +0 -0
  31. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/input_injection.py +0 -0
  32. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/inventory.py +0 -0
  33. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/logs.py +0 -0
  34. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/modal_loop.py +0 -0
  35. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/performance.py +0 -0
  36. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/preferences.py +0 -0
  37. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/protocol.py +0 -0
  38. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/server.py +0 -0
  39. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/sharing.py +0 -0
  40. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/tls.py +0 -0
  41. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/viewer.py +0 -0
  42. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/window_state.py +0 -0
  43. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/conftest.py +0 -0
  44. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_about.py +0 -0
  45. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_autostart.py +0 -0
  46. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_clipboard.py +0 -0
  47. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_compat.py +0 -0
  48. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_config.py +0 -0
  49. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_discovery.py +0 -0
  50. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_dxgi.py +0 -0
  51. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_frames.py +0 -0
  52. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_icon.py +0 -0
  53. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_input.py +0 -0
  54. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_input_injection.py +0 -0
  55. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_inventory.py +0 -0
  56. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_logs.py +0 -0
  57. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_modal_loop.py +0 -0
  58. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_performance.py +0 -0
  59. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_preferences.py +0 -0
  60. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_protocol.py +0 -0
  61. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_server_window.py +0 -0
  62. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_sharing.py +0 -0
  63. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_tls.py +0 -0
  64. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_window_state.py +0 -0
  65. {remotedesktop-0.19.0 → remotedesktop-0.20.0}/uv.lock +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).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.19.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
5
  Project-URL: Homepage, https://github.com/jamesabel/remotedesktop
6
6
  Project-URL: Repository, https://github.com/jamesabel/remotedesktop
@@ -47,9 +47,11 @@ Microsoft accounts, no cloud: just two apps and your LAN.
47
47
  ![Client demo](https://raw.githubusercontent.com/jamesabel/remotedesktop/master/docs/media/client-demo.gif)
48
48
 
49
49
  The client discovers the server on the LAN, connects, and streams its
50
- desktop live in the *Remote Screen* tab — click into the view and your mouse
51
- and keyboard control the remote machine. The *Performance* tab graphs
52
- 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.
53
55
 
54
56
  ### Server
55
57
 
@@ -62,6 +64,7 @@ time with mean/min/max/p99/jitter over the recent window).
62
64
  ## Features
63
65
 
64
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.
65
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.
66
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.
67
70
  - 📋 **Two-way clipboard** — text and images copied on either machine appear on the other.
@@ -115,6 +118,60 @@ handshake messages until the server user admits them; access can be revoked
115
118
  at any time from the server's *Clients on LAN* tab. There is no dependency
116
119
  on Windows RDP or any Microsoft-based authentication.
117
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
+
118
175
  ## Versioning
119
176
 
120
177
  The apps follow [semantic versioning](https://semver.org): the **major**
@@ -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,60 @@ 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
+
95
152
  ## Versioning
96
153
 
97
154
  The apps follow [semantic versioning](https://semver.org): the **major**
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.19.0"
3
+ __version__ = "0.20.0"
@@ -0,0 +1,452 @@
1
+ """Client GUI application: discovers servers on the LAN, connects to one or
2
+ more of them, and shows each server's desktop in its own viewer tab."""
3
+
4
+ import logging
5
+ import sqlite3
6
+ import sys
7
+ import threading
8
+ import time
9
+
10
+ from PySide6.QtCore import Qt, Signal
11
+ from PySide6.QtGui import QCloseEvent
12
+ from PySide6.QtWidgets import (
13
+ QApplication,
14
+ QDockWidget,
15
+ QListWidget,
16
+ QListWidgetItem,
17
+ QMainWindow,
18
+ QMessageBox,
19
+ QPlainTextEdit,
20
+ QPushButton,
21
+ QTabBar,
22
+ QTabWidget,
23
+ QVBoxLayout,
24
+ QWidget,
25
+ )
26
+
27
+ from remotedesktop import __version__, compat, db, icon, logs, window_state
28
+ from remotedesktop.about import AboutTab
29
+ from remotedesktop.logs import PeerLogDialog, read_log_tail
30
+ from remotedesktop.clipboard import ClipboardSync
31
+ from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
32
+ from remotedesktop.discovery import DISCOVERY_PORT, ServerInfo, discover_servers
33
+ from remotedesktop.inventory import ConnectionInventory, InventoryTab
34
+ from remotedesktop.performance import PerformanceMonitor, PerformanceTab
35
+ from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
36
+ from remotedesktop.sharing import ShareClient
37
+ from remotedesktop.viewer import ViewerWidget
38
+
39
+ _log = logging.getLogger("remotedesktop.client")
40
+
41
+
42
+ class DiscoveryPanel(QWidget):
43
+ """Scans the LAN for servers and lists them for the user to pick."""
44
+
45
+ serverActivated = Signal(ServerInfo)
46
+ serversFound = Signal(list)
47
+ status = Signal(str)
48
+ _scanFinished = Signal(list)
49
+
50
+ def __init__(self, parent: QWidget | None = None) -> None:
51
+ super().__init__(parent)
52
+ self._refresh_button = QPushButton("Refresh")
53
+ self.server_list = QListWidget()
54
+ layout = QVBoxLayout(self)
55
+ layout.addWidget(self._refresh_button)
56
+ layout.addWidget(self.server_list)
57
+ self._refresh_button.clicked.connect(self.refresh)
58
+ self.server_list.itemActivated.connect(self._on_item_activated)
59
+ self._scanFinished.connect(self._show_results)
60
+
61
+ def refresh(self) -> None:
62
+ self._refresh_button.setEnabled(False)
63
+ self._refresh_button.setText("Scanning…")
64
+ self.status.emit(f"Scanning LAN (UDP broadcast to port {DISCOVERY_PORT}) …")
65
+ threading.Thread(target=self._scan, name="discovery-scan", daemon=True).start()
66
+
67
+ def _scan(self) -> None:
68
+ # Runs on a worker thread; the signal is delivered queued on the GUI
69
+ # thread. Any failure must still emit, or the button stays disabled.
70
+ servers: list[ServerInfo] = []
71
+ try:
72
+ servers = discover_servers()
73
+ except Exception:
74
+ pass
75
+ self._scanFinished.emit(servers)
76
+
77
+ def _show_results(self, servers: list) -> None:
78
+ self._refresh_button.setEnabled(True)
79
+ self._refresh_button.setText("Refresh")
80
+ self.server_list.clear()
81
+ for server in servers:
82
+ item = QListWidgetItem(f"{server.name} ({server.host}:{server.port})")
83
+ item.setData(Qt.ItemDataRole.UserRole, server)
84
+ self.server_list.addItem(item)
85
+ self.serversFound.emit(servers)
86
+ found = ", ".join(f"{s.name} at {s.host}:{s.port}" for s in servers)
87
+ self.status.emit(f"Scan finished — found: {found}" if servers else "Scan finished — no servers found")
88
+
89
+ def _on_item_activated(self, item: QListWidgetItem) -> None:
90
+ self.serverActivated.emit(item.data(Qt.ItemDataRole.UserRole))
91
+
92
+
93
+ class ServerSession:
94
+ """One server connection: its ShareClient, the viewer tab that displays
95
+ it, and the per-connection state the window tracks for it. The session
96
+ (and its tab) outlives a disconnect, so the user can reconnect in place;
97
+ closing the tab ends the session."""
98
+
99
+ def __init__(self, key: str, name: str, client: ShareClient, viewer: ViewerWidget) -> None:
100
+ self.key = key # "host:port", the KnownServers/inventory key
101
+ self.name = name
102
+ self.client = client
103
+ self.viewer = viewer
104
+ self.connected = False
105
+ self.denied = False
106
+ self.version_mismatch = False
107
+ self.frame_count = 0
108
+ # What the status bar shows while this session's tab is current.
109
+ self.status_text = ""
110
+
111
+
112
+ class ClientWindow(QMainWindow):
113
+ def __init__(
114
+ self, *, connection: sqlite3.Connection | None = None, auto_scan: bool = True
115
+ ) -> None:
116
+ super().__init__()
117
+ self.setWindowIcon(icon.app_icon("client"))
118
+ # Tests inject a connection to a temp database; the app uses the default.
119
+ self._db = connection if connection is not None else db.connect(default_db_path())
120
+ self._settings = Settings(self._db)
121
+ self.performance = PerformanceMonitor(
122
+ window_seconds=float(load_performance_window_seconds(self._settings)),
123
+ parent=self,
124
+ )
125
+ self.inventory = ConnectionInventory(self._db, "client_peers", self)
126
+ # One tab per server connection (inserted at the front, closable),
127
+ # followed by the fixed tabs, which never get a close button.
128
+ self._sessions: list[ServerSession] = []
129
+ self._tabs = QTabWidget()
130
+ self._tabs.setTabsClosable(True)
131
+ self._tabs.tabCloseRequested.connect(self._on_tab_close_requested)
132
+ self._tabs.currentChanged.connect(lambda _index: self._refresh_status_bar())
133
+ self._tabs.addTab(
134
+ InventoryTab(self.inventory, "Forget server", self._forget_server),
135
+ "Servers on LAN",
136
+ )
137
+ self._tabs.addTab(
138
+ PerformanceTab(self.performance, local="client", remote="server"),
139
+ "Performance",
140
+ )
141
+ self.setCentralWidget(self._tabs)
142
+
143
+ self.discovery_panel = DiscoveryPanel(self)
144
+ servers_dock = QDockWidget("Servers", self)
145
+ servers_dock.setWidget(self.discovery_panel)
146
+ self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, servers_dock)
147
+
148
+ self.connection_log = QPlainTextEdit(self)
149
+ self.connection_log.setReadOnly(True)
150
+ self.connection_log.setMaximumBlockCount(1000)
151
+ self.get_log_button = QPushButton("Get server log")
152
+ self.get_log_button.setToolTip("Ask the connected server to send its debug log")
153
+ self.get_log_button.clicked.connect(self._request_server_log)
154
+ log_tab = QWidget()
155
+ log_layout = QVBoxLayout(log_tab)
156
+ log_layout.addWidget(self.get_log_button, alignment=Qt.AlignmentFlag.AlignLeft)
157
+ log_layout.addWidget(self.connection_log)
158
+ self._tabs.addTab(log_tab, "Connection log")
159
+ self._tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
160
+ self._tabs.addTab(AboutTab(), "About")
161
+ # Only session tabs are closable; strip the buttons the fixed tabs
162
+ # got from setTabsClosable (styles place them on either side).
163
+ bar = self._tabs.tabBar()
164
+ for index in range(self._tabs.count()):
165
+ for side in (QTabBar.ButtonPosition.LeftSide, QTabBar.ButtonPosition.RightSide):
166
+ bar.setTabButton(index, side, None)
167
+
168
+ self.discovery_panel.serverActivated.connect(self._on_server_activated)
169
+ self.discovery_panel.serversFound.connect(self._record_discovered)
170
+ self.discovery_panel.status.connect(self.log)
171
+
172
+ self._clipboard = ClipboardSync(parent=self)
173
+ self._known_servers = KnownServers(self._db)
174
+ self._identity = load_client_identity(self._db)
175
+ self._update_window_title()
176
+ self.statusBar().showMessage("Not connected")
177
+ window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
178
+ self.log("Client started")
179
+ # Tests pass auto_scan=False so window tests never broadcast on the LAN.
180
+ if auto_scan:
181
+ self.discovery_panel.refresh()
182
+
183
+ def log(self, message: str) -> None:
184
+ # Everything shown in the Connection log pane also goes to the debug
185
+ # log file (when main() enabled it), so it survives the window.
186
+ _log.info(message)
187
+ self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
188
+
189
+ def _update_window_title(self) -> None:
190
+ """Connected server names lead the title, so the taskbar (and a
191
+ minimized window) says who this client is viewing."""
192
+ names = [session.name for session in self._sessions if session.connected]
193
+ base = f"Remote Desktop Client {__version__}"
194
+ self.setWindowTitle(f"{', '.join(names)} — {base}" if names else base)
195
+
196
+ def _session_for_key(self, key: str) -> ServerSession | None:
197
+ for session in self._sessions:
198
+ if session.key == key:
199
+ return session
200
+ return None
201
+
202
+ def _session_for_viewer(self, widget) -> ServerSession | None:
203
+ for session in self._sessions:
204
+ if session.viewer is widget:
205
+ return session
206
+ return None
207
+
208
+ def _set_session_status(self, session: ServerSession, text: str) -> None:
209
+ session.status_text = text
210
+ if self._tabs.currentWidget() is session.viewer:
211
+ self.statusBar().showMessage(text)
212
+
213
+ def _refresh_status_bar(self) -> None:
214
+ session = self._session_for_viewer(self._tabs.currentWidget())
215
+ if session is not None:
216
+ self.statusBar().showMessage(session.status_text)
217
+ return
218
+ names = [s.name for s in self._sessions if s.connected]
219
+ self.statusBar().showMessage(
220
+ f"Connected to {', '.join(names)}" if names else "Not connected"
221
+ )
222
+
223
+ def _forget_server(self, key: str) -> None:
224
+ answer = QMessageBox.question(
225
+ self,
226
+ "Forget server",
227
+ f"Forget server {key}?\n\n"
228
+ "If connected it will be disconnected, and the next connection will "
229
+ "need the server user to approve this computer again.",
230
+ )
231
+ if answer != QMessageBox.StandardButton.Yes:
232
+ return
233
+ session = self._session_for_key(key)
234
+ if session is not None:
235
+ self._close_session(session)
236
+ self._known_servers.forget(key)
237
+ self.inventory.record(key, "forgotten")
238
+ self.log(f"Forgot server {key}")
239
+
240
+ def _record_discovered(self, servers: list) -> None:
241
+ for server in servers:
242
+ key = f"{server.host}:{server.port}"
243
+ session = self._session_for_key(key)
244
+ if session is not None and session.connected:
245
+ continue # don't downgrade a connected server to "discovered"
246
+ self.inventory.record(
247
+ key, "discovered", name=server.name, address=key, detail=key
248
+ )
249
+
250
+ def _on_server_activated(self, server: ServerInfo) -> None:
251
+ key = f"{server.host}:{server.port}"
252
+ session = self._session_for_key(key)
253
+ if session is not None and session.connected:
254
+ self._tabs.setCurrentWidget(session.viewer)
255
+ self.log(f"Already connected to {session.name} ({key})")
256
+ return
257
+ if session is None:
258
+ session = self._create_session(key, server.name)
259
+ else:
260
+ session.name = server.name
261
+ self._tabs.setTabText(self._sessions.index(session), session.name)
262
+ self._connect_session(session, server.host, server.port)
263
+
264
+ def _create_session(self, key: str, name: str) -> ServerSession:
265
+ viewer = ViewerWidget()
266
+ client = ShareClient(
267
+ identity=self._identity,
268
+ known_servers=self._known_servers,
269
+ clipboard=self._clipboard,
270
+ performance=self.performance,
271
+ log_provider=lambda: read_log_tail("client"),
272
+ parent=self,
273
+ )
274
+ session = ServerSession(key, name, client, viewer)
275
+ viewer.inputEvent.connect(lambda event, s=session: self._on_input_event(s, event))
276
+ client.status.connect(lambda message, s=session: self.log(f"[{s.name}] {message}"))
277
+ client.connected.connect(lambda server_name, s=session: self._on_connected(s, server_name))
278
+ client.approvalPending.connect(lambda s=session: self._on_approval_pending(s))
279
+ client.denied.connect(lambda reason, s=session: self._on_denied(s, reason))
280
+ client.disconnected.connect(lambda s=session: self._on_disconnected(s))
281
+ client.frameReceived.connect(lambda image, s=session: self._on_frame(s, image))
282
+ client.logReceived.connect(lambda text, s=session: self._show_server_log(s.name, text))
283
+ self._sessions.append(session)
284
+ self._tabs.insertTab(len(self._sessions) - 1, viewer, session.name)
285
+ return session
286
+
287
+ def _connect_session(self, session: ServerSession, host: str, port: int) -> None:
288
+ session.frame_count = 0
289
+ session.connected = False
290
+ session.denied = False
291
+ session.version_mismatch = False
292
+ self.inventory.record(
293
+ session.key, "attempt", name=session.name,
294
+ address=session.key, detail=session.key,
295
+ )
296
+ # Graphs show only the current connection(s): clear them for a fresh
297
+ # start, but never while another session's stream is being sampled.
298
+ if not any(s.connected for s in self._sessions if s is not session):
299
+ self.performance.reset()
300
+ session.viewer.clear(f"Connecting to {session.name} …")
301
+ self._tabs.setCurrentWidget(session.viewer)
302
+ self._set_session_status(
303
+ session, f"Connecting to {session.name} ({session.key}) …"
304
+ )
305
+ session.client.connect_to(host, port)
306
+
307
+ def _close_session(self, session: ServerSession) -> None:
308
+ """Disconnect the session and remove its tab (tab close / forget)."""
309
+ session.client.close() # a synchronous disconnect signal may fire here
310
+ index = self._sessions.index(session)
311
+ self._sessions.pop(index)
312
+ self._tabs.removeTab(index)
313
+ if session.connected and not session.denied:
314
+ self.inventory.record(session.key, "disconnected", name=session.name)
315
+ session.connected = False
316
+ session.client.deleteLater()
317
+ session.viewer.deleteLater()
318
+ self.log(f"Closed connection to {session.name} ({session.key})")
319
+ self._update_window_title()
320
+ self._refresh_status_bar()
321
+
322
+ def _on_tab_close_requested(self, index: int) -> None:
323
+ if index < len(self._sessions): # fixed tabs carry no close button
324
+ self._close_session(self._sessions[index])
325
+
326
+ def _on_approval_pending(self, session: ServerSession) -> None:
327
+ if session not in self._sessions:
328
+ return
329
+ session.viewer.clear(
330
+ f"Waiting for approval — someone at {session.name} "
331
+ "must allow this connection"
332
+ )
333
+ self._set_session_status(
334
+ session,
335
+ f"Waiting for the user on {session.name} to approve this computer …",
336
+ )
337
+
338
+ def _server_label(self, session: ServerSession) -> str:
339
+ """The server's name with its app version when it reported one,
340
+ e.g. 'DEN-PC (0.19.0)' — flagged when its major version differs."""
341
+ version = session.client.server_app_version
342
+ if not version:
343
+ return session.name
344
+ marker = " ⚠ VERSION MISMATCH" if session.version_mismatch else ""
345
+ return f"{session.name} ({version}{marker})"
346
+
347
+ def _on_connected(self, session: ServerSession, server_name: str) -> None:
348
+ if session not in self._sessions:
349
+ return
350
+ session.name = server_name or session.name
351
+ self._tabs.setTabText(self._sessions.index(session), session.name)
352
+ session.connected = True
353
+ # Semver policy: matching majors are the compatibility contract. A
354
+ # mismatch warns loudly (log, dialog, status bar) but never blocks —
355
+ # the user may still try, with no guarantees.
356
+ warning = compat.mismatch_warning(
357
+ __version__, session.client.server_app_version, "server"
358
+ )
359
+ session.version_mismatch = warning is not None
360
+ if warning:
361
+ self.log(warning)
362
+ box = QMessageBox(
363
+ QMessageBox.Icon.Warning,
364
+ "Version mismatch",
365
+ warning,
366
+ QMessageBox.StandardButton.Ok,
367
+ self,
368
+ )
369
+ box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
370
+ box.show() # non-modal: streaming continues behind it
371
+ self.inventory.record(session.key, "connected", name=session.name)
372
+ session.viewer.setFocus()
373
+ self._set_session_status(
374
+ session,
375
+ f"Connected to {self._server_label(session)} — waiting for first frame "
376
+ "(click the view to control it)",
377
+ )
378
+ self._update_window_title()
379
+
380
+ def _on_input_event(self, session: ServerSession, event: dict) -> None:
381
+ if session.connected:
382
+ session.client.send_input(event)
383
+
384
+ def _on_denied(self, session: ServerSession, reason: str) -> None:
385
+ if session not in self._sessions:
386
+ return
387
+ session.connected = False
388
+ session.denied = True
389
+ self.inventory.record(session.key, "denied", name=session.name)
390
+ session.viewer.clear(f"Connection denied: {reason}")
391
+ self._set_session_status(session, f"Denied by {session.name}: {reason}")
392
+ self._update_window_title()
393
+
394
+ def _on_disconnected(self, session: ServerSession) -> None:
395
+ if session not in self._sessions:
396
+ return
397
+ session.connected = False
398
+ # After a denial, keep "denied" as the peer's state in the inventory
399
+ # rather than overwriting it with the trailing "disconnected".
400
+ if not session.denied:
401
+ self.inventory.record(session.key, "disconnected", name=session.name)
402
+ session.viewer.clear("Disconnected")
403
+ self._set_session_status(session, f"Disconnected from {session.name}")
404
+ self._update_window_title()
405
+
406
+ def _request_server_log(self) -> None:
407
+ session = self._session_for_viewer(self._tabs.currentWidget())
408
+ if session is None:
409
+ connected = [s for s in self._sessions if s.connected]
410
+ session = connected[-1] if connected else None
411
+ if session is None:
412
+ self.log("Not connected — no server to request a log from")
413
+ return
414
+ session.client.request_log()
415
+
416
+ def _show_server_log(self, server_name: str, text: str) -> None:
417
+ title = (
418
+ f'Log from server "{server_name}"' if server_name else "Log from server"
419
+ )
420
+ PeerLogDialog(title, text, self).show()
421
+
422
+ def _on_frame(self, session: ServerSession, image) -> None:
423
+ if session not in self._sessions:
424
+ return
425
+ session.frame_count += 1
426
+ session.viewer.show_frame(image)
427
+ self._set_session_status(
428
+ session,
429
+ f"Viewing {self._server_label(session)} — {image.width()}x{image.height()} — "
430
+ f"{session.frame_count} frames received",
431
+ )
432
+
433
+ def closeEvent(self, event: QCloseEvent) -> None:
434
+ window_state.save_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
435
+ for session in self._sessions:
436
+ session.client.close()
437
+ super().closeEvent(event)
438
+
439
+
440
+ def main() -> None: # pragma: no cover - runs the Qt event loop
441
+ log_path = logs.init_logging("client")
442
+ icon.set_windows_app_id("remotedesktop.client")
443
+ app = QApplication(sys.argv)
444
+ app.setWindowIcon(icon.app_icon("client"))
445
+ window = ClientWindow() # auto_scan starts the first LAN scan
446
+ window.log(f"Detailed log: {log_path}")
447
+ window.show()
448
+ raise SystemExit(app.exec())
449
+
450
+
451
+ if __name__ == "__main__": # pragma: no cover
452
+ main()