remotedesktop 0.9.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/CLAUDE.md +2 -0
  2. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/PKG-INFO +1 -1
  3. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/__init__.py +1 -1
  4. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/client.py +25 -1
  5. remotedesktop-0.11.0/src/remotedesktop/logs.py +94 -0
  6. remotedesktop-0.11.0/src/remotedesktop/modal_loop.py +103 -0
  7. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/performance.py +134 -17
  8. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/server.py +30 -1
  9. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/sharing.py +83 -0
  10. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/viewer.py +8 -0
  11. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_client_window.py +23 -0
  12. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_input.py +19 -0
  13. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_logs.py +18 -0
  14. remotedesktop-0.11.0/tests/test_modal_loop.py +68 -0
  15. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_performance.py +136 -0
  16. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_server_window.py +47 -0
  17. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_sharing.py +62 -2
  18. remotedesktop-0.9.0/src/remotedesktop/logs.py +0 -46
  19. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/.github/workflows/ci.yml +0 -0
  20. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/.gitignore +0 -0
  21. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/LICENSE +0 -0
  22. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/pyproject.toml +0 -0
  23. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/readme.md +0 -0
  24. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/run_claude.bat +0 -0
  25. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/run_client.bat +0 -0
  26. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/run_server.bat +0 -0
  27. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/scripts/make_coverage_badge.py +0 -0
  28. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/scripts/make_venv.bat +0 -0
  29. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/autostart.py +0 -0
  30. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/clipboard.py +0 -0
  31. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/config.py +0 -0
  32. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/db.py +0 -0
  33. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/discovery.py +0 -0
  34. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/frames.py +0 -0
  35. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/icon.py +0 -0
  36. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/input_injection.py +0 -0
  37. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/inventory.py +0 -0
  38. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/preferences.py +0 -0
  39. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/protocol.py +0 -0
  40. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/tls.py +0 -0
  41. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/src/remotedesktop/window_state.py +0 -0
  42. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/conftest.py +0 -0
  43. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_autostart.py +0 -0
  44. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_clipboard.py +0 -0
  45. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_config.py +0 -0
  46. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_discovery.py +0 -0
  47. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_frames.py +0 -0
  48. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_icon.py +0 -0
  49. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_input_injection.py +0 -0
  50. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_inventory.py +0 -0
  51. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_preferences.py +0 -0
  52. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_protocol.py +0 -0
  53. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_smoke.py +0 -0
  54. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_tls.py +0 -0
  55. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/tests/test_window_state.py +0 -0
  56. {remotedesktop-0.9.0 → remotedesktop-0.11.0}/uv.lock +0 -0
@@ -47,6 +47,8 @@ Managed with `uv` (hatchling build backend, src layout):
47
47
  - **Revoking access**: `InventoryTab` takes an optional `(action_label, action_callback)`; the callback gets the selected peer's key. The server's tab wires "Revoke access" → `ShareServer.revoke_client(client_id)` (removes the token via `PairedClients.revoke`, disconnects any live stream with a `denied: access revoked`, and `_drop` reports state "revoked"; a `_revoked` set distinguishes it from a plain disconnect). The client's tab wires "Forget server" → `KnownServers.forget(key)` + disconnect. After either, the next connection needs a fresh approval. Server and client each use a *separate table* (`server_peers` / `client_peers`, selected via the `table=` arg) so a machine running both apps doesn't commingle them; `peers` is the default table used by tests. `ConnectionInventory` never lets a DB error break connectivity (load/save are wrapped).
48
48
  - Sharing tests drive real sockets on the GUI thread by pumping `qapp.processEvents()` until a condition holds (see `pump()` in `tests/test_sharing.py`). Use the `make_server`/`make_client` helpers there and the session-scoped `credentials` fixture (one generated cert reused across tests — generating per test is ~100ms each). Tests exercise the real pairing flow (auto-approve), so there is no "pre-approve" shortcut anymore.
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
+ - **Modal-loop pump** (`modal_loop.py`): a Windows title-bar drag (or click-and-hold) puts the window's thread into a native modal move/size loop where Qt's dispatcher stops — for the server this deadlocked when a *remote* click grabbed the server window's own title bar (the mouse-up sat unread on the socket). `ServerWindow.nativeEvent` feeds `ModalLoopPump`, which runs a native `SetTimer` between `WM_ENTERSIZEMOVE`/`WM_EXITSIZEMOVE` whose callback pumps Qt events (user input excluded). Tests inject a fake `timers` backend — never let them create real Win32 timers.
51
+ - **Log exchange**: either side can fetch the peer's debug log over the connection (`{"type": "log_request"}` → `{"type": "log", "text": ...}`, admitted streams only). `ShareServer`/`ShareClient` take a `log_provider=` callable (the windows pass `logs.read_log_tail("server"/"client")`, capped at `logs.TAIL_BYTES`); a missing provider/file answers with a placeholder rather than hanging the requester. Both windows have a "Get client log"/"Get server log" button in the Connection log tab; the reply opens a `logs.PeerLogDialog`. The server requests from the most recently admitted stream.
50
52
  - **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
