remotedesktop 0.7.0__tar.gz → 0.8.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.8.0}/CLAUDE.md +1 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/PKG-INFO +6 -2
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/readme.md +5 -1
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/client.py +11 -1
- remotedesktop-0.8.0/src/remotedesktop/performance.py +342 -0
- remotedesktop-0.8.0/src/remotedesktop/preferences.py +49 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/protocol.py +6 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/server.py +52 -2
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/sharing.py +31 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_client_window.py +4 -0
- remotedesktop-0.8.0/tests/test_performance.py +250 -0
- remotedesktop-0.8.0/tests/test_preferences.py +31 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_server_window.py +41 -1
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/.gitignore +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/LICENSE +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/pyproject.toml +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/run_claude.bat +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/run_client.bat +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/run_server.bat +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/conftest.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_config.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_input.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.0}/tests/test_window_state.py +0 -0
- {remotedesktop-0.7.0 → remotedesktop-0.8.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.8.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
|
|
@@ -28,6 +28,8 @@ 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
|
|
|
@@ -93,6 +95,11 @@ class ClientWindow(QMainWindow):
|
|
|
93
95
|
self.setWindowTitle("Remote Desktop Client")
|
|
94
96
|
# Tests inject a connection to a temp database; the app uses the default.
|
|
95
97
|
self._db = connection if connection is not None else db.connect(default_db_path())
|
|
98
|
+
self._settings = Settings(self._db)
|
|
99
|
+
self.performance = PerformanceMonitor(
|
|
100
|
+
window_seconds=float(load_performance_window_seconds(self._settings)),
|
|
101
|
+
parent=self,
|
|
102
|
+
)
|
|
96
103
|
self.viewer = ViewerWidget(self)
|
|
97
104
|
self.inventory = ConnectionInventory(self._db, "client_peers", self)
|
|
98
105
|
tabs = QTabWidget()
|
|
@@ -101,6 +108,7 @@ class ClientWindow(QMainWindow):
|
|
|
101
108
|
InventoryTab(self.inventory, "Forget server", self._forget_server),
|
|
102
109
|
"Servers on LAN",
|
|
103
110
|
)
|
|
111
|
+
tabs.addTab(PerformanceTab(self.performance), "Performance")
|
|
104
112
|
self.setCentralWidget(tabs)
|
|
105
113
|
|
|
106
114
|
self.discovery_panel = DiscoveryPanel(self)
|
|
@@ -112,6 +120,7 @@ class ClientWindow(QMainWindow):
|
|
|
112
120
|
self.connection_log.setReadOnly(True)
|
|
113
121
|
self.connection_log.setMaximumBlockCount(1000)
|
|
114
122
|
tabs.addTab(self.connection_log, "Connection log")
|
|
123
|
+
tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
|
|
115
124
|
|
|
116
125
|
self.discovery_panel.serverActivated.connect(self._on_server_activated)
|
|
117
126
|
self.discovery_panel.serversFound.connect(self._record_discovered)
|
|
@@ -128,7 +137,6 @@ class ClientWindow(QMainWindow):
|
|
|
128
137
|
self._server_key = ""
|
|
129
138
|
self._frame_count = 0
|
|
130
139
|
self.statusBar().showMessage("Not connected")
|
|
131
|
-
self._settings = Settings(self._db)
|
|
132
140
|
window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
|
|
133
141
|
self.log("Client started")
|
|
134
142
|
# Tests pass auto_scan=False so window tests never broadcast on the LAN.
|
|
@@ -180,10 +188,12 @@ class ClientWindow(QMainWindow):
|
|
|
180
188
|
self._server_key, "attempt", name=server.name,
|
|
181
189
|
address=self._server_key, detail=self._server_key,
|
|
182
190
|
)
|
|
191
|
+
self.performance.reset() # graphs show only the current connection
|
|
183
192
|
client = ShareClient(
|
|
184
193
|
identity=self._identity,
|
|
185
194
|
known_servers=self._known_servers,
|
|
186
195
|
clipboard=self._clipboard,
|
|
196
|
+
performance=self.performance,
|
|
187
197
|
parent=self,
|
|
188
198
|
)
|
|
189
199
|
self._client = client
|
|
@@ -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())
|
|
@@ -7,7 +7,7 @@ import sqlite3
|
|
|
7
7
|
import sys
|
|
8
8
|
import time
|
|
9
9
|
|
|
10
|
-
from PySide6.QtCore import Qt
|
|
10
|
+
from PySide6.QtCore import QProcess, Qt
|
|
11
11
|
from PySide6.QtGui import QCloseEvent
|
|
12
12
|
from PySide6.QtWidgets import (
|
|
13
13
|
QApplication,
|
|
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
|
|
|
16
16
|
QMainWindow,
|
|
17
17
|
QMessageBox,
|
|
18
18
|
QPlainTextEdit,
|
|
19
|
+
QPushButton,
|
|
19
20
|
QTabWidget,
|
|
20
21
|
QVBoxLayout,
|
|
21
22
|
QWidget,
|
|
@@ -31,6 +32,8 @@ from remotedesktop.discovery import (
|
|
|
31
32
|
DiscoveryResponder,
|
|
32
33
|
)
|
|
33
34
|
from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
35
|
+
from remotedesktop.performance import PerformanceMonitor, PerformanceTab
|
|
36
|
+
from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
|
|
34
37
|
from remotedesktop.sharing import ShareServer
|
|
35
38
|
|
|
36
39
|
_log = logging.getLogger("remotedesktop.server")
|
|
@@ -60,14 +63,27 @@ class ServerWindow(QMainWindow):
|
|
|
60
63
|
self.connection_log = QPlainTextEdit()
|
|
61
64
|
self.connection_log.setReadOnly(True)
|
|
62
65
|
self.connection_log.setMaximumBlockCount(1000)
|
|
66
|
+
self.restart_button = QPushButton("Restart server")
|
|
67
|
+
self.restart_button.setToolTip(
|
|
68
|
+
"Relaunch this app (e.g. after updating the software). It can be "
|
|
69
|
+
"clicked from a remote desktop session, so an update doesn't "
|
|
70
|
+
"require visiting this computer."
|
|
71
|
+
)
|
|
72
|
+
self.restart_button.clicked.connect(self._restart_server)
|
|
63
73
|
status_tab = QWidget()
|
|
64
74
|
status_layout = QVBoxLayout(status_tab)
|
|
65
75
|
status_layout.addWidget(self._summary)
|
|
66
76
|
status_layout.addWidget(self.autostart_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
77
|
+
status_layout.addWidget(self.restart_button, alignment=Qt.AlignmentFlag.AlignHCenter)
|
|
67
78
|
status_layout.addStretch(1)
|
|
68
79
|
|
|
69
80
|
# Tests inject a connection to a temp database; the app uses the default.
|
|
70
81
|
self._db = connection if connection is not None else db.connect(default_db_path())
|
|
82
|
+
self._settings = Settings(self._db)
|
|
83
|
+
self.performance = PerformanceMonitor(
|
|
84
|
+
window_seconds=float(load_performance_window_seconds(self._settings)),
|
|
85
|
+
parent=self,
|
|
86
|
+
)
|
|
71
87
|
self.inventory = ConnectionInventory(self._db, "server_peers", self)
|
|
72
88
|
tabs = QTabWidget()
|
|
73
89
|
tabs.addTab(status_tab, "Status")
|
|
@@ -75,7 +91,9 @@ class ServerWindow(QMainWindow):
|
|
|
75
91
|
InventoryTab(self.inventory, "Revoke access", self._revoke_client),
|
|
76
92
|
"Clients on LAN",
|
|
77
93
|
)
|
|
94
|
+
tabs.addTab(PerformanceTab(self.performance), "Performance")
|
|
78
95
|
tabs.addTab(self.connection_log, "Connection log")
|
|
96
|
+
tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
|
|
79
97
|
self.setCentralWidget(tabs)
|
|
80
98
|
|
|
81
99
|
if credentials is None:
|
|
@@ -91,6 +109,7 @@ class ServerWindow(QMainWindow):
|
|
|
91
109
|
credentials=credentials,
|
|
92
110
|
paired=paired,
|
|
93
111
|
clipboard=self._clipboard,
|
|
112
|
+
performance=self.performance,
|
|
94
113
|
parent=self,
|
|
95
114
|
)
|
|
96
115
|
self.share_server.status.connect(self.log)
|
|
@@ -119,7 +138,6 @@ class ServerWindow(QMainWindow):
|
|
|
119
138
|
f"(UDP port {discovery_port}, TCP port {self.share_server.port})"
|
|
120
139
|
)
|
|
121
140
|
self._update_summary(0)
|
|
122
|
-
self._settings = Settings(self._db)
|
|
123
141
|
window_state.restore_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
|
|
124
142
|
|
|
125
143
|
def log(self, message: str) -> None:
|
|
@@ -169,6 +187,38 @@ class ServerWindow(QMainWindow):
|
|
|
169
187
|
if answer == QMessageBox.StandardButton.Yes:
|
|
170
188
|
self.share_server.revoke_client(client_id)
|
|
171
189
|
|
|
190
|
+
def _restart_server(self) -> None:
|
|
191
|
+
"""Relaunch this app in a new process and exit.
|
|
192
|
+
|
|
193
|
+
Meant to be clicked through a remote desktop session after updating
|
|
194
|
+
the software, so the new version starts without anyone at this
|
|
195
|
+
computer. The listening sockets are closed before spawning so the
|
|
196
|
+
replacement can bind the same ports.
|
|
197
|
+
"""
|
|
198
|
+
answer = QMessageBox.question(
|
|
199
|
+
self,
|
|
200
|
+
"Restart server",
|
|
201
|
+
"Restart the server app?\n\n"
|
|
202
|
+
"Viewers will be disconnected and can reconnect in a few seconds "
|
|
203
|
+
"(approved clients reconnect without a new permission prompt).",
|
|
204
|
+
)
|
|
205
|
+
if answer != QMessageBox.StandardButton.Yes:
|
|
206
|
+
return
|
|
207
|
+
self.log("Restarting: freeing ports and launching a new server process")
|
|
208
|
+
_log.info("Restart requested — relaunching %s -m remotedesktop.server", sys.executable)
|
|
209
|
+
if self.responder is not None:
|
|
210
|
+
self.responder.stop()
|
|
211
|
+
self.responder = None
|
|
212
|
+
self.share_server.close()
|
|
213
|
+
if not QProcess.startDetached(sys.executable, ["-m", "remotedesktop.server"]):
|
|
214
|
+
# Extremely unlikely (sys.executable exists); the app stays open —
|
|
215
|
+
# sharing is stopped, but the machine isn't left with nothing.
|
|
216
|
+
self.log("Restart failed: could not launch a new process — restart manually")
|
|
217
|
+
_log.error("QProcess.startDetached failed for %s", sys.executable)
|
|
218
|
+
return
|
|
219
|
+
self.close()
|
|
220
|
+
QApplication.quit()
|
|
221
|
+
|
|
172
222
|
def _ask_approval(self, client_id: str, client_name: str) -> bool:
|
|
173
223
|
box = QMessageBox(
|
|
174
224
|
QMessageBox.Icon.Question,
|
|
@@ -40,6 +40,7 @@ from remotedesktop.config import (
|
|
|
40
40
|
from remotedesktop.discovery import DEFAULT_CONNECT_PORT
|
|
41
41
|
from remotedesktop import frames
|
|
42
42
|
from remotedesktop.input_injection import InputInjector
|
|
43
|
+
from remotedesktop.performance import PerformanceMonitor
|
|
43
44
|
from remotedesktop.protocol import MAX_PAYLOAD, PROTOCOL_VERSION, MessageStream
|
|
44
45
|
from remotedesktop import db, tls
|
|
45
46
|
|
|
@@ -87,9 +88,11 @@ class ShareServer(QObject):
|
|
|
87
88
|
fps: int = DEFAULT_FPS,
|
|
88
89
|
injector: InputInjector | None = None,
|
|
89
90
|
clipboard=None,
|
|
91
|
+
performance: PerformanceMonitor | None = None,
|
|
90
92
|
parent: QObject | None = None,
|
|
91
93
|
) -> None:
|
|
92
94
|
super().__init__(parent)
|
|
95
|
+
self._performance = performance
|
|
93
96
|
self._approve_client = approve_client
|
|
94
97
|
self._paired = paired if paired is not None else PairedClients(db.connect(default_db_path()))
|
|
95
98
|
self._fps = fps
|
|
@@ -170,6 +173,8 @@ class ShareServer(QObject):
|
|
|
170
173
|
self._previous_frame = None
|
|
171
174
|
if had_clients:
|
|
172
175
|
self.clientCountChanged.emit(0)
|
|
176
|
+
if self._performance is not None:
|
|
177
|
+
self._performance.reset()
|
|
173
178
|
self._server.close()
|
|
174
179
|
self.status.emit("Server closed")
|
|
175
180
|
|
|
@@ -198,6 +203,11 @@ class ShareServer(QObject):
|
|
|
198
203
|
stream.jsonReceived.connect(lambda m, s=stream: self._on_message(s, m))
|
|
199
204
|
|
|
200
205
|
def _on_message(self, stream: MessageStream, message: dict) -> None:
|
|
206
|
+
if message.get("type") in ("ping", "pong"):
|
|
207
|
+
# Same admission rule as input/clipboard: pre-auth peers get nothing.
|
|
208
|
+
if stream in self._streams and self._performance is not None:
|
|
209
|
+
self._performance.handle_message(stream, message)
|
|
210
|
+
return
|
|
201
211
|
if message.get("type") == "input":
|
|
202
212
|
if stream in self._streams:
|
|
203
213
|
self._inject(stream, message)
|
|
@@ -330,6 +340,8 @@ class ShareServer(QObject):
|
|
|
330
340
|
stream.send_json(welcome)
|
|
331
341
|
self._needs_keyframe.add(stream) # its first frame must be a full one
|
|
332
342
|
self._streams.append(stream)
|
|
343
|
+
if self._performance is not None:
|
|
344
|
+
self._performance.add_stream(stream)
|
|
333
345
|
self.clientCountChanged.emit(len(self._streams))
|
|
334
346
|
self.status.emit(
|
|
335
347
|
f'Streaming screen to "{client_name}" at {self._fps} fps '
|
|
@@ -396,6 +408,10 @@ class ShareServer(QObject):
|
|
|
396
408
|
stream.send_json(message)
|
|
397
409
|
|
|
398
410
|
def _drop(self, stream: MessageStream) -> None:
|
|
411
|
+
# Detach from the monitor before the deferred delete below, so it
|
|
412
|
+
# never samples a deleted stream (idempotent for never-admitted ones).
|
|
413
|
+
if self._performance is not None:
|
|
414
|
+
self._performance.remove_stream(stream)
|
|
399
415
|
self._controllers.discard(stream)
|
|
400
416
|
self._all_streams.discard(stream)
|
|
401
417
|
self._prompting.discard(stream)
|
|
@@ -511,9 +527,11 @@ class ShareClient(QObject):
|
|
|
511
527
|
*,
|
|
512
528
|
known_servers: KnownServers | None = None,
|
|
513
529
|
clipboard=None,
|
|
530
|
+
performance: PerformanceMonitor | None = None,
|
|
514
531
|
parent: QObject | None = None,
|
|
515
532
|
) -> None:
|
|
516
533
|
super().__init__(parent)
|
|
534
|
+
self._performance = performance
|
|
517
535
|
self._client_id, self._name = identity or load_client_identity(
|
|
518
536
|
db.connect(default_db_path())
|
|
519
537
|
)
|
|
@@ -545,6 +563,9 @@ class ShareClient(QObject):
|
|
|
545
563
|
|
|
546
564
|
def connect_to(self, host: str, port: int) -> None:
|
|
547
565
|
self._socket.abort() # drop any previous connection or attempt
|
|
566
|
+
if self._performance is not None:
|
|
567
|
+
# abort() emits no disconnected signal, so detach explicitly.
|
|
568
|
+
self._performance.remove_stream(self._stream)
|
|
548
569
|
self._got_first_frame = False
|
|
549
570
|
self._frame_count = 0
|
|
550
571
|
self._last_image = None
|
|
@@ -600,6 +621,8 @@ class ShareClient(QObject):
|
|
|
600
621
|
self._stream.send_json(hello)
|
|
601
622
|
|
|
602
623
|
def _on_disconnected(self) -> None:
|
|
624
|
+
if self._performance is not None:
|
|
625
|
+
self._performance.remove_stream(self._stream)
|
|
603
626
|
self.status.emit("Disconnected from server")
|
|
604
627
|
self.disconnected.emit()
|
|
605
628
|
|
|
@@ -626,6 +649,11 @@ class ShareClient(QObject):
|
|
|
626
649
|
f'Server "{name}" accepted the connection — waiting for first frame'
|
|
627
650
|
)
|
|
628
651
|
self.connected.emit(name)
|
|
652
|
+
if self._performance is not None:
|
|
653
|
+
# Attach only once admitted: handshake traffic is never
|
|
654
|
+
# sampled and no ping goes to a server that hasn't
|
|
655
|
+
# welcomed us.
|
|
656
|
+
self._performance.add_stream(self._stream)
|
|
629
657
|
case "pending":
|
|
630
658
|
self.status.emit(
|
|
631
659
|
"Server is asking its user for permission — waiting for approval"
|
|
@@ -639,6 +667,9 @@ class ShareClient(QObject):
|
|
|
639
667
|
if self._clipboard is not None:
|
|
640
668
|
self.status.emit("Clipboard update received from server")
|
|
641
669
|
self._clipboard.apply(message)
|
|
670
|
+
case "ping" | "pong":
|
|
671
|
+
if self._performance is not None:
|
|
672
|
+
self._performance.handle_message(self._stream, message)
|
|
642
673
|
|
|
643
674
|
def _on_frame(self, data: bytes) -> None:
|
|
644
675
|
# Full frame — PNG keyframe or (legacy servers) JPEG; sniffed from
|
|
@@ -30,6 +30,10 @@ def test_window_starts_disconnected(qapp, tmp_path):
|
|
|
30
30
|
window = make_window(tmp_path)
|
|
31
31
|
assert window.statusBar().currentMessage() == "Not connected"
|
|
32
32
|
assert "Client started" in window.connection_log.toPlainText()
|
|
33
|
+
tabs = window.centralWidget()
|
|
34
|
+
labels = [tabs.tabText(i) for i in range(tabs.count())]
|
|
35
|
+
assert "Performance" in labels and "Preferences" in labels
|
|
36
|
+
assert not window.performance._timer.isActive() # idle: no periodic work
|
|
33
37
|
window.close()
|
|
34
38
|
|
|
35
39
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from PySide6.QtGui import QShowEvent
|
|
5
|
+
from PySide6.QtNetwork import QHostAddress, QTcpServer, QTcpSocket
|
|
6
|
+
from PySide6.QtWidgets import QApplication
|
|
7
|
+
|
|
8
|
+
from remotedesktop import db
|
|
9
|
+
from remotedesktop.config import KnownServers, PairedClients
|
|
10
|
+
from remotedesktop.performance import (
|
|
11
|
+
GraphWidget,
|
|
12
|
+
MetricSeries,
|
|
13
|
+
PerformanceMonitor,
|
|
14
|
+
PerformanceTab,
|
|
15
|
+
)
|
|
16
|
+
from remotedesktop.protocol import MessageStream
|
|
17
|
+
from remotedesktop.sharing import ShareClient, ShareServer
|
|
18
|
+
|
|
19
|
+
from test_sharing import IDENTITY, pump
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeClock:
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self.t = 1000.0
|
|
25
|
+
|
|
26
|
+
def __call__(self) -> float:
|
|
27
|
+
return self.t
|
|
28
|
+
|
|
29
|
+
def advance(self, dt: float) -> None:
|
|
30
|
+
self.t += dt
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FakeStream:
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.bytes_sent = 0
|
|
36
|
+
self.bytes_received = 0
|
|
37
|
+
self.sent: list[dict] = []
|
|
38
|
+
|
|
39
|
+
def send_json(self, message: dict) -> None:
|
|
40
|
+
self.sent.append(message)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_metric_series_trims_to_window():
|
|
44
|
+
clock = FakeClock()
|
|
45
|
+
series = MetricSeries(10.0, clock=clock)
|
|
46
|
+
series.add(1.0)
|
|
47
|
+
clock.advance(5)
|
|
48
|
+
series.add(2.0)
|
|
49
|
+
clock.advance(6) # first sample is now 11 s old, second 6 s
|
|
50
|
+
assert [value for _t, value in series.samples()] == [2.0]
|
|
51
|
+
series.set_window(1.0) # tightening the window re-trims immediately
|
|
52
|
+
assert series.samples() == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_monitor_samples_aggregate_bandwidth(qapp):
|
|
56
|
+
clock = FakeClock()
|
|
57
|
+
monitor = PerformanceMonitor(window_seconds=60, clock=clock)
|
|
58
|
+
first, second = FakeStream(), FakeStream()
|
|
59
|
+
monitor.add_stream(first)
|
|
60
|
+
monitor._on_tick() # first tick establishes the baseline; no rate sample
|
|
61
|
+
assert monitor.send_bps.latest() is None
|
|
62
|
+
first.bytes_sent += 1000
|
|
63
|
+
first.bytes_received += 500
|
|
64
|
+
clock.advance(2.0)
|
|
65
|
+
monitor._on_tick()
|
|
66
|
+
assert monitor.send_bps.latest() == 500.0
|
|
67
|
+
assert monitor.recv_bps.latest() == 250.0
|
|
68
|
+
# A second stream aggregates; removing it never yields a negative rate.
|
|
69
|
+
monitor.add_stream(second)
|
|
70
|
+
second.bytes_sent += 300
|
|
71
|
+
clock.advance(1.0)
|
|
72
|
+
monitor._on_tick()
|
|
73
|
+
assert monitor.send_bps.latest() == 300.0
|
|
74
|
+
monitor.remove_stream(second)
|
|
75
|
+
clock.advance(1.0)
|
|
76
|
+
monitor._on_tick()
|
|
77
|
+
assert monitor.send_bps.latest() == 0.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_monitor_skips_rate_on_zero_elapsed(qapp):
|
|
81
|
+
monitor = PerformanceMonitor(clock=FakeClock()) # clock never advances
|
|
82
|
+
monitor.add_stream(FakeStream())
|
|
83
|
+
monitor._on_tick()
|
|
84
|
+
monitor._on_tick()
|
|
85
|
+
assert monitor.send_bps.latest() is None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_ping_pong_records_rtt_and_replies(qapp):
|
|
89
|
+
clock = FakeClock()
|
|
90
|
+
monitor = PerformanceMonitor(clock=clock)
|
|
91
|
+
stream = FakeStream()
|
|
92
|
+
monitor.add_stream(stream)
|
|
93
|
+
monitor._on_tick()
|
|
94
|
+
ping = stream.sent[-1]
|
|
95
|
+
assert ping["type"] == "ping"
|
|
96
|
+
assert "rtt" not in ping # nothing measured yet
|
|
97
|
+
clock.advance(0.05)
|
|
98
|
+
monitor.handle_message(stream, {"type": "pong", "id": ping["id"]})
|
|
99
|
+
assert monitor.rtt_ms.latest() == pytest.approx(50.0)
|
|
100
|
+
clock.advance(1.0)
|
|
101
|
+
monitor._on_tick()
|
|
102
|
+
assert stream.sent[-1]["rtt"] == pytest.approx(50.0) # piggybacked
|
|
103
|
+
# An incoming ping is answered with an echoing pong and records peer RTT.
|
|
104
|
+
monitor.handle_message(stream, {"type": "ping", "id": 99, "rtt": 12.5})
|
|
105
|
+
assert stream.sent[-1] == {"type": "pong", "id": 99}
|
|
106
|
+
assert monitor.peer_rtt_ms.latest() == 12.5
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_stale_or_malformed_pongs_are_ignored(qapp):
|
|
110
|
+
monitor = PerformanceMonitor(clock=FakeClock())
|
|
111
|
+
stream = FakeStream()
|
|
112
|
+
monitor.add_stream(stream)
|
|
113
|
+
monitor.handle_message(stream, {"type": "pong", "id": 12345})
|
|
114
|
+
monitor.handle_message(stream, {"type": "pong", "id": "weird"})
|
|
115
|
+
monitor.handle_message(stream, {"type": "pong"})
|
|
116
|
+
assert monitor.rtt_ms.latest() is None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_reset_clears_everything_and_stops_timer(qapp):
|
|
120
|
+
clock = FakeClock()
|
|
121
|
+
monitor = PerformanceMonitor(clock=clock)
|
|
122
|
+
monitor.add_stream(FakeStream())
|
|
123
|
+
monitor._on_tick()
|
|
124
|
+
clock.advance(1.0)
|
|
125
|
+
monitor._on_tick()
|
|
126
|
+
assert monitor._timer.isActive()
|
|
127
|
+
monitor.reset()
|
|
128
|
+
assert not monitor._timer.isActive()
|
|
129
|
+
assert monitor.send_bps.samples() == []
|
|
130
|
+
assert monitor.rtt_ms.samples() == []
|
|
131
|
+
assert monitor._pending == {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_message_stream_counts_framed_bytes(qapp):
|
|
135
|
+
listener = QTcpServer()
|
|
136
|
+
assert listener.listen(QHostAddress.SpecialAddress.LocalHost, 0)
|
|
137
|
+
out_sock = QTcpSocket()
|
|
138
|
+
out_sock.connectToHost("127.0.0.1", listener.serverPort())
|
|
139
|
+
pump(qapp, lambda: listener.hasPendingConnections())
|
|
140
|
+
in_sock = listener.nextPendingConnection()
|
|
141
|
+
sender, receiver = MessageStream(out_sock), MessageStream(in_sock)
|
|
142
|
+
got = []
|
|
143
|
+
receiver.jsonReceived.connect(got.append)
|
|
144
|
+
receiver.frameReceived.connect(got.append)
|
|
145
|
+
try:
|
|
146
|
+
sender.send_json({"a": 1})
|
|
147
|
+
sender.send_frame(b"xyz")
|
|
148
|
+
pump(qapp, lambda: len(got) == 2)
|
|
149
|
+
# 4-byte length + 1 kind byte per message, plus the payloads.
|
|
150
|
+
expected = (5 + len(json.dumps({"a": 1}).encode())) + (5 + 3)
|
|
151
|
+
assert sender.bytes_sent == expected
|
|
152
|
+
assert receiver.bytes_received == expected
|
|
153
|
+
finally:
|
|
154
|
+
out_sock.abort()
|
|
155
|
+
listener.close()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def make_pair(credentials, tmp_path, *, server_perf, client_perf):
|
|
159
|
+
server = ShareServer(
|
|
160
|
+
approve_client=lambda *_: True,
|
|
161
|
+
credentials=credentials,
|
|
162
|
+
paired=PairedClients(db.connect(tmp_path / "server.db")),
|
|
163
|
+
performance=server_perf,
|
|
164
|
+
)
|
|
165
|
+
assert server.listen(0)
|
|
166
|
+
client = ShareClient(
|
|
167
|
+
identity=IDENTITY,
|
|
168
|
+
known_servers=KnownServers(db.connect(tmp_path / "client.db")),
|
|
169
|
+
performance=client_perf,
|
|
170
|
+
)
|
|
171
|
+
return server, client
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_rtt_and_bandwidth_measured_on_both_ends(qapp, credentials, tmp_path):
|
|
175
|
+
server_perf = PerformanceMonitor(interval_ms=50)
|
|
176
|
+
client_perf = PerformanceMonitor(interval_ms=50)
|
|
177
|
+
server, client = make_pair(
|
|
178
|
+
credentials, tmp_path, server_perf=server_perf, client_perf=client_perf
|
|
179
|
+
)
|
|
180
|
+
client.connect_to("127.0.0.1", server.port)
|
|
181
|
+
try:
|
|
182
|
+
# Both sides measure their own RTT ...
|
|
183
|
+
pump(qapp, lambda: client_perf.rtt_ms.latest() is not None)
|
|
184
|
+
pump(qapp, lambda: server_perf.rtt_ms.latest() is not None)
|
|
185
|
+
# ... and learn the peer's measurement from piggybacked pings.
|
|
186
|
+
pump(qapp, lambda: client_perf.peer_rtt_ms.latest() is not None)
|
|
187
|
+
pump(qapp, lambda: server_perf.peer_rtt_ms.latest() is not None)
|
|
188
|
+
for series in (client_perf.rtt_ms, client_perf.peer_rtt_ms):
|
|
189
|
+
assert all(value >= 0 for _t, value in series.samples())
|
|
190
|
+
# Bandwidth flows: frames server->client, pings both ways.
|
|
191
|
+
pump(qapp, lambda: client_perf.recv_bps.latest() is not None)
|
|
192
|
+
pump(qapp, lambda: server_perf.send_bps.latest() is not None)
|
|
193
|
+
assert any(value > 0 for _t, value in client_perf.recv_bps.samples())
|
|
194
|
+
finally:
|
|
195
|
+
client.close()
|
|
196
|
+
server.close()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_old_peer_leaves_rtt_series_empty(qapp, credentials, tmp_path):
|
|
200
|
+
# A performance-less server takes the exact code path a 0.7.0 server
|
|
201
|
+
# takes for ping messages: silently dropped, never answered.
|
|
202
|
+
client_perf = PerformanceMonitor(interval_ms=50)
|
|
203
|
+
server, client = make_pair(
|
|
204
|
+
credentials, tmp_path, server_perf=None, client_perf=client_perf
|
|
205
|
+
)
|
|
206
|
+
frames = []
|
|
207
|
+
client.frameReceived.connect(frames.append)
|
|
208
|
+
client.connect_to("127.0.0.1", server.port)
|
|
209
|
+
try:
|
|
210
|
+
pump(qapp, lambda: len(client_perf.recv_bps.samples()) >= 2)
|
|
211
|
+
assert client_perf.rtt_ms.latest() is None
|
|
212
|
+
assert client_perf.peer_rtt_ms.latest() is None
|
|
213
|
+
assert frames # unanswered pings don't harm the connection
|
|
214
|
+
finally:
|
|
215
|
+
client.close()
|
|
216
|
+
server.close()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_graph_widgets_render_headless(qapp):
|
|
220
|
+
seeded = PerformanceMonitor()
|
|
221
|
+
seeded.send_bps.add(100.0)
|
|
222
|
+
seeded.send_bps.add(2048.0)
|
|
223
|
+
seeded.recv_bps.add(50.0)
|
|
224
|
+
for monitor in (seeded, PerformanceMonitor()): # data and "no data" paths
|
|
225
|
+
tab = PerformanceTab(monitor)
|
|
226
|
+
tab.resize(400, 300)
|
|
227
|
+
pixmap = tab.grab()
|
|
228
|
+
assert not pixmap.isNull()
|
|
229
|
+
assert pixmap.width() > 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_tab_schedules_no_paints_while_hidden(qapp, monkeypatch):
|
|
233
|
+
monitor = PerformanceMonitor()
|
|
234
|
+
_tab = PerformanceTab(monitor) # subscribes to monitor.updated
|
|
235
|
+
painted = []
|
|
236
|
+
monkeypatch.setattr(GraphWidget, "update", lambda self: painted.append(self))
|
|
237
|
+
monitor.updated.emit() # tab was never shown -> not visible
|
|
238
|
+
assert painted == []
|
|
239
|
+
monkeypatch.setattr(PerformanceTab, "isVisible", lambda self: True)
|
|
240
|
+
monitor.updated.emit()
|
|
241
|
+
assert len(painted) == 2 # both graphs refreshed
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_tab_refreshes_on_show_event(qapp, monkeypatch):
|
|
245
|
+
monitor = PerformanceMonitor()
|
|
246
|
+
tab = PerformanceTab(monitor)
|
|
247
|
+
painted = []
|
|
248
|
+
monkeypatch.setattr(GraphWidget, "update", lambda self: painted.append(self))
|
|
249
|
+
QApplication.sendEvent(tab, QShowEvent())
|
|
250
|
+
assert len(painted) == 2
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from remotedesktop import db
|
|
2
|
+
from remotedesktop.config import Settings
|
|
3
|
+
from remotedesktop.performance import PerformanceMonitor
|
|
4
|
+
from remotedesktop.preferences import (
|
|
5
|
+
PERFORMANCE_WINDOW_KEY,
|
|
6
|
+
PreferencesTab,
|
|
7
|
+
load_performance_window_seconds,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_default_and_invalid_values_fall_back(tmp_path):
|
|
12
|
+
settings = Settings(db.connect(tmp_path / "prefs.db"))
|
|
13
|
+
assert load_performance_window_seconds(settings) == 120
|
|
14
|
+
settings.set(PERFORMANCE_WINDOW_KEY, "garbage")
|
|
15
|
+
assert load_performance_window_seconds(settings) == 120
|
|
16
|
+
settings.set(PERFORMANCE_WINDOW_KEY, "-5")
|
|
17
|
+
assert load_performance_window_seconds(settings) == 120
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_history_change_persists_and_applies(qapp, tmp_path):
|
|
21
|
+
connection = db.connect(tmp_path / "prefs.db")
|
|
22
|
+
settings = Settings(connection)
|
|
23
|
+
monitor = PerformanceMonitor()
|
|
24
|
+
tab = PreferencesTab(settings, monitor)
|
|
25
|
+
assert tab.history_minutes.value() == 2 # the 2-minute default
|
|
26
|
+
tab.history_minutes.setValue(5) # fires valueChanged
|
|
27
|
+
assert settings.get(PERFORMANCE_WINDOW_KEY) == "300"
|
|
28
|
+
assert monitor.window_seconds == 300.0
|
|
29
|
+
# A fresh tab (new window / restart) reads the persisted value.
|
|
30
|
+
tab2 = PreferencesTab(settings, PerformanceMonitor())
|
|
31
|
+
assert tab2.history_minutes.value() == 5
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import socket
|
|
2
|
+
import sys
|
|
2
3
|
|
|
4
|
+
from PySide6.QtCore import QProcess
|
|
3
5
|
from PySide6.QtNetwork import QHostAddress, QTcpServer
|
|
4
|
-
from PySide6.QtWidgets import QMessageBox
|
|
6
|
+
from PySide6.QtWidgets import QApplication, QMessageBox
|
|
5
7
|
|
|
6
8
|
from remotedesktop import db
|
|
7
9
|
from remotedesktop.autostart import Autostart
|
|
@@ -33,6 +35,10 @@ def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
|
|
|
33
35
|
assert window._discoverable
|
|
34
36
|
assert "Discoverable on this LAN" in window._summary.text()
|
|
35
37
|
assert "Not sharing" in window._summary.text()
|
|
38
|
+
tabs = window.centralWidget()
|
|
39
|
+
labels = [tabs.tabText(i) for i in range(tabs.count())]
|
|
40
|
+
assert "Performance" in labels and "Preferences" in labels
|
|
41
|
+
assert not window.performance._timer.isActive() # idle: no periodic work
|
|
36
42
|
finally:
|
|
37
43
|
window.close()
|
|
38
44
|
|
|
@@ -85,6 +91,40 @@ def test_autostart_checkbox_toggles_registration(qapp, credentials, tmp_path):
|
|
|
85
91
|
window.close()
|
|
86
92
|
|
|
87
93
|
|
|
94
|
+
def test_restart_declined_keeps_serving(qapp, credentials, tmp_path, monkeypatch):
|
|
95
|
+
monkeypatch.setattr(
|
|
96
|
+
QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)
|
|
97
|
+
)
|
|
98
|
+
launches = []
|
|
99
|
+
monkeypatch.setattr(QProcess, "startDetached", staticmethod(lambda *a: launches.append(a)))
|
|
100
|
+
window = make_window(credentials, tmp_path)
|
|
101
|
+
try:
|
|
102
|
+
window._restart_server()
|
|
103
|
+
assert launches == []
|
|
104
|
+
assert window.share_server._server.isListening()
|
|
105
|
+
assert window.responder is not None
|
|
106
|
+
finally:
|
|
107
|
+
window.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_restart_frees_ports_and_relaunches(qapp, credentials, tmp_path, monkeypatch):
|
|
111
|
+
monkeypatch.setattr(
|
|
112
|
+
QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
|
|
113
|
+
)
|
|
114
|
+
launches, quits = [], []
|
|
115
|
+
monkeypatch.setattr(
|
|
116
|
+
QProcess, "startDetached", staticmethod(lambda *a: launches.append(a) or True)
|
|
117
|
+
)
|
|
118
|
+
monkeypatch.setattr(QApplication, "quit", staticmethod(lambda: quits.append(True)))
|
|
119
|
+
window = make_window(credentials, tmp_path)
|
|
120
|
+
window._restart_server()
|
|
121
|
+
# Ports are freed before the new process is spawned, so it can bind them.
|
|
122
|
+
assert window.responder is None
|
|
123
|
+
assert not window.share_server._server.isListening()
|
|
124
|
+
assert launches == [(sys.executable, ["-m", "remotedesktop.server"])]
|
|
125
|
+
assert quits == [True]
|
|
126
|
+
|
|
127
|
+
|
|
88
128
|
def stub_approval_prompt(monkeypatch, button):
|
|
89
129
|
"""Answer _ask_approval's stay-on-top QMessageBox without showing any UI.
|
|
90
130
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|