remotedesktop 0.7.0__tar.gz → 0.9.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.7.0 → remotedesktop-0.9.0}/CLAUDE.md +1 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/PKG-INFO +6 -2
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/readme.md +5 -1
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/client.py +15 -2
- remotedesktop-0.9.0/src/remotedesktop/icon.py +69 -0
- remotedesktop-0.9.0/src/remotedesktop/performance.py +342 -0
- remotedesktop-0.9.0/src/remotedesktop/preferences.py +49 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/protocol.py +6 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/server.py +56 -3
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/sharing.py +31 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_client_window.py +5 -0
- remotedesktop-0.9.0/tests/test_icon.py +19 -0
- remotedesktop-0.9.0/tests/test_performance.py +250 -0
- remotedesktop-0.9.0/tests/test_preferences.py +31 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_server_window.py +42 -1
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/.gitignore +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/LICENSE +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/pyproject.toml +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/run_claude.bat +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/run_client.bat +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/run_server.bat +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/conftest.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_config.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_input.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/tests/test_window_state.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.9.0}/uv.lock +0 -0
|
@@ -49,6 +49,7 @@ Managed with `uv` (hatchling build backend, src layout):
|
|
|
49
49
|
- **Input forwarding**: `ViewerWidget` captures mouse/keyboard events and emits `inputEvent` dicts with coordinates normalized 0..1 over the *displayed* frame rect (letterboxing is reversed via `_display_rect()`; events outside the frame are dropped). Keys carry the client's `nativeVirtualKey()` — since both ends are Windows, the server injects that VK directly with no key-translation table. The client sends these as `{"type": "input", ...}`; `ShareServer` only injects input from streams that completed the hello/approval handshake.
|
|
50
50
|
- **Injection is isolated behind `InputInjector`** (`input_injection.py`, Windows `SendInput` via ctypes; inert stub off-Windows). ShareServer takes an `injector=` param so tests pass a recording fake — **never let tests construct a real `InputInjector`, or they will move the host's actual mouse/keyboard.** Normalized 0..1 coords map directly to SendInput's 0..65535 absolute range over the primary monitor.
|
|
51
51
|
- **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.
|
|
52
|
+
- **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 the most recently admitted stream: `{"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).
|
|
52
53
|
- **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.
|
|
53
54
|
- 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.
|
|
54
55
|
- Widget tests need a `QApplication`; use the session-scoped `qapp` fixture in `tests/conftest.py`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
Author-email: James Abel <j@abel.co>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,7 +30,11 @@ computers on the same LAN, with autodiscovery of servers.
|
|
|
30
30
|
|
|
31
31
|
The server can optionally start automatically when you log in to Windows —
|
|
32
32
|
a checkbox on its Status tab registers it under the per-user Run key (no
|
|
33
|
-
administrator rights needed).
|
|
33
|
+
administrator rights needed). The Status tab also has a *Restart server*
|
|
34
|
+
button that relaunches the app in a fresh process; since the server's
|
|
35
|
+
screen can be controlled remotely, this lets you update the software from
|
|
36
|
+
a remote desktop session and restart into the new version without visiting
|
|
37
|
+
the machine (approved clients reconnect without a new permission prompt).
|
|
34
38
|
|
|
35
39
|
Connections are made to the desktop screen, keyboard, mouse, and clipboard.
|
|
36
40
|
Other connections are not provided, such as shared drives, devices, or
|
|
@@ -11,7 +11,11 @@ computers on the same LAN, with autodiscovery of servers.
|
|
|
11
11
|
|
|
12
12
|
The server can optionally start automatically when you log in to Windows —
|
|
13
13
|
a checkbox on its Status tab registers it under the per-user Run key (no
|
|
14
|
-
administrator rights needed).
|
|
14
|
+
administrator rights needed). The Status tab also has a *Restart server*
|
|
15
|
+
button that relaunches the app in a fresh process; since the server's
|
|
16
|
+
screen can be controlled remotely, this lets you update the software from
|
|
17
|
+
a remote desktop session and restart into the new version without visiting
|
|
18
|
+
the machine (approved clients reconnect without a new permission prompt).
|
|
15
19
|
|
|
16
20
|
Connections are made to the desktop screen, keyboard, mouse, and clipboard.
|
|
17
21
|
Other connections are not provided, such as shared drives, devices, or
|
|
@@ -23,11 +23,13 @@ from PySide6.QtWidgets import (
|
|
|
23
23
|
QWidget,
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
-
from remotedesktop import db, logs, window_state
|
|
26
|
+
from remotedesktop import db, icon, logs, window_state
|
|
27
27
|
from remotedesktop.clipboard import ClipboardSync
|
|
28
28
|
from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
|
|
29
29
|
from remotedesktop.discovery import DISCOVERY_PORT, ServerInfo, discover_servers
|
|
30
30
|
from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
31
|
+
from remotedesktop.performance import PerformanceMonitor, PerformanceTab
|
|
32
|
+
from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
|
|
31
33
|
from remotedesktop.sharing import ShareClient
|
|
32
34
|
from remotedesktop.viewer import ViewerWidget
|
|
33
35
|
|
|
@@ -91,8 +93,14 @@ class ClientWindow(QMainWindow):
|
|
|
91
93
|
) -> None:
|
|
92
94
|
super().__init__()
|
|
93
95
|
self.setWindowTitle("Remote Desktop Client")
|
|
96
|
+
self.setWindowIcon(icon.app_icon("client"))
|
|
94
97
|
# Tests inject a connection to a temp database; the app uses the default.
|
|
95
98
|
self._db = connection if connection is not None else db.connect(default_db_path())
|
|
99
|
+
self._settings = Settings(self._db)
|
|
100
|
+
self.performance = PerformanceMonitor(
|
|
101
|
+
window_seconds=float(load_performance_window_seconds(self._settings)),
|
|
102
|
+
parent=self,
|
|
103
|
+
)
|
|
96
104
|
self.viewer = ViewerWidget(self)
|
|
97
105
|
self.inventory = ConnectionInventory(self._db, "client_peers", self)
|
|
98
106
|
tabs = QTabWidget()
|
|
@@ -101,6 +109,7 @@ class ClientWindow(QMainWindow):
|
|
|
101
109
|
InventoryTab(self.inventory, "Forget server", self._forget_server),
|
|
102
110
|
"Servers on LAN",
|
|
103
111
|
)
|
|
112
|
+
tabs.addTab(PerformanceTab(self.performance), "Performance")
|
|
104
113
|
self.setCentralWidget(tabs)
|
|
105
114
|
|
|
106
115
|
self.discovery_panel = DiscoveryPanel(self)
|
|
@@ -112,6 +121,7 @@ class ClientWindow(QMainWindow):
|
|
|
112
121
|
self.connection_log.setReadOnly(True)
|
|
113
122
|
self.connection_log.setMaximumBlockCount(1000)
|
|
114
123
|
tabs.addTab(self.connection_log, "Connection log")
|
|
124
|
+
tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
|
|
115
125
|
|
|
116
126
|
self.discovery_panel.serverActivated.connect(self._on_server_activated)
|
|
117
127
|
self.discovery_panel.serversFound.connect(self._record_discovered)
|
|
@@ -128,7 +138,6 @@ class ClientWindow(QMainWindow):
|
|
|
128
138
|
self._server_key = ""
|
|
129
139
|
self._frame_count = 0
|
|
130
140
|
self.statusBar().showMessage("Not connected")
|
|
131
|
-
self._settings = Settings(self._db)
|
|
132
141
|
window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
|
|
133
142
|
self.log("Client started")
|
|
134
143
|
# Tests pass auto_scan=False so window tests never broadcast on the LAN.
|
|
@@ -180,10 +189,12 @@ class ClientWindow(QMainWindow):
|
|
|
180
189
|
self._server_key, "attempt", name=server.name,
|
|
181
190
|
address=self._server_key, detail=self._server_key,
|
|
182
191
|
)
|
|
192
|
+
self.performance.reset() # graphs show only the current connection
|
|
183
193
|
client = ShareClient(
|
|
184
194
|
identity=self._identity,
|
|
185
195
|
known_servers=self._known_servers,
|
|
186
196
|
clipboard=self._clipboard,
|
|
197
|
+
performance=self.performance,
|
|
187
198
|
parent=self,
|
|
188
199
|
)
|
|
189
200
|
self._client = client
|
|
@@ -254,7 +265,9 @@ class ClientWindow(QMainWindow):
|
|
|
254
265
|
|
|
255
266
|
def main() -> None: # pragma: no cover - runs the Qt event loop
|
|
256
267
|
log_path = logs.init_logging("client")
|
|
268
|
+
icon.set_windows_app_id("remotedesktop.client")
|
|
257
269
|
app = QApplication(sys.argv)
|
|
270
|
+
app.setWindowIcon(icon.app_icon("client"))
|
|
258
271
|
window = ClientWindow() # auto_scan starts the first LAN scan
|
|
259
272
|
window.log(f"Detailed log: {log_path}")
|
|
260
273
|
window.show()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The application icon, drawn in code.
|
|
2
|
+
|
|
3
|
+
A monitor with a mouse pointer on the screen — "this desktop is controlled
|
|
4
|
+
remotely". Painted with QPainter at every common icon size (no binary asset
|
|
5
|
+
to version or package), with an accent color per app so the client (blue)
|
|
6
|
+
and server (green) are distinguishable in the taskbar when both run on one
|
|
7
|
+
machine.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import ctypes
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from PySide6.QtCore import QPointF, QRectF, Qt
|
|
14
|
+
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap, QPolygonF
|
|
15
|
+
|
|
16
|
+
_SIZES = (16, 24, 32, 48, 64, 128, 256)
|
|
17
|
+
_FRAME = QColor("#37474f")
|
|
18
|
+
_ACCENTS = {"client": QColor("#1e88e5"), "server": QColor("#43a047")}
|
|
19
|
+
# A classic cursor-arrow outline on a 0..17 grid, placed on the screen.
|
|
20
|
+
_POINTER = [(0, 0), (0, 14), (4, 11), (7, 17), (9.5, 16), (6.5, 10), (11, 10)]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pixmap(size: int, accent: QColor) -> QPixmap:
|
|
24
|
+
pixmap = QPixmap(size, size)
|
|
25
|
+
pixmap.fill(Qt.GlobalColor.transparent)
|
|
26
|
+
painter = QPainter(pixmap)
|
|
27
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
28
|
+
s = size / 64.0
|
|
29
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
30
|
+
# Monitor frame, screen, stand, base.
|
|
31
|
+
painter.setBrush(_FRAME)
|
|
32
|
+
painter.drawRoundedRect(QRectF(4 * s, 6 * s, 56 * s, 40 * s), 5 * s, 5 * s)
|
|
33
|
+
painter.setBrush(accent)
|
|
34
|
+
painter.drawRoundedRect(QRectF(9 * s, 11 * s, 46 * s, 30 * s), 2 * s, 2 * s)
|
|
35
|
+
painter.setBrush(_FRAME)
|
|
36
|
+
painter.drawRect(QRectF(28 * s, 46 * s, 8 * s, 6 * s))
|
|
37
|
+
painter.drawRoundedRect(QRectF(18 * s, 52 * s, 28 * s, 5 * s), 2 * s, 2 * s)
|
|
38
|
+
# Mouse pointer on the screen: remote control.
|
|
39
|
+
pointer = QPolygonF(
|
|
40
|
+
[QPointF((22 + x * 1.4) * s, (14 + y * 1.4) * s) for x, y in _POINTER]
|
|
41
|
+
)
|
|
42
|
+
painter.setBrush(QColor("white"))
|
|
43
|
+
painter.drawPolygon(pointer)
|
|
44
|
+
painter.end()
|
|
45
|
+
return pixmap
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def app_icon(role: str) -> QIcon:
|
|
49
|
+
"""The window/taskbar icon for "client" or "server"."""
|
|
50
|
+
accent = _ACCENTS[role]
|
|
51
|
+
icon = QIcon()
|
|
52
|
+
for size in _SIZES:
|
|
53
|
+
icon.addPixmap(_pixmap(size, accent))
|
|
54
|
+
return icon
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_windows_app_id(app_id: str) -> None:
|
|
58
|
+
"""Give this process its own Windows taskbar identity.
|
|
59
|
+
|
|
60
|
+
Without it, Windows groups the app under the Python interpreter's
|
|
61
|
+
identity and may show the interpreter's icon in the taskbar instead of
|
|
62
|
+
the window icon. Harmless no-op off Windows or on failure.
|
|
63
|
+
"""
|
|
64
|
+
if sys.platform != "win32": # pragma: no cover - Windows is the target
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
|
68
|
+
except (AttributeError, OSError): # pragma: no cover - defensive
|
|
69
|
+
pass
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Connection performance monitoring: bandwidth and round-trip time.
|
|
2
|
+
|
|
3
|
+
`PerformanceMonitor` is an opt-in collaborator for `ShareServer`/`ShareClient`
|
|
4
|
+
(the `clipboard=` pattern): while at least one admitted stream is attached, a
|
|
5
|
+
1 s timer samples the streams' framed byte counters into rolling bandwidth
|
|
6
|
+
series and pings the peer. Each side measures its own round-trip time and
|
|
7
|
+
piggybacks the latest measurement on its next ping (`"rtt"`), so both ends can
|
|
8
|
+
graph both directions. Peers that predate this feature simply never answer
|
|
9
|
+
pings — the RTT series stay empty and nothing else is affected.
|
|
10
|
+
|
|
11
|
+
Wire messages (JSON, admitted streams only):
|
|
12
|
+
{"type": "ping", "id": <int>, "rtt": <ms, omitted before first measurement>}
|
|
13
|
+
{"type": "pong", "id": <int>} # pure echo
|
|
14
|
+
|
|
15
|
+
`PerformanceTab` shows the graphs. It schedules no paint work while hidden
|
|
16
|
+
(background pages of a QTabWidget are hidden widgets): sampling continues in
|
|
17
|
+
the monitor, and the tab repaints on show with the accrued history.
|
|
18
|
+
|
|
19
|
+
All timing uses time.monotonic(), injectable as `clock=` for tests.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import itertools
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
from collections import deque
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from typing import Protocol
|
|
28
|
+
|
|
29
|
+
from PySide6.QtCore import QObject, QPointF, QTimer, Qt, Signal
|
|
30
|
+
from PySide6.QtGui import QColor, QPainter, QPaintEvent, QPolygonF, QShowEvent
|
|
31
|
+
from PySide6.QtWidgets import QVBoxLayout, QWidget
|
|
32
|
+
|
|
33
|
+
_log = logging.getLogger("remotedesktop.performance")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StreamLike(Protocol):
|
|
37
|
+
"""What the monitor needs from a stream: `protocol.MessageStream`
|
|
38
|
+
satisfies this, and tests substitute a plain fake with a recording
|
|
39
|
+
send_json."""
|
|
40
|
+
|
|
41
|
+
bytes_sent: int
|
|
42
|
+
bytes_received: int
|
|
43
|
+
|
|
44
|
+
def send_json(self, message: dict) -> None: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
DEFAULT_WINDOW_SECONDS = 120.0
|
|
48
|
+
DEFAULT_INTERVAL_MS = 1000
|
|
49
|
+
_PING_PRUNE_SECONDS = 30.0
|
|
50
|
+
_HEARTBEAT_TICKS = 10 # one debug-log line per this many samples
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MetricSeries:
|
|
54
|
+
"""(monotonic time, value) samples kept for a rolling time window."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, window_seconds: float, *, clock: Callable[[], float] = time.monotonic) -> None:
|
|
57
|
+
self._window = window_seconds
|
|
58
|
+
self._clock = clock
|
|
59
|
+
self._samples: deque[tuple[float, float]] = deque()
|
|
60
|
+
|
|
61
|
+
def add(self, value: float) -> None:
|
|
62
|
+
self._samples.append((self._clock(), value))
|
|
63
|
+
self._trim()
|
|
64
|
+
|
|
65
|
+
def samples(self) -> list[tuple[float, float]]:
|
|
66
|
+
self._trim()
|
|
67
|
+
return list(self._samples)
|
|
68
|
+
|
|
69
|
+
def latest(self) -> float | None:
|
|
70
|
+
return self._samples[-1][1] if self._samples else None
|
|
71
|
+
|
|
72
|
+
def clear(self) -> None:
|
|
73
|
+
self._samples.clear()
|
|
74
|
+
|
|
75
|
+
def set_window(self, window_seconds: float) -> None:
|
|
76
|
+
self._window = window_seconds
|
|
77
|
+
self._trim()
|
|
78
|
+
|
|
79
|
+
def _trim(self) -> None:
|
|
80
|
+
cutoff = self._clock() - self._window
|
|
81
|
+
while self._samples and self._samples[0][0] < cutoff:
|
|
82
|
+
self._samples.popleft()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PerformanceMonitor(QObject):
|
|
86
|
+
"""Samples bandwidth and measures RTT for the attached streams.
|
|
87
|
+
|
|
88
|
+
The timer runs only while streams are attached (the ShareServer._timer
|
|
89
|
+
discipline), so an idle app schedules no periodic work. Bandwidth
|
|
90
|
+
aggregates all attached streams; pings go to the most recently attached
|
|
91
|
+
stream only, which keeps the RTT series a single coherent line when a
|
|
92
|
+
server has several viewers.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
updated = Signal() # one emit per sample tick
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
window_seconds: float = DEFAULT_WINDOW_SECONDS,
|
|
101
|
+
interval_ms: int = DEFAULT_INTERVAL_MS,
|
|
102
|
+
clock: Callable[[], float] = time.monotonic,
|
|
103
|
+
parent: QObject | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
super().__init__(parent)
|
|
106
|
+
self._clock = clock
|
|
107
|
+
self._window = window_seconds
|
|
108
|
+
self.send_bps = MetricSeries(window_seconds, clock=clock)
|
|
109
|
+
self.recv_bps = MetricSeries(window_seconds, clock=clock)
|
|
110
|
+
self.rtt_ms = MetricSeries(window_seconds, clock=clock)
|
|
111
|
+
self.peer_rtt_ms = MetricSeries(window_seconds, clock=clock)
|
|
112
|
+
self._streams: list[StreamLike] = []
|
|
113
|
+
self._baselines: dict[StreamLike, tuple[int, int]] = {}
|
|
114
|
+
self._active: StreamLike | None = None
|
|
115
|
+
self._pending: dict[int, float] = {}
|
|
116
|
+
self._ids = itertools.count(1)
|
|
117
|
+
self._last_tick: float | None = None
|
|
118
|
+
self._tick_count = 0
|
|
119
|
+
self._timer = QTimer(self)
|
|
120
|
+
self._timer.setInterval(interval_ms)
|
|
121
|
+
self._timer.timeout.connect(self._on_tick)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def window_seconds(self) -> float:
|
|
125
|
+
return self._window
|
|
126
|
+
|
|
127
|
+
def set_window_seconds(self, window_seconds: float) -> None:
|
|
128
|
+
self._window = window_seconds
|
|
129
|
+
for series in (self.send_bps, self.recv_bps, self.rtt_ms, self.peer_rtt_ms):
|
|
130
|
+
series.set_window(window_seconds)
|
|
131
|
+
|
|
132
|
+
def add_stream(self, stream: StreamLike) -> None:
|
|
133
|
+
if stream not in self._baselines:
|
|
134
|
+
self._streams.append(stream)
|
|
135
|
+
self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
|
|
136
|
+
self._active = stream
|
|
137
|
+
if not self._timer.isActive():
|
|
138
|
+
self._last_tick = None
|
|
139
|
+
self._timer.start()
|
|
140
|
+
|
|
141
|
+
def remove_stream(self, stream: StreamLike) -> None:
|
|
142
|
+
if stream not in self._baselines:
|
|
143
|
+
return
|
|
144
|
+
self._streams.remove(stream)
|
|
145
|
+
del self._baselines[stream]
|
|
146
|
+
if self._active is stream:
|
|
147
|
+
self._active = self._streams[-1] if self._streams else None
|
|
148
|
+
if not self._streams:
|
|
149
|
+
self._timer.stop()
|
|
150
|
+
self._pending.clear()
|
|
151
|
+
self._last_tick = None
|
|
152
|
+
|
|
153
|
+
def reset(self) -> None:
|
|
154
|
+
"""Detach everything and clear the graphs (new connection attempt)."""
|
|
155
|
+
for stream in list(self._streams):
|
|
156
|
+
self.remove_stream(stream)
|
|
157
|
+
for series in (self.send_bps, self.recv_bps, self.rtt_ms, self.peer_rtt_ms):
|
|
158
|
+
series.clear()
|
|
159
|
+
|
|
160
|
+
def handle_message(self, stream: StreamLike, message: dict) -> None:
|
|
161
|
+
match message.get("type"):
|
|
162
|
+
case "ping":
|
|
163
|
+
stream.send_json({"type": "pong", "id": message.get("id")})
|
|
164
|
+
rtt = message.get("rtt")
|
|
165
|
+
if isinstance(rtt, (int, float)) and stream is self._active:
|
|
166
|
+
self.peer_rtt_ms.add(float(rtt))
|
|
167
|
+
case "pong":
|
|
168
|
+
ping_id = message.get("id")
|
|
169
|
+
if isinstance(ping_id, int):
|
|
170
|
+
sent = self._pending.pop(ping_id, None)
|
|
171
|
+
if sent is not None:
|
|
172
|
+
self.rtt_ms.add((self._clock() - sent) * 1000.0)
|
|
173
|
+
|
|
174
|
+
def _on_tick(self) -> None:
|
|
175
|
+
now = self._clock()
|
|
176
|
+
delta_sent = delta_received = 0
|
|
177
|
+
for stream in self._streams:
|
|
178
|
+
base_sent, base_received = self._baselines[stream]
|
|
179
|
+
delta_sent += stream.bytes_sent - base_sent
|
|
180
|
+
delta_received += stream.bytes_received - base_received
|
|
181
|
+
self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
|
|
182
|
+
if self._last_tick is not None and (elapsed := now - self._last_tick) > 0:
|
|
183
|
+
self.send_bps.add(delta_sent / elapsed)
|
|
184
|
+
self.recv_bps.add(delta_received / elapsed)
|
|
185
|
+
self._last_tick = now
|
|
186
|
+
|
|
187
|
+
if self._active is not None:
|
|
188
|
+
for ping_id in [i for i, t in self._pending.items() if now - t > _PING_PRUNE_SECONDS]:
|
|
189
|
+
del self._pending[ping_id]
|
|
190
|
+
ping_id = next(self._ids)
|
|
191
|
+
self._pending[ping_id] = now
|
|
192
|
+
ping: dict = {"type": "ping", "id": ping_id}
|
|
193
|
+
if (rtt := self.rtt_ms.latest()) is not None:
|
|
194
|
+
ping["rtt"] = rtt
|
|
195
|
+
self._active.send_json(ping)
|
|
196
|
+
|
|
197
|
+
self._tick_count += 1
|
|
198
|
+
if self._tick_count % _HEARTBEAT_TICKS == 0:
|
|
199
|
+
_log.debug(
|
|
200
|
+
"send %.1f KB/s, recv %.1f KB/s, rtt %s, peer rtt %s, %d stream(s)",
|
|
201
|
+
(self.send_bps.latest() or 0.0) / 1024,
|
|
202
|
+
(self.recv_bps.latest() or 0.0) / 1024,
|
|
203
|
+
f"{rtt:.1f} ms" if (rtt := self.rtt_ms.latest()) is not None else "n/a",
|
|
204
|
+
f"{peer:.1f} ms" if (peer := self.peer_rtt_ms.latest()) is not None else "n/a",
|
|
205
|
+
len(self._streams),
|
|
206
|
+
)
|
|
207
|
+
self.updated.emit()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def format_rate(bytes_per_second: float) -> str:
|
|
211
|
+
if bytes_per_second >= 1024 * 1024:
|
|
212
|
+
return f"{bytes_per_second / (1024 * 1024):.1f} MB/s"
|
|
213
|
+
if bytes_per_second >= 1024:
|
|
214
|
+
return f"{bytes_per_second / 1024:.1f} KB/s"
|
|
215
|
+
return f"{bytes_per_second:.0f} B/s"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def format_ms(ms: float) -> str:
|
|
219
|
+
return f"{ms:.1f} ms"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class GraphWidget(QWidget):
|
|
223
|
+
"""A rolling line graph over the monitor's window, painted with QPainter.
|
|
224
|
+
|
|
225
|
+
Holds references to the monitor's live MetricSeries and pulls their data
|
|
226
|
+
inside paintEvent, so a hidden graph does no per-sample work at all.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
title: str,
|
|
232
|
+
series: list[tuple[str, QColor, MetricSeries]],
|
|
233
|
+
monitor: PerformanceMonitor,
|
|
234
|
+
format_value: Callable[[float], str],
|
|
235
|
+
*,
|
|
236
|
+
clock: Callable[[], float] = time.monotonic,
|
|
237
|
+
parent: QWidget | None = None,
|
|
238
|
+
) -> None:
|
|
239
|
+
super().__init__(parent)
|
|
240
|
+
self._title = title
|
|
241
|
+
self._series = series
|
|
242
|
+
self._monitor = monitor
|
|
243
|
+
self._format_value = format_value
|
|
244
|
+
self._clock = clock
|
|
245
|
+
self.setMinimumSize(300, 120)
|
|
246
|
+
|
|
247
|
+
def paintEvent(self, event: QPaintEvent) -> None:
|
|
248
|
+
painter = QPainter(self)
|
|
249
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
250
|
+
palette = self.palette()
|
|
251
|
+
painter.fillRect(self.rect(), palette.base())
|
|
252
|
+
painter.setPen(palette.color(palette.ColorRole.Mid))
|
|
253
|
+
painter.drawRect(self.rect().adjusted(0, 0, -1, -1))
|
|
254
|
+
painter.setPen(palette.color(palette.ColorRole.Text))
|
|
255
|
+
painter.drawText(8, 16, self._title)
|
|
256
|
+
|
|
257
|
+
window = self._monitor.window_seconds
|
|
258
|
+
now = self._clock()
|
|
259
|
+
data = [(label, color, series.samples()) for label, color, series in self._series]
|
|
260
|
+
if not any(samples for _label, _color, samples in data):
|
|
261
|
+
painter.drawText(
|
|
262
|
+
self.rect(), Qt.AlignmentFlag.AlignCenter, "no data"
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
y_max = max(
|
|
266
|
+
(value for _l, _c, samples in data for _t, value in samples), default=1.0
|
|
267
|
+
)
|
|
268
|
+
y_max = max(y_max, 1.0)
|
|
269
|
+
|
|
270
|
+
graph = self.rect().adjusted(8, 24, -8, -20)
|
|
271
|
+
for label, color, samples in data:
|
|
272
|
+
if not samples:
|
|
273
|
+
continue
|
|
274
|
+
polyline = QPolygonF()
|
|
275
|
+
for t, value in samples:
|
|
276
|
+
x = graph.left() + graph.width() * (1 - (now - t) / window)
|
|
277
|
+
y = graph.bottom() - graph.height() * (value / y_max)
|
|
278
|
+
polyline.append(QPointF(x, y))
|
|
279
|
+
painter.setPen(color)
|
|
280
|
+
painter.drawPolyline(polyline)
|
|
281
|
+
|
|
282
|
+
# Legend with latest values along the bottom edge.
|
|
283
|
+
x = graph.left()
|
|
284
|
+
for label, color, samples in data:
|
|
285
|
+
latest = samples[-1][1] if samples else None
|
|
286
|
+
text = f"{label} {self._format_value(latest)}" if latest is not None else f"{label} —"
|
|
287
|
+
painter.setPen(color)
|
|
288
|
+
painter.drawText(x, self.rect().bottom() - 6, text)
|
|
289
|
+
x += painter.fontMetrics().horizontalAdvance(text) + 16
|
|
290
|
+
painter.setPen(palette.color(palette.ColorRole.Text))
|
|
291
|
+
painter.drawText(
|
|
292
|
+
self.rect().right() - 8 - painter.fontMetrics().horizontalAdvance(self._format_value(y_max)),
|
|
293
|
+
16,
|
|
294
|
+
self._format_value(y_max),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class PerformanceTab(QWidget):
|
|
299
|
+
"""Bandwidth and round-trip-time graphs for the current connection.
|
|
300
|
+
|
|
301
|
+
Graphs repaint only while this tab is visible: sampling continues in the
|
|
302
|
+
monitor regardless, but a background tab schedules zero paint work.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def __init__(self, monitor: PerformanceMonitor, parent: QWidget | None = None) -> None:
|
|
306
|
+
super().__init__(parent)
|
|
307
|
+
self._monitor = monitor
|
|
308
|
+
self.bandwidth_graph = GraphWidget(
|
|
309
|
+
"Bandwidth",
|
|
310
|
+
[
|
|
311
|
+
("send", QColor("#2196f3"), monitor.send_bps),
|
|
312
|
+
("receive", QColor("#4caf50"), monitor.recv_bps),
|
|
313
|
+
],
|
|
314
|
+
monitor,
|
|
315
|
+
format_rate,
|
|
316
|
+
)
|
|
317
|
+
self.ping_graph = GraphWidget(
|
|
318
|
+
"Round-trip time",
|
|
319
|
+
[
|
|
320
|
+
("this side", QColor("#ff9800"), monitor.rtt_ms),
|
|
321
|
+
("peer", QColor("#9c27b0"), monitor.peer_rtt_ms),
|
|
322
|
+
],
|
|
323
|
+
monitor,
|
|
324
|
+
format_ms,
|
|
325
|
+
)
|
|
326
|
+
layout = QVBoxLayout(self)
|
|
327
|
+
layout.addWidget(self.bandwidth_graph, stretch=1)
|
|
328
|
+
layout.addWidget(self.ping_graph, stretch=1)
|
|
329
|
+
monitor.updated.connect(self._refresh)
|
|
330
|
+
|
|
331
|
+
def _refresh(self) -> None:
|
|
332
|
+
if not self.isVisible():
|
|
333
|
+
return # hidden tab page: schedule no paint work at all
|
|
334
|
+
self._refresh_now()
|
|
335
|
+
|
|
336
|
+
def _refresh_now(self) -> None:
|
|
337
|
+
self.bandwidth_graph.update()
|
|
338
|
+
self.ping_graph.update()
|
|
339
|
+
|
|
340
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
341
|
+
super().showEvent(event)
|
|
342
|
+
self._refresh_now() # repaint with the history accrued while hidden
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The Preferences tab: user-adjustable settings, persisted in `Settings`.
|
|
2
|
+
|
|
3
|
+
Currently one preference — the performance-history window length. Values live
|
|
4
|
+
in the shared SQLite settings table (the single-database architecture), so
|
|
5
|
+
they survive restarts and are injectable in tests like every other store.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from PySide6.QtWidgets import QFormLayout, QSpinBox, QWidget
|
|
9
|
+
|
|
10
|
+
from remotedesktop.config import Settings
|
|
11
|
+
from remotedesktop.performance import PerformanceMonitor
|
|
12
|
+
|
|
13
|
+
PERFORMANCE_WINDOW_KEY = "performance_window_seconds"
|
|
14
|
+
DEFAULT_PERFORMANCE_WINDOW_SECONDS = 120
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_performance_window_seconds(settings: Settings) -> int:
|
|
18
|
+
raw = settings.get(PERFORMANCE_WINDOW_KEY, str(DEFAULT_PERFORMANCE_WINDOW_SECONDS))
|
|
19
|
+
try:
|
|
20
|
+
value = int(raw) # ty: ignore[invalid-argument-type]
|
|
21
|
+
except (TypeError, ValueError):
|
|
22
|
+
return DEFAULT_PERFORMANCE_WINDOW_SECONDS
|
|
23
|
+
return value if value > 0 else DEFAULT_PERFORMANCE_WINDOW_SECONDS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PreferencesTab(QWidget):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
settings: Settings,
|
|
30
|
+
performance: PerformanceMonitor,
|
|
31
|
+
parent: QWidget | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(parent)
|
|
34
|
+
self._settings = settings
|
|
35
|
+
self._performance = performance
|
|
36
|
+
self.history_minutes = QSpinBox()
|
|
37
|
+
self.history_minutes.setRange(1, 30)
|
|
38
|
+
self.history_minutes.setSuffix(" min")
|
|
39
|
+
self.history_minutes.setValue(
|
|
40
|
+
max(1, round(load_performance_window_seconds(settings) / 60))
|
|
41
|
+
)
|
|
42
|
+
self.history_minutes.valueChanged.connect(self._on_history_changed)
|
|
43
|
+
layout = QFormLayout(self)
|
|
44
|
+
layout.addRow("Performance history", self.history_minutes)
|
|
45
|
+
|
|
46
|
+
def _on_history_changed(self, minutes: int) -> None:
|
|
47
|
+
seconds = minutes * 60
|
|
48
|
+
self._settings.set(PERFORMANCE_WINDOW_KEY, str(seconds))
|
|
49
|
+
self._performance.set_window_seconds(float(seconds))
|
|
@@ -44,6 +44,10 @@ class MessageStream(QObject):
|
|
|
44
44
|
super().__init__(parent)
|
|
45
45
|
self.socket = socket
|
|
46
46
|
self.max_payload = max_payload
|
|
47
|
+
# Framed application bytes (headers included, TLS overhead excluded),
|
|
48
|
+
# sampled by the performance monitor to compute bandwidth.
|
|
49
|
+
self.bytes_sent = 0
|
|
50
|
+
self.bytes_received = 0
|
|
47
51
|
socket.readyRead.connect(self._on_ready_read)
|
|
48
52
|
|
|
49
53
|
def send_json(self, message: dict) -> None:
|
|
@@ -56,6 +60,7 @@ class MessageStream(QObject):
|
|
|
56
60
|
self._send(_KIND_DELTA, payload)
|
|
57
61
|
|
|
58
62
|
def _send(self, kind: int, payload: bytes) -> None:
|
|
63
|
+
self.bytes_sent += _HEADER.size + len(payload)
|
|
59
64
|
self.socket.write(_HEADER.pack(len(payload), kind) + payload)
|
|
60
65
|
|
|
61
66
|
def _on_ready_read(self) -> None:
|
|
@@ -79,6 +84,7 @@ class MessageStream(QObject):
|
|
|
79
84
|
return
|
|
80
85
|
self.socket.read(_HEADER.size)
|
|
81
86
|
payload = self.socket.read(length).data()
|
|
87
|
+
self.bytes_received += _HEADER.size + length
|
|
82
88
|
if kind == _KIND_JSON:
|
|
83
89
|
try:
|
|
84
90
|
message = json.loads(bytes(payload).decode())
|