53
  - **Transport is TLS + a token handshake** (`tls.py`, `sharing.py`, `config.py`). The Qt SSL backend here is Windows **schannel** (no OpenSSL); it does complete server-side TLS with a `cryptography`-generated self-signed cert loaded from PEM (verified), but be wary of schannel-specific quirks if you change the config. The server persists a self-signed cert/key under the config dir (`tls.load_or_create_credentials`; `ShareServer(credentials=...)`, or ephemeral if omitted). The client connects with `connectToHostEncrypted`, ignores the expected self-signed cert errors (`PeerVerifyMode.VerifyNone`), and pins the cert fingerprint **softly** — a change is logged but does *not* block the connection (robustness over strict security, per the trusted-LAN intent). Authentication: on first connect the server user approves and the server issues a random token (`PairedClients.pair`, stored server-side by client-id; client stores it in `KnownServers` keyed by `host:port` with the fingerprint). On reconnect the client sends the token in its hello and is admitted with no prompt; a missing/invalid token just falls back to re-approval (never hard-fails). **Do not reintroduce hard cert-pinning or challenge-response** — the user explicitly wanted robust connections over maximum security.
52
54
  - **Performance monitoring** (`performance.py` + `preferences.py`): `PerformanceMonitor` is an opt-in collaborator (`performance=` on `ShareServer`/`ShareClient`, the `clipboard=` pattern — plain sharing tests leave it off). While admitted streams are attached, a 1 s QTimer samples `MessageStream.bytes_sent/bytes_received` counters into rolling `MetricSeries` (bandwidth, aggregated across streams via per-stream baselines) and pings 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).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.9.0
3
+ Version: 0.11.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
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.11.0"
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
24
24
  )
25
25
 
26
26
  from remotedesktop import db, icon, logs, window_state
27
+ from remotedesktop.logs import PeerLogDialog, read_log_tail
27
28
  from remotedesktop.clipboard import ClipboardSync
28
29
  from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
29
30
  from remotedesktop.discovery import DISCOVERY_PORT, ServerInfo, discover_servers
@@ -120,7 +121,14 @@ class ClientWindow(QMainWindow):
120
121
  self.connection_log = QPlainTextEdit(self)
121
122
  self.connection_log.setReadOnly(True)
122
123
  self.connection_log.setMaximumBlockCount(1000)
123
- tabs.addTab(self.connection_log, "Connection log")
124
+ self.get_log_button = QPushButton("Get server log")
125
+ self.get_log_button.setToolTip("Ask the connected server to send its debug log")
126
+ self.get_log_button.clicked.connect(self._request_server_log)
127
+ log_tab = QWidget()
128
+ log_layout = QVBoxLayout(log_tab)
129
+ log_layout.addWidget(self.get_log_button, alignment=Qt.AlignmentFlag.AlignLeft)
130
+ log_layout.addWidget(self.connection_log)
131
+ tabs.addTab(log_tab, "Connection log")
124
132
  tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
125
133
 
126
134
  self.discovery_panel.serverActivated.connect(self._on_server_activated)
@@ -195,6 +203,7 @@ class ClientWindow(QMainWindow):
195
203
  known_servers=self._known_servers,
196
204
  clipboard=self._clipboard,
197
205
  performance=self.performance,
206
+ log_provider=lambda: read_log_tail("client"),
198
207
  parent=self,
199
208
  )
200
209
  self._client = client
@@ -204,6 +213,7 @@ class ClientWindow(QMainWindow):
204
213
  client.denied.connect(self._on_denied)
205
214
  client.disconnected.connect(self._on_disconnected)
206
215
  client.frameReceived.connect(self._on_frame)
216
+ client.logReceived.connect(self._show_server_log)
207
217
  self.viewer.clear(f"Connecting to {server.name} …")
208
218
  self.statusBar().showMessage(f"Connecting to {server.name} ({server.host}:{server.port}) …")
209
219
  client.connect_to(server.host, server.port)
@@ -248,6 +258,20 @@ class ClientWindow(QMainWindow):
248
258
  self.viewer.clear("Disconnected")
249
259
  self.statusBar().showMessage(f"Disconnected from {self._server_name}")
250
260
 
