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.
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/CLAUDE.md +1 -1
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/PKG-INFO +61 -4
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/readme.md +60 -3
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/__init__.py +1 -1
- remotedesktop-0.20.0/src/remotedesktop/client.py +452 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_client_window.py +97 -25
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_smoke.py +2 -2
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tools/make_demo_gifs.py +1 -1
- remotedesktop-0.19.0/src/remotedesktop/client.py +0 -335
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/.gitignore +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/LICENSE +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/docs/media/client-demo.gif +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/docs/media/server-demo.gif +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/pyproject.toml +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/run_claude.bat +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/run_client.bat +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/run_server.bat +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/about.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/compat.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/dxgi.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/modal_loop.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/performance.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/server.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/sharing.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/conftest.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_about.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_compat.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_config.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_dxgi.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_input.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_modal_loop.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_performance.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_server_window.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.19.0 → remotedesktop-0.20.0}/tests/test_window_state.py +0 -0
- {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
|
|
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.
|
|
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
|

|
|
48
48
|
|
|
49
49
|
The client discovers the server on the LAN, connects, and streams its
|
|
50
|
-
desktop live in
|
|
51
|
-
and keyboard control the remote machine.
|
|
52
|
-
|
|
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
|

|
|
25
25
|
|
|
26
26
|
The client discovers the server on the LAN, connects, and streams its
|
|
27
|
-
desktop live in
|
|
28
|
-
and keyboard control the remote machine.
|
|
29
|
-
|
|
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**
|
|
@@ -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()
|