261
+ def _request_server_log(self) -> None:
262
+ if self._client is None:
263
+ self.log("Not connected — no server to request a log from")
264
+ return
265
+ self._client.request_log()
266
+
267
+ def _show_server_log(self, text: str) -> None:
268
+ title = (
269
+ f'Log from server "{self._server_name}"'
270
+ if self._server_name
271
+ else "Log from server"
272
+ )
273
+ PeerLogDialog(title, text, self).show()
274
+
251
275
  def _on_frame(self, image) -> None:
252
276
  self._frame_count += 1
253
277
  self.viewer.show_frame(image)
@@ -0,0 +1,94 @@
1
+ """Persistent debug logging for the client and server apps.
2
+
3
+ The GUI "Connection log" panes only live as long as their window, which makes
4
+ an intermittent connection problem impossible to diagnose after the fact.
5
+ `init_logging` attaches a rotating file handler to the "remotedesktop" logger
6
+ so everything the panes show — plus lower-level socket and protocol detail
7
+ that is logged directly — survives on disk with millisecond timestamps.
8
+
9
+ Only the app entry points call `init_logging`; library code and tests just
10
+ log to `logging.getLogger("remotedesktop...")`, which is a no-op without a
11
+ handler.
12
+ """
13
+
14
+ import logging
15
+ import logging.handlers
16
+ from pathlib import Path
17
+
18
+ import platformdirs
19
+ from PySide6.QtCore import Qt
20
+ from PySide6.QtWidgets import QDialog, QPlainTextEdit, QVBoxLayout, QWidget
21
+
22
+ # Cap on what a peer gets when it asks for our log: the most recent entries
23
+ # are what matter, and the reply must stay a comfortable single message.
24
+ TAIL_BYTES = 1_000_000
25
+
26
+
27
+ def default_log_dir() -> Path:
28
+ return Path(platformdirs.user_log_dir("remotedesktop", appauthor=False))
29
+
30
+
31
+ def log_path(app_name: str, *, directory: Path | None = None) -> Path:
32
+ log_dir = directory if directory is not None else default_log_dir()
33
+ return log_dir / f"{app_name}.log"
34
+
35
+
36
+ def read_log_tail(
37
+ app_name: str, *, directory: Path | None = None, max_bytes: int = TAIL_BYTES
38
+ ) -> str:
39
+ """The last `max_bytes` of the app's debug log, for sending to a peer.
40
+
41
+ Log exchange is a diagnostic aid and must never break a connection, so a
42
+ missing or unreadable log yields a placeholder line instead of raising.
43
+ """
44
+ path = log_path(app_name, directory=directory)
45
+ try:
46
+ with path.open("rb") as file:
47
+ size = file.seek(0, 2)
48
+ file.seek(max(0, size - max_bytes))
49
+ data = file.read(max_bytes)
50
+ except OSError:
51
+ return f"(no log available: {path} cannot be read)"
52
+ return data.decode("utf-8", errors="replace")
53
+
54
+
55
+ class PeerLogDialog(QDialog):
56
+ """Non-modal, read-only viewer for a log received from the peer."""
57
+
58
+ def __init__(self, title: str, text: str, parent: QWidget | None = None) -> None:
59
+ super().__init__(parent)
60
+ self.setWindowTitle(title)
61
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
62
+ self.resize(900, 600)
63
+ view = QPlainTextEdit(self)
64
+ view.setReadOnly(True)
65
+ view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
66
+ view.setPlainText(text)
67
+ # The most recent entries matter most: start scrolled to the end.
68
+ view.verticalScrollBar().setValue(view.verticalScrollBar().maximum())
69
+ layout = QVBoxLayout(self)
70
+ layout.addWidget(view)
71
+
72
+
73
+ def init_logging(app_name: str, *, directory: Path | None = None) -> Path:
74
+ """Write "remotedesktop" logger output to <log dir>/<app_name>.log.
75
+
76
+ Returns the log file path. `directory` exists for tests; the apps use
77
+ the platformdirs log dir.
78
+ """
79
+ log_dir = directory if directory is not None else default_log_dir()
80
+ log_dir.mkdir(parents=True, exist_ok=True)
81
+ path = log_path(app_name, directory=directory)
82
+ handler = logging.handlers.RotatingFileHandler(
83
+ path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
84
+ )
85
+ handler.setFormatter(
86
+ logging.Formatter(
87
+ "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s %(message)s",
88
+ datefmt="%Y-%m-%d %H:%M:%S",
89
+ )
90
+ )
91
+ logger = logging.getLogger("remotedesktop")
92
+ logger.addHandler(handler)
93
+ logger.setLevel(logging.DEBUG)
94
+ return path
@@ -0,0 +1,103 @@
1
+ """Keep the Qt event loop serviced during Windows' modal move/size loop.
2
+
3
+ Dragging (or click-and-holding) a window's title bar puts that window's
4
+ thread into a native modal loop; Qt's event dispatcher stops running, so
5
+ timers, socket reads — and therefore remote-input processing — all stall.
6
+ For the share server that was a deadlock: an injected remote mouse-down on
7
+ the server window's own title bar enters the loop, and the mouse-up that
8
+ would end it sits unread on a socket the frozen event loop never services,
9
+ until someone at the server machine intervenes.
10
+
11
+ Native WM_TIMER callbacks *are* dispatched inside modal loops, so between
12
+ WM_ENTERSIZEMOVE and WM_EXITSIZEMOVE a SetTimer callback pumps Qt events
13
+ (user input excluded) every few milliseconds. A side benefit: screen
14
+ sharing keeps streaming while the local user drags the server window.
15
+
16
+ Feed every message from the window's `nativeEvent` to
17
+ `handle_native_event`; the pump is inert off Windows. Tests inject a fake
18
+ `timers` backend so no native timer is ever created.
19
+ """
20
+
21
+ import ctypes
22
+ import logging
23
+ import sys
24
+ from collections.abc import Callable
25
+ from ctypes import wintypes
26
+
27
+ from PySide6.QtCore import QCoreApplication, QEventLoop
28
+
29
+ _log = logging.getLogger("remotedesktop.modal_loop")
30
+
31
+ WM_ENTERSIZEMOVE = 0x0231
32
+ WM_EXITSIZEMOVE = 0x0232
33
+ _TIMER_ID = 0x5244 # arbitrary but stable; scoped to the window's hwnd
34
+ _TIMER_INTERVAL_MS = 15 # comfortably under the 33 ms capture tick
35
+
36
+
37
+ def _pump_qt() -> None:
38
+ # Excluding user input keeps re-entrant clicks and keys out of our own
39
+ # widgets while the native modal loop owns the mouse.
40
+ QCoreApplication.processEvents(QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents)
41
+
42
+
43
+ class _NativeTimers:
44
+ """SetTimer/KillTimer with a TIMERPROC, which the modal loop dispatches."""
45
+
46
+ def __init__(self, callback: Callable[[], None]) -> None:
47
+ self._user32 = ctypes.windll.user32
48
+ # The WINFUNCTYPE wrapper must stay referenced for the timer's lifetime.
49
+ self._proc = ctypes.WINFUNCTYPE(
50
+ None, wintypes.HWND, wintypes.UINT, ctypes.c_size_t, wintypes.DWORD
51
+ )(lambda _hwnd, _msg, _timer_id, _tick: callback())
52
+
53
+ def start(self, hwnd: int) -> None:
54
+ self._user32.SetTimer(hwnd, _TIMER_ID, _TIMER_INTERVAL_MS, self._proc)
55
+
56
+ def stop(self, hwnd: int) -> None:
57
+ self._user32.KillTimer(hwnd, _TIMER_ID)
58
+
59
+
60
+ class ModalLoopPump:
61
+ """Runs a native timer that pumps Qt events while a window of ours sits
62
+ in the modal move/size loop."""
63
+
64
+ def __init__(self, *, pump: Callable[[], None] | None = None, timers=None) -> None:
65
+ self._pump = pump if pump is not None else _pump_qt
66
+ if timers is None and sys.platform == "win32":
67
+ timers = _NativeTimers(self._on_timer)
68
+ self._timers = timers # None: inert (non-Windows)
69
+ self._hwnd: int | None = None
70
+ self._pumping = False
71
+
72
+ def handle_native_event(self, event_type, message) -> None:
73
+ """Call from QWidget.nativeEvent with its arguments verbatim."""
74
+ if self._timers is None or bytes(event_type) != b"windows_generic_MSG":
75
+ return
76
+ msg = wintypes.MSG.from_address(int(message))
77
+ if msg.message == WM_ENTERSIZEMOVE:
78
+ self._enter(msg.hWnd or 0)
79
+ elif msg.message == WM_EXITSIZEMOVE:
80
+ self._exit()
81
+
82
+ def _enter(self, hwnd: int) -> None:
83
+ if self._hwnd is not None: # unbalanced enter: replace the old timer
84
+ self._timers.stop(self._hwnd)
85
+ self._hwnd = hwnd
86
+ self._timers.start(hwnd)
87
+ _log.debug("Modal move/size loop entered — pumping Qt from a native timer")
88
+
89
+ def _exit(self) -> None:
90
+ if self._hwnd is None:
91
+ return
92
+ self._timers.stop(self._hwnd)
93
+ self._hwnd = None
94
+ _log.debug("Modal move/size loop exited")
95
+
96
+ def _on_timer(self) -> None:
97
+ if self._pumping: # processEvents can dispatch this timer re-entrantly
98
+ return
99
+ self._pumping = True
100
+ try:
101
+ self._pump()
102
+ finally:
103
+ self._pumping = False
@@ -8,6 +8,15 @@ piggybacks the latest measurement on its next ping (`"rtt"`), so both ends can
8
8
  graph both directions. Peers that predate this feature simply never answer
9
9
  pings — the RTT series stay empty and nothing else is affected.
10
10
 
11
+ The same machinery doubles as dead-connection detection: once the active
12
+ stream's peer has proven it answers pings, a peer that then goes completely
13
+ silent (no bytes received at all) for `dead_after_seconds` is presumed gone
14
+ and `connectionLost` is emitted, once. A half-open TCP connection is otherwise
15
+ invisible here — an unchanged screen legitimately sends nothing, so a frozen
16
+ last frame looks exactly like a live static desktop. Peers that never answered
17
+ a ping (pre-feature versions) never arm the detector, so they are never
18
+ falsely reported.
19
+
11
20
  Wire messages (JSON, admitted streams only):
12
21
  {"type": "ping", "id": <int>, "rtt": <ms, omitted before first measurement>}
13
22
  {"type": "pong", "id": <int>} # pure echo
@@ -21,13 +30,14 @@ All timing uses time.monotonic(), injectable as `clock=` for tests.
21
30
 
22
31
  import itertools
23
32
  import logging
33
+ import math
24
34
  import time
25
35
  from collections import deque
26
36
  from collections.abc import Callable
27
37
  from typing import Protocol
28
38
 
29
39
  from PySide6.QtCore import QObject, QPointF, QTimer, Qt, Signal
30
- from PySide6.QtGui import QColor, QPainter, QPaintEvent, QPolygonF, QShowEvent
40
+ from PySide6.QtGui import QColor, QPainter, QPaintEvent, QPen, QPolygonF, QShowEvent
31
41
  from PySide6.QtWidgets import QVBoxLayout, QWidget
32
42
 
33
43
  _log = logging.getLogger("remotedesktop.performance")
@@ -46,8 +56,10 @@ class StreamLike(Protocol):
46
56
 
47
57
  DEFAULT_WINDOW_SECONDS = 120.0
48
58
  DEFAULT_INTERVAL_MS = 1000
59
+ DEFAULT_DEAD_AFTER_SECONDS = 10.0
49
60
  _PING_PRUNE_SECONDS = 30.0
50
61
  _HEARTBEAT_TICKS = 10 # one debug-log line per this many samples
62
+ _GRID_DIVISIONS = 4 # grid cells per axis, i.e. 5 ticks including both ends
51
63
 
52
64
 
53
65
  class MetricSeries:
@@ -93,18 +105,21 @@ class PerformanceMonitor(QObject):
93
105
  """
94
106
 
95
107
  updated = Signal() # one emit per sample tick
108
+ connectionLost = Signal(object) # the active stream whose responsive peer went silent
96
109
 
97
110
  def __init__(
98
111
  self,
99
112
  *,
100
113
  window_seconds: float = DEFAULT_WINDOW_SECONDS,
101
114
  interval_ms: int = DEFAULT_INTERVAL_MS,
115
+ dead_after_seconds: float = DEFAULT_DEAD_AFTER_SECONDS,
102
116
  clock: Callable[[], float] = time.monotonic,
103
117
  parent: QObject | None = None,
104
118
  ) -> None:
105
119
  super().__init__(parent)
106
120
  self._clock = clock
107
121
  self._window = window_seconds
122
+ self._dead_after = dead_after_seconds
108
123
  self.send_bps = MetricSeries(window_seconds, clock=clock)
109
124
  self.recv_bps = MetricSeries(window_seconds, clock=clock)
110
125
  self.rtt_ms = MetricSeries(window_seconds, clock=clock)
@@ -112,6 +127,12 @@ class PerformanceMonitor(QObject):
112
127
  self._streams: list[StreamLike] = []
113
128
  self._baselines: dict[StreamLike, tuple[int, int]] = {}
114
129
  self._active: StreamLike | None = None
130
+ # Dead-connection detection, all scoped to the active stream: when its
131
+ # bytes_received counter last grew, whether its peer ever answered a
132
+ # ping (arms the detector), and whether a loss was already reported.
133
+ self._last_data: float | None = None
134
+ self._peer_responsive = False
135
+ self._lost_reported = False
115
136
  self._pending: dict[int, float] = {}
116
137
  self._ids = itertools.count(1)
117
138
  self._last_tick: float | None = None
@@ -124,6 +145,10 @@ class PerformanceMonitor(QObject):
124
145
  def window_seconds(self) -> float:
125
146
  return self._window
126
147
 
148
+ @property
149
+ def dead_after_seconds(self) -> float:
150
+ return self._dead_after
151
+
127
152
  def set_window_seconds(self, window_seconds: float) -> None:
128
153
  self._window = window_seconds
129
154
  for series in (self.send_bps, self.recv_bps, self.rtt_ms, self.peer_rtt_ms):
@@ -133,7 +158,7 @@ class PerformanceMonitor(QObject):
133
158
  if stream not in self._baselines:
134
159
  self._streams.append(stream)
135
160
  self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
136
- self._active = stream
161
+ self._activate(stream)
137
162
  if not self._timer.isActive():
138
163
  self._last_tick = None
139
164
  self._timer.start()
@@ -144,12 +169,18 @@ class PerformanceMonitor(QObject):
144
169
  self._streams.remove(stream)
145
170
  del self._baselines[stream]
146
171
  if self._active is stream:
147
- self._active = self._streams[-1] if self._streams else None
172
+ self._activate(self._streams[-1] if self._streams else None)
148
173
  if not self._streams:
149
174
  self._timer.stop()
150
175
  self._pending.clear()
151
176
  self._last_tick = None
152
177
 
178
+ def _activate(self, stream: StreamLike | None) -> None:
179
+ self._active = stream
180
+ self._last_data = self._clock() if stream is not None else None
181
+ self._peer_responsive = False
182
+ self._lost_reported = False
183
+
153
184
  def reset(self) -> None:
154
185
  """Detach everything and clear the graphs (new connection attempt)."""
155
186
  for stream in list(self._streams):
@@ -162,9 +193,15 @@ class PerformanceMonitor(QObject):
162
193
  case "ping":
163
194
  stream.send_json({"type": "pong", "id": message.get("id")})
164
195
  rtt = message.get("rtt")
165
- if isinstance(rtt, (int, float)) and stream is self._active:
166
- self.peer_rtt_ms.add(float(rtt))
196
+ if stream is self._active:
197
+ # A peer that pings us runs this feature too — that arms
198
+ # the silence detector just as well as a pong does.
199
+ self._peer_responsive = True
200
+ if isinstance(rtt, (int, float)):
201
+ self.peer_rtt_ms.add(float(rtt))
167
202
  case "pong":
203
+ if stream is self._active:
204
+ self._peer_responsive = True
168
205
  ping_id = message.get("id")
169
206
  if isinstance(ping_id, int):
170
207
  sent = self._pending.pop(ping_id, None)
@@ -178,6 +215,8 @@ class PerformanceMonitor(QObject):
178
215
  base_sent, base_received = self._baselines[stream]
179
216
  delta_sent += stream.bytes_sent - base_sent
180
217
  delta_received += stream.bytes_received - base_received
218
+ if stream is self._active and stream.bytes_received > base_received:
219
+ self._last_data = now
181
220
  self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
182
221
  if self._last_tick is not None and (elapsed := now - self._last_tick) > 0:
183
222
  self.send_bps.add(delta_sent / elapsed)
@@ -194,6 +233,20 @@ class PerformanceMonitor(QObject):
194
233
  ping["rtt"] = rtt
195
234
  self._active.send_json(ping)
196
235
 
236
+ if (
237
+ self._peer_responsive
238
+ and not self._lost_reported
239
+ and self._last_data is not None
240
+ and now - self._last_data > self._dead_after
241
+ ):
242
+ self._lost_reported = True
243
+ _log.warning(
244
+ "No data from the peer for %.0f s (pings unanswered) — "
245
+ "connection presumed lost",
246
+ now - self._last_data,
247
+ )
248
+ self.connectionLost.emit(self._active)
249
+
197
250
  self._tick_count += 1
198
251
  if self._tick_count % _HEARTBEAT_TICKS == 0:
199
252
  _log.debug(
@@ -219,6 +272,30 @@ def format_ms(ms: float) -> str:
219
272
  return f"{ms:.1f} ms"
220
273
 
221
274
 
275
+ def rate_unit(bytes_per_second: float) -> float:
276
+ """The divisor format_rate will display this value in (B/KB/MB per s),
277
+ so axis ticks can be computed in the displayed unit and land on round
278
+ numbers there."""
279
+ if bytes_per_second >= 1024 * 1024:
280
+ return 1024.0 * 1024.0
281
+ if bytes_per_second >= 1024:
282
+ return 1024.0
283
+ return 1.0
284
+
285
+
286
+ def nice_ceiling(value: float) -> float:
287
+ """Smallest 1/2/5 x 10^k that is >= value, so axis ticks land on round
288
+ numbers instead of the raw data maximum."""
289
+ if value <= 0:
290
+ return 1.0
291
+ exponent = math.floor(math.log10(value))
292
+ for multiplier in (1.0, 2.0, 5.0, 10.0):
293
+ candidate = multiplier * 10.0**exponent
294
+ if candidate >= value:
295
+ return candidate
296
+ raise AssertionError("unreachable: 10 * 10**floor(log10(v)) >= v")
297
+
298
+
222
299
  class GraphWidget(QWidget):
223
300
  """A rolling line graph over the monitor's window, painted with QPainter.
224
301
 
@@ -233,6 +310,7 @@ class GraphWidget(QWidget):
233
310
  monitor: PerformanceMonitor,
234
311
  format_value: Callable[[float], str],
235
312
  *,
313
+ tick_unit: Callable[[float], float] = lambda _value: 1.0,
236
314
  clock: Callable[[], float] = time.monotonic,
237
315
  parent: QWidget | None = None,
238
316
  ) -> None:
@@ -241,8 +319,12 @@ class GraphWidget(QWidget):
241
319
  self._series = series
242
320
  self._monitor = monitor
243
321
  self._format_value = format_value
322
+ # Maps the data maximum to the unit format_value displays it in, so
323
+ # the axis ceiling is a round number of that unit (100 KB/s, not the
324
+ # 97.7 KB/s that a round number of bytes/s would render as).
325
+ self._tick_unit = tick_unit
244
326
  self._clock = clock
245
- self.setMinimumSize(300, 120)
327
+ self.setMinimumSize(300, 140) # room for the axis-label rows
246
328
 
247
329
  def paintEvent(self, event: QPaintEvent) -> None:
248
330
  painter = QPainter(self)
@@ -262,12 +344,52 @@ class GraphWidget(QWidget):
262
344
  self.rect(), Qt.AlignmentFlag.AlignCenter, "no data"
263
345
  )
264
346
  return
265
- y_max = max(
347
+ raw_max = max(
266
348
  (value for _l, _c, samples in data for _t, value in samples), default=1.0
267
349
  )
268
- y_max = max(y_max, 1.0)
350
+ unit = self._tick_unit(raw_max)
351
+ y_max = unit * nice_ceiling(raw_max / unit)
352
+
353
+ # Y tick labels (with units, via the formatter) size the left margin;
354
+ # the bottom leaves a row for X tick labels and one for the legend.
355
+ metrics = painter.fontMetrics()
356
+ y_ticks = [y_max * i / _GRID_DIVISIONS for i in range(_GRID_DIVISIONS + 1)]
357
+ y_labels = [self._format_value(v) for v in y_ticks]
358
+ left = 8 + max(metrics.horizontalAdvance(t) for t in y_labels) + 6
359
+ graph = self.rect().adjusted(left, 24, -12, -(2 * metrics.height() + 12))
360
+
361
+ grid_color = palette.color(palette.ColorRole.Mid)
362
+ grid_pen = QPen(grid_color, 0, Qt.PenStyle.DotLine)
363
+ text_color = palette.color(palette.ColorRole.Text)
364
+ for value, label in zip(y_ticks, y_labels):
365
+ y = graph.bottom() - graph.height() * (value / y_max)
366
+ painter.setPen(grid_pen)
367
+ painter.drawLine(graph.left(), round(y), graph.right(), round(y))
368
+ painter.setPen(text_color)
369
+ painter.drawText(
370
+ graph.left() - 6 - metrics.horizontalAdvance(label),
371
+ round(y) + metrics.ascent() // 2 - 1,
372
+ label,
373
+ )
374
+ x_label_baseline = graph.bottom() + metrics.ascent() + 4
375
+ for i in range(_GRID_DIVISIONS + 1):
376
+ seconds_ago = window * (1 - i / _GRID_DIVISIONS)
377
+ x = graph.left() + graph.width() * i / _GRID_DIVISIONS
378
+ painter.setPen(grid_pen)
379
+ painter.drawLine(round(x), graph.top(), round(x), graph.bottom())
380
+ label = f"{seconds_ago:g}"
381
+ width = metrics.horizontalAdvance(label)
382
+ # Center on the tick, but keep the edge labels inside the graph.
383
+ text_x = min(max(x - width / 2, graph.left()), graph.right() - width)
384
+ painter.setPen(text_color)
385
+ painter.drawText(round(text_x), x_label_baseline, label)
386
+ caption = "seconds ago"
387
+ painter.drawText(
388
+ self.rect().right() - 8 - metrics.horizontalAdvance(caption),
389
+ self.rect().bottom() - 6,
390
+ caption,
391
+ )
269
392
 
270
- graph = self.rect().adjusted(8, 24, -8, -20)
271
393
  for label, color, samples in data:
272
394
  if not samples:
273
395
  continue
@@ -285,14 +407,8 @@ class GraphWidget(QWidget):
285
407
  latest = samples[-1][1] if samples else None
286
408
  text = f"{label} {self._format_value(latest)}" if latest is not None else f"{label} —"
287
409
  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
- )
410
+ painter.drawText(round(x), self.rect().bottom() - 6, text)
411
+ x += metrics.horizontalAdvance(text) + 16
296
412
 
297
413
 
298
414
  class PerformanceTab(QWidget):
@@ -313,6 +429,7 @@ class PerformanceTab(QWidget):
313
429
  ],
314
430
  monitor,
315
431
  format_rate,
432
+ tick_unit=rate_unit,
316
433
  )
317
434
  self.ping_graph = GraphWidget(
318
435
  "Round-trip time",
@@ -32,6 +32,8 @@ from remotedesktop.discovery import (
32
32
  DiscoveryResponder,
33
33
  )
34
34
  from remotedesktop.inventory import ConnectionInventory, InventoryTab
35
+ from remotedesktop.logs import PeerLogDialog, read_log_tail
36
+ from remotedesktop.modal_loop import ModalLoopPump
35
37
  from remotedesktop.performance import PerformanceMonitor, PerformanceTab
36
38
  from remotedesktop.preferences import PreferencesTab, load_performance_window_seconds
37
39
  from remotedesktop.sharing import ShareServer
@@ -64,6 +66,20 @@ class ServerWindow(QMainWindow):
64
66
  self.connection_log = QPlainTextEdit()
65
67
  self.connection_log.setReadOnly(True)
66
68
  self.connection_log.setMaximumBlockCount(1000)
69
+ self.get_log_button = QPushButton("Get client log")
70
+ self.get_log_button.setToolTip(
71
+ "Ask the most recently connected client to send its debug log"
72
+ )
73
+ self.get_log_button.clicked.connect(self._request_client_log)
74
+ log_tab = QWidget()
75
+ log_layout = QVBoxLayout(log_tab)
76
+ log_layout.addWidget(self.get_log_button, alignment=Qt.AlignmentFlag.AlignLeft)
77
+ log_layout.addWidget(self.connection_log)
78
+ # While this window sits in Windows' modal move/size loop (title-bar
79
+ # drag — including one driven by an injected remote click), Qt stops
80
+ # running; the pump keeps sockets and timers serviced so a remote
81
+ # mouse-up can still arrive and end the drag instead of deadlocking.
82
+ self._modal_pump = ModalLoopPump()
67
83
  self.restart_button = QPushButton("Restart server")
68
84
  self.restart_button.setToolTip(
69
85
  "Relaunch this app (e.g. after updating the software). It can be "
@@ -93,7 +109,7 @@ class ServerWindow(QMainWindow):
93
109
  "Clients on LAN",
94
110
  )
95
111
  tabs.addTab(PerformanceTab(self.performance), "Performance")
96
- tabs.addTab(self.connection_log, "Connection log")
112
+ tabs.addTab(log_tab, "Connection log")
97
113
  tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
98
114
  self.setCentralWidget(tabs)
99
115
 
@@ -111,11 +127,13 @@ class ServerWindow(QMainWindow):
111
127
  paired=paired,
112
128
  clipboard=self._clipboard,
113
129
  performance=self.performance,
130
+ log_provider=lambda: read_log_tail("server"),
114
131
  parent=self,
115
132
  )
116
133
  self.share_server.status.connect(self.log)
117
134
  self.share_server.clientCountChanged.connect(self._update_summary)
118
135
  self.share_server.peerEvent.connect(self._record_peer)
136
+ self.share_server.logReceived.connect(self._show_client_log)
119
137
  self._listening = self.share_server.listen(connect_port)
120
138
 
121
139
  self.responder: DiscoveryResponder | None = None
@@ -220,6 +238,17 @@ class ServerWindow(QMainWindow):
220
238
  self.close()
221
239
  QApplication.quit()
222
240
 
241
+ def _request_client_log(self) -> None:
242
+ self.share_server.request_log()
243
+
244
+ def _show_client_log(self, client_name: str, text: str) -> None:
245
+ title = f'Log from client "{client_name}"' if client_name else "Log from client"
246
+ PeerLogDialog(title, text, self).show()
247
+
248
+ def nativeEvent(self, event_type, message):
249
+ self._modal_pump.handle_native_event(event_type, message)
250
+ return super().nativeEvent(event_type, message)
251
+
223
252
  def _ask_approval(self, client_id: str, client_name: str) -> bool:
224
253
  box = QMessageBox(
225
254
  QMessageBox.Icon.Question,