remotedesktop 0.9.0__tar.gz → 0.10.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.9.0 → remotedesktop-0.10.0}/CLAUDE.md +2 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/PKG-INFO +1 -1
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/client.py +25 -1
- remotedesktop-0.10.0/src/remotedesktop/logs.py +94 -0
- remotedesktop-0.10.0/src/remotedesktop/modal_loop.py +103 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/performance.py +55 -4
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/server.py +30 -1
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/sharing.py +83 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_client_window.py +23 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_logs.py +18 -0
- remotedesktop-0.10.0/tests/test_modal_loop.py +68 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_performance.py +109 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_server_window.py +47 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_sharing.py +62 -2
- remotedesktop-0.9.0/src/remotedesktop/logs.py +0 -46
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/.gitignore +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/LICENSE +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/pyproject.toml +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/readme.md +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/run_claude.bat +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/run_client.bat +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/run_server.bat +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/frames.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/conftest.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_config.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_frames.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_input.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.0}/tests/test_window_state.py +0 -0
- {remotedesktop-0.9.0 → remotedesktop-0.10.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.
|
|
3
|
+
Version: 0.10.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
|
|
@@ -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
|
-
|
|
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
|
|
@@ -46,6 +55,7 @@ class StreamLike(Protocol):
|
|
|
46
55
|
|
|
47
56
|
DEFAULT_WINDOW_SECONDS = 120.0
|
|
48
57
|
DEFAULT_INTERVAL_MS = 1000
|
|
58
|
+
DEFAULT_DEAD_AFTER_SECONDS = 10.0
|
|
49
59
|
_PING_PRUNE_SECONDS = 30.0
|
|
50
60
|
_HEARTBEAT_TICKS = 10 # one debug-log line per this many samples
|
|
51
61
|
|
|
@@ -93,18 +103,21 @@ class PerformanceMonitor(QObject):
|
|
|
93
103
|
"""
|
|
94
104
|
|
|
95
105
|
updated = Signal() # one emit per sample tick
|
|
106
|
+
connectionLost = Signal(object) # the active stream whose responsive peer went silent
|
|
96
107
|
|
|
97
108
|
def __init__(
|
|
98
109
|
self,
|
|
99
110
|
*,
|
|
100
111
|
window_seconds: float = DEFAULT_WINDOW_SECONDS,
|
|
101
112
|
interval_ms: int = DEFAULT_INTERVAL_MS,
|
|
113
|
+
dead_after_seconds: float = DEFAULT_DEAD_AFTER_SECONDS,
|
|
102
114
|
clock: Callable[[], float] = time.monotonic,
|
|
103
115
|
parent: QObject | None = None,
|
|
104
116
|
) -> None:
|
|
105
117
|
super().__init__(parent)
|
|
106
118
|
self._clock = clock
|
|
107
119
|
self._window = window_seconds
|
|
120
|
+
self._dead_after = dead_after_seconds
|
|
108
121
|
self.send_bps = MetricSeries(window_seconds, clock=clock)
|
|
109
122
|
self.recv_bps = MetricSeries(window_seconds, clock=clock)
|
|
110
123
|
self.rtt_ms = MetricSeries(window_seconds, clock=clock)
|
|
@@ -112,6 +125,12 @@ class PerformanceMonitor(QObject):
|
|
|
112
125
|
self._streams: list[StreamLike] = []
|
|
113
126
|
self._baselines: dict[StreamLike, tuple[int, int]] = {}
|
|
114
127
|
self._active: StreamLike | None = None
|
|
128
|
+
# Dead-connection detection, all scoped to the active stream: when its
|
|
129
|
+
# bytes_received counter last grew, whether its peer ever answered a
|
|
130
|
+
# ping (arms the detector), and whether a loss was already reported.
|
|
131
|
+
self._last_data: float | None = None
|
|
132
|
+
self._peer_responsive = False
|
|
133
|
+
self._lost_reported = False
|
|
115
134
|
self._pending: dict[int, float] = {}
|
|
116
135
|
self._ids = itertools.count(1)
|
|
117
136
|
self._last_tick: float | None = None
|
|
@@ -124,6 +143,10 @@ class PerformanceMonitor(QObject):
|
|
|
124
143
|
def window_seconds(self) -> float:
|
|
125
144
|
return self._window
|
|
126
145
|
|
|
146
|
+
@property
|
|
147
|
+
def dead_after_seconds(self) -> float:
|
|
148
|
+
return self._dead_after
|
|
149
|
+
|
|
127
150
|
def set_window_seconds(self, window_seconds: float) -> None:
|
|
128
151
|
self._window = window_seconds
|
|
129
152
|
for series in (self.send_bps, self.recv_bps, self.rtt_ms, self.peer_rtt_ms):
|
|
@@ -133,7 +156,7 @@ class PerformanceMonitor(QObject):
|
|
|
133
156
|
if stream not in self._baselines:
|
|
134
157
|
self._streams.append(stream)
|
|
135
158
|
self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
|
|
136
|
-
self.
|
|
159
|
+
self._activate(stream)
|
|
137
160
|
if not self._timer.isActive():
|
|
138
161
|
self._last_tick = None
|
|
139
162
|
self._timer.start()
|
|
@@ -144,12 +167,18 @@ class PerformanceMonitor(QObject):
|
|
|
144
167
|
self._streams.remove(stream)
|
|
145
168
|
del self._baselines[stream]
|
|
146
169
|
if self._active is stream:
|
|
147
|
-
self.
|
|
170
|
+
self._activate(self._streams[-1] if self._streams else None)
|
|
148
171
|
if not self._streams:
|
|
149
172
|
self._timer.stop()
|
|
150
173
|
self._pending.clear()
|
|
151
174
|
self._last_tick = None
|
|
152
175
|
|
|
176
|
+
def _activate(self, stream: StreamLike | None) -> None:
|
|
177
|
+
self._active = stream
|
|
178
|
+
self._last_data = self._clock() if stream is not None else None
|
|
179
|
+
self._peer_responsive = False
|
|
180
|
+
self._lost_reported = False
|
|
181
|
+
|
|
153
182
|
def reset(self) -> None:
|
|
154
183
|
"""Detach everything and clear the graphs (new connection attempt)."""
|
|
155
184
|
for stream in list(self._streams):
|
|
@@ -162,9 +191,15 @@ class PerformanceMonitor(QObject):
|
|
|
162
191
|
case "ping":
|
|
163
192
|
stream.send_json({"type": "pong", "id": message.get("id")})
|
|
164
193
|
rtt = message.get("rtt")
|
|
165
|
-
if
|
|
166
|
-
|
|
194
|
+
if stream is self._active:
|
|
195
|
+
# A peer that pings us runs this feature too — that arms
|
|
196
|
+
# the silence detector just as well as a pong does.
|
|
197
|
+
self._peer_responsive = True
|
|
198
|
+
if isinstance(rtt, (int, float)):
|
|
199
|
+
self.peer_rtt_ms.add(float(rtt))
|
|
167
200
|
case "pong":
|
|
201
|
+
if stream is self._active:
|
|
202
|
+
self._peer_responsive = True
|
|
168
203
|
ping_id = message.get("id")
|
|
169
204
|
if isinstance(ping_id, int):
|
|
170
205
|
sent = self._pending.pop(ping_id, None)
|
|
@@ -178,6 +213,8 @@ class PerformanceMonitor(QObject):
|
|
|
178
213
|
base_sent, base_received = self._baselines[stream]
|
|
179
214
|
delta_sent += stream.bytes_sent - base_sent
|
|
180
215
|
delta_received += stream.bytes_received - base_received
|
|
216
|
+
if stream is self._active and stream.bytes_received > base_received:
|
|
217
|
+
self._last_data = now
|
|
181
218
|
self._baselines[stream] = (stream.bytes_sent, stream.bytes_received)
|
|
182
219
|
if self._last_tick is not None and (elapsed := now - self._last_tick) > 0:
|
|
183
220
|
self.send_bps.add(delta_sent / elapsed)
|
|
@@ -194,6 +231,20 @@ class PerformanceMonitor(QObject):
|
|
|
194
231
|
ping["rtt"] = rtt
|
|
195
232
|
self._active.send_json(ping)
|
|
196
233
|
|
|
234
|
+
if (
|
|
235
|
+
self._peer_responsive
|
|
236
|
+
and not self._lost_reported
|
|
237
|
+
and self._last_data is not None
|
|
238
|
+
and now - self._last_data > self._dead_after
|
|
239
|
+
):
|
|
240
|
+
self._lost_reported = True
|
|
241
|
+
_log.warning(
|
|
242
|
+
"No data from the peer for %.0f s (pings unanswered) — "
|
|
243
|
+
"connection presumed lost",
|
|
244
|
+
now - self._last_data,
|
|
245
|
+
)
|
|
246
|
+
self.connectionLost.emit(self._active)
|
|
247
|
+
|
|
197
248
|
self._tick_count += 1
|
|
198
249
|
if self._tick_count % _HEARTBEAT_TICKS == 0:
|
|
199
250
|
_log.debug(
|
|
@@ -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(
|
|
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,
|
|
@@ -78,6 +78,7 @@ class ShareServer(QObject):
|
|
|
78
78
|
clientCountChanged = Signal(int)
|
|
79
79
|
status = Signal(str)
|
|
80
80
|
peerEvent = Signal(dict) # {key, event, name, address, detail} for the inventory
|
|
81
|
+
logReceived = Signal(str, str) # (client name, log text) answering request_log
|
|
81
82
|
|
|
82
83
|
def __init__(
|
|
83
84
|
self,
|
|
@@ -89,10 +90,12 @@ class ShareServer(QObject):
|
|
|
89
90
|
injector: InputInjector | None = None,
|
|
90
91
|
clipboard=None,
|
|
91
92
|
performance: PerformanceMonitor | None = None,
|
|
93
|
+
log_provider: Callable[[], str] | None = None,
|
|
92
94
|
parent: QObject | None = None,
|
|
93
95
|
) -> None:
|
|
94
96
|
super().__init__(parent)
|
|
95
97
|
self._performance = performance
|
|
98
|
+
self._log_provider = log_provider
|
|
96
99
|
self._approve_client = approve_client
|
|
97
100
|
self._paired = paired if paired is not None else PairedClients(db.connect(default_db_path()))
|
|
98
101
|
self._fps = fps
|
|
@@ -217,6 +220,21 @@ class ShareServer(QObject):
|
|
|
217
220
|
self.status.emit(f"Clipboard update received from {_peer(stream.socket)}")
|
|
218
221
|
self._clipboard.apply(message)
|
|
219
222
|
return
|
|
223
|
+
if message.get("type") == "log_request":
|
|
224
|
+
# Same admission rule as input/clipboard: pre-auth peers get nothing.
|
|
225
|
+
if stream in self._streams:
|
|
226
|
+
self._send_log(stream)
|
|
227
|
+
return
|
|
228
|
+
if message.get("type") == "log":
|
|
229
|
+
if stream in self._streams:
|
|
230
|
+
text = str(message.get("text", ""))
|
|
231
|
+
name = self._stream_key.get(stream, ("", "", ""))[1]
|
|
232
|
+
self.status.emit(
|
|
233
|
+
f'Received log from "{name or _peer(stream.socket)}" '
|
|
234
|
+
f"({len(text) // 1024} KB)"
|
|
235
|
+
)
|
|
236
|
+
self.logReceived.emit(name, text)
|
|
237
|
+
return
|
|
220
238
|
if message.get("type") == "keyframe":
|
|
221
239
|
# The client lost sync with the delta stream (e.g. a band failed
|
|
222
240
|
# to decode) and wants a full frame to rebuild its canvas.
|
|
@@ -407,6 +425,27 @@ class ShareServer(QObject):
|
|
|
407
425
|
for stream in self._streams:
|
|
408
426
|
stream.send_json(message)
|
|
409
427
|
|
|
428
|
+
def request_log(self) -> None:
|
|
429
|
+
"""Ask the most recently admitted client to send its debug log
|
|
430
|
+
(delivered via `logReceived`)."""
|
|
431
|
+
if not self._streams:
|
|
432
|
+
self.status.emit("No connected client to request a log from")
|
|
433
|
+
return
|
|
434
|
+
stream = self._streams[-1]
|
|
435
|
+
self.status.emit(f"Requesting the log of the client at {_peer(stream.socket)}")
|
|
436
|
+
stream.send_json({"type": "log_request"})
|
|
437
|
+
|
|
438
|
+
def _send_log(self, stream: MessageStream) -> None:
|
|
439
|
+
text = (
|
|
440
|
+
self._log_provider()
|
|
441
|
+
if self._log_provider is not None
|
|
442
|
+
else "(no log available on the server)"
|
|
443
|
+
)
|
|
444
|
+
self.status.emit(
|
|
445
|
+
f"Log requested by {_peer(stream.socket)} — sending {len(text) // 1024} KB"
|
|
446
|
+
)
|
|
447
|
+
stream.send_json({"type": "log", "text": text})
|
|
448
|
+
|
|
410
449
|
def _drop(self, stream: MessageStream) -> None:
|
|
411
450
|
# Detach from the monitor before the deferred delete below, so it
|
|
412
451
|
# never samples a deleted stream (idempotent for never-admitted ones).
|
|
@@ -520,6 +559,7 @@ class ShareClient(QObject):
|
|
|
520
559
|
disconnected = Signal()
|
|
521
560
|
frameReceived = Signal(QImage)
|
|
522
561
|
status = Signal(str)
|
|
562
|
+
logReceived = Signal(str) # server log text answering request_log
|
|
523
563
|
|
|
524
564
|
def __init__(
|
|
525
565
|
self,
|
|
@@ -528,10 +568,14 @@ class ShareClient(QObject):
|
|
|
528
568
|
known_servers: KnownServers | None = None,
|
|
529
569
|
clipboard=None,
|
|
530
570
|
performance: PerformanceMonitor | None = None,
|
|
571
|
+
log_provider: Callable[[], str] | None = None,
|
|
531
572
|
parent: QObject | None = None,
|
|
532
573
|
) -> None:
|
|
533
574
|
super().__init__(parent)
|
|
534
575
|
self._performance = performance
|
|
576
|
+
self._log_provider = log_provider
|
|
577
|
+
if performance is not None:
|
|
578
|
+
performance.connectionLost.connect(self._on_connection_lost)
|
|
535
579
|
self._client_id, self._name = identity or load_client_identity(
|
|
536
580
|
db.connect(default_db_path())
|
|
537
581
|
)
|
|
@@ -587,6 +631,15 @@ class ShareClient(QObject):
|
|
|
587
631
|
self.status.emit("Sending local clipboard to server")
|
|
588
632
|
self._stream.send_json({"type": "clipboard", **payload})
|
|
589
633
|
|
|
634
|
+
def request_log(self) -> None:
|
|
635
|
+
"""Ask the connected server to send its debug log (delivered via
|
|
636
|
+
`logReceived`)."""
|
|
637
|
+
if self._socket.state() != QSslSocket.SocketState.ConnectedState:
|
|
638
|
+
self.status.emit("Not connected — cannot request the server's log")
|
|
639
|
+
return
|
|
640
|
+
self.status.emit("Requesting the server's log")
|
|
641
|
+
self._stream.send_json({"type": "log_request"})
|
|
642
|
+
|
|
590
643
|
def _on_ssl_errors(self, errors) -> None:
|
|
591
644
|
# Self-signed server certificate is expected; identity is pinned instead.
|
|
592
645
|
self.status.emit(
|
|
@@ -620,6 +673,22 @@ class ShareClient(QObject):
|
|
|
620
673
|
hello["token"] = self._server_token
|
|
621
674
|
self._stream.send_json(hello)
|
|
622
675
|
|
|
676
|
+
def _on_connection_lost(self, stream) -> None:
|
|
677
|
+
# The window shares one monitor across successive ShareClient
|
|
678
|
+
# instances, so only the owner of the silent stream may react. (The
|
|
679
|
+
# None check is for the type checker: the signal is only connected
|
|
680
|
+
# when a monitor exists.)
|
|
681
|
+
if stream is not self._stream or self._performance is None:
|
|
682
|
+
return
|
|
683
|
+
self.status.emit(
|
|
684
|
+
"Connection lost: no data from the server for "
|
|
685
|
+
f"{self._performance.dead_after_seconds:.0f} s — disconnecting"
|
|
686
|
+
)
|
|
687
|
+
# A half-open socket never emits disconnected on its own; abort() is
|
|
688
|
+
# silent too, so run the disconnect path explicitly.
|
|
689
|
+
self._socket.abort()
|
|
690
|
+
self._on_disconnected()
|
|
691
|
+
|
|
623
692
|
def _on_disconnected(self) -> None:
|
|
624
693
|
if self._performance is not None:
|
|
625
694
|
self._performance.remove_stream(self._stream)
|
|
@@ -670,6 +739,20 @@ class ShareClient(QObject):
|
|
|
670
739
|
case "ping" | "pong":
|
|
671
740
|
if self._performance is not None:
|
|
672
741
|
self._performance.handle_message(self._stream, message)
|
|
742
|
+
case "log_request":
|
|
743
|
+
text = (
|
|
744
|
+
self._log_provider()
|
|
745
|
+
if self._log_provider is not None
|
|
746
|
+
else "(no log available on the client)"
|
|
747
|
+
)
|
|
748
|
+
self.status.emit(
|
|
749
|
+
f"Server requested this client's log — sending {len(text) // 1024} KB"
|
|
750
|
+
)
|
|
751
|
+
self._stream.send_json({"type": "log", "text": text})
|
|
752
|
+
case "log":
|
|
753
|
+
text = str(message.get("text", ""))
|
|
754
|
+
self.status.emit(f"Received the server's log ({len(text) // 1024} KB)")
|
|
755
|
+
self.logReceived.emit(text)
|
|
673
756
|
|
|
674
757
|
def _on_frame(self, data: bytes) -> None:
|
|
675
758
|
# Full frame — PNG keyframe or (legacy servers) JPEG; sniffed from
|
|
@@ -26,6 +26,29 @@ def make_share_server(credentials, tmp_path, *, approve=lambda *_: True):
|
|
|
26
26
|
return server
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def test_get_server_log_button_needs_a_connection(qapp, tmp_path):
|
|
30
|
+
window = make_window(tmp_path)
|
|
31
|
+
try:
|
|
32
|
+
window.get_log_button.click()
|
|
33
|
+
assert "no server to request a log from" in window.connection_log.toPlainText()
|
|
34
|
+
finally:
|
|
35
|
+
window.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_received_server_log_opens_a_viewer_dialog(qapp, tmp_path):
|
|
39
|
+
from remotedesktop.logs import PeerLogDialog
|
|
40
|
+
|
|
41
|
+
window = make_window(tmp_path)
|
|
42
|
+
try:
|
|
43
|
+
window._show_server_log("some log text")
|
|
44
|
+
dialog = window.findChild(PeerLogDialog)
|
|
45
|
+
assert dialog is not None
|
|
46
|
+
assert "Log from server" in dialog.windowTitle()
|
|
47
|
+
dialog.close()
|
|
48
|
+
finally:
|
|
49
|
+
window.close()
|
|
50
|
+
|
|
51
|
+
|
|
29
52
|
def test_window_starts_disconnected(qapp, tmp_path):
|
|
30
53
|
window = make_window(tmp_path)
|
|
31
54
|
assert window.statusBar().currentMessage() == "Not connected"
|
|
@@ -21,3 +21,21 @@ def test_init_logging_writes_formatted_records(tmp_path):
|
|
|
21
21
|
for handler in list(root.handlers):
|
|
22
22
|
root.removeHandler(handler)
|
|
23
23
|
handler.close()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_read_log_tail_returns_recent_content(tmp_path):
|
|
27
|
+
(tmp_path / "server.log").write_text("early line\nrecent line\n", encoding="utf-8")
|
|
28
|
+
text = logs.read_log_tail("server", directory=tmp_path)
|
|
29
|
+
assert "early line" in text and "recent line" in text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_read_log_tail_keeps_only_the_end_of_a_big_log(tmp_path):
|
|
33
|
+
(tmp_path / "client.log").write_bytes(b"OLD" * 1000 + b"NEWEST")
|
|
34
|
+
text = logs.read_log_tail("client", directory=tmp_path, max_bytes=100)
|
|
35
|
+
assert len(text) == 100
|
|
36
|
+
assert text.endswith("NEWEST")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_read_log_tail_without_a_log_file_reports_instead_of_failing(tmp_path):
|
|
40
|
+
text = logs.read_log_tail("server", directory=tmp_path)
|
|
41
|
+
assert "no log available" in text
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The pump must start its native timer exactly for the span of the modal
|
|
2
|
+
move/size loop, and its timer callback must never re-enter the Qt pump.
|
|
3
|
+
|
|
4
|
+
All tests inject a fake timer backend, so no real Win32 timer is created.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ctypes
|
|
8
|
+
from ctypes import wintypes
|
|
9
|
+
|
|
10
|
+
from remotedesktop.modal_loop import WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE, ModalLoopPump
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeTimers:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.calls = []
|
|
16
|
+
|
|
17
|
+
def start(self, hwnd):
|
|
18
|
+
self.calls.append(("start", hwnd))
|
|
19
|
+
|
|
20
|
+
def stop(self, hwnd):
|
|
21
|
+
self.calls.append(("stop", hwnd))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def native_message(message_id, hwnd=0xBEEF):
|
|
25
|
+
"""A wintypes.MSG whose address stands in for Qt's nativeEvent pointer."""
|
|
26
|
+
msg = wintypes.MSG()
|
|
27
|
+
msg.hWnd = hwnd
|
|
28
|
+
msg.message = message_id
|
|
29
|
+
return msg
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_enter_and_exit_bound_the_timer():
|
|
33
|
+
timers = FakeTimers()
|
|
34
|
+
pump = ModalLoopPump(pump=lambda: None, timers=timers)
|
|
35
|
+
enter, exit_ = native_message(WM_ENTERSIZEMOVE), native_message(WM_EXITSIZEMOVE)
|
|
36
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(enter))
|
|
37
|
+
assert timers.calls == [("start", 0xBEEF)]
|
|
38
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(exit_))
|
|
39
|
+
assert timers.calls == [("start", 0xBEEF), ("stop", 0xBEEF)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_exit_without_enter_is_ignored():
|
|
43
|
+
timers = FakeTimers()
|
|
44
|
+
pump = ModalLoopPump(pump=lambda: None, timers=timers)
|
|
45
|
+
exit_ = native_message(WM_EXITSIZEMOVE)
|
|
46
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(exit_))
|
|
47
|
+
assert timers.calls == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_other_messages_and_event_types_are_ignored():
|
|
51
|
+
timers = FakeTimers()
|
|
52
|
+
pump = ModalLoopPump(pump=lambda: None, timers=timers)
|
|
53
|
+
other = native_message(0x000F) # WM_PAINT
|
|
54
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(other))
|
|
55
|
+
# A non-Windows event type must not even be parsed as a MSG pointer.
|
|
56
|
+
pump.handle_native_event(b"xcb_generic_event_t", 0)
|
|
57
|
+
assert timers.calls == []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_timer_callback_pumps_but_never_reentrantly():
|
|
61
|
+
pumped = []
|
|
62
|
+
pump = ModalLoopPump(pump=lambda: pumped.append(True) or pump._on_timer(), timers=FakeTimers())
|
|
63
|
+
# The pump callable above simulates processEvents dispatching our own
|
|
64
|
+
# WM_TIMER again mid-pump; the guard must swallow that inner call.
|
|
65
|
+
pump._on_timer()
|
|
66
|
+
assert pumped == [True]
|
|
67
|
+
pump._on_timer() # guard resets between ticks
|
|
68
|
+
assert pumped == [True, True]
|
|
@@ -216,6 +216,115 @@ def test_old_peer_leaves_rtt_series_empty(qapp, credentials, tmp_path):
|
|
|
216
216
|
server.close()
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
def arm(monitor: PerformanceMonitor, stream: FakeStream, clock: FakeClock) -> None:
|
|
220
|
+
"""Answer a ping (proving the peer responsive) and tick again so the
|
|
221
|
+
pong's bytes are observed — the silence deadline starts fresh from here."""
|
|
222
|
+
monitor._on_tick()
|
|
223
|
+
stream.bytes_received += 10 # the pong's bytes arriving
|
|
224
|
+
monitor.handle_message(stream, {"type": "pong", "id": stream.sent[-1]["id"]})
|
|
225
|
+
clock.advance(1.0)
|
|
226
|
+
monitor._on_tick()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_connection_lost_after_responsive_peer_goes_silent(qapp):
|
|
230
|
+
clock = FakeClock()
|
|
231
|
+
monitor = PerformanceMonitor(dead_after_seconds=10.0, clock=clock)
|
|
232
|
+
stream = FakeStream()
|
|
233
|
+
lost = []
|
|
234
|
+
monitor.connectionLost.connect(lost.append)
|
|
235
|
+
monitor.add_stream(stream)
|
|
236
|
+
arm(monitor, stream, clock)
|
|
237
|
+
clock.advance(9.0)
|
|
238
|
+
monitor._on_tick() # bytes arrived 9 s ago: within the deadline
|
|
239
|
+
assert lost == []
|
|
240
|
+
clock.advance(2.0)
|
|
241
|
+
monitor._on_tick() # 11 s of silence
|
|
242
|
+
assert lost == [stream]
|
|
243
|
+
clock.advance(5.0)
|
|
244
|
+
monitor._on_tick() # reported once, not on every subsequent tick
|
|
245
|
+
assert lost == [stream]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_incoming_data_defers_connection_lost(qapp):
|
|
249
|
+
clock = FakeClock()
|
|
250
|
+
monitor = PerformanceMonitor(dead_after_seconds=10.0, clock=clock)
|
|
251
|
+
stream = FakeStream()
|
|
252
|
+
lost = []
|
|
253
|
+
monitor.connectionLost.connect(lost.append)
|
|
254
|
+
monitor.add_stream(stream)
|
|
255
|
+
arm(monitor, stream, clock)
|
|
256
|
+
for _ in range(4): # 24 s total, but data keeps arriving every 6 s
|
|
257
|
+
clock.advance(6.0)
|
|
258
|
+
stream.bytes_received += 1
|
|
259
|
+
monitor._on_tick()
|
|
260
|
+
assert lost == []
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_incoming_ping_also_arms_the_detector(qapp):
|
|
264
|
+
clock = FakeClock()
|
|
265
|
+
monitor = PerformanceMonitor(dead_after_seconds=10.0, clock=clock)
|
|
266
|
+
stream = FakeStream()
|
|
267
|
+
lost = []
|
|
268
|
+
monitor.connectionLost.connect(lost.append)
|
|
269
|
+
monitor.add_stream(stream)
|
|
270
|
+
monitor.handle_message(stream, {"type": "ping", "id": 1})
|
|
271
|
+
clock.advance(11.0)
|
|
272
|
+
monitor._on_tick()
|
|
273
|
+
assert lost == [stream]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_silent_legacy_peer_is_never_reported_lost(qapp):
|
|
277
|
+
# A peer that never answered a ping (pre-0.9 server) must never trip the
|
|
278
|
+
# detector: its silence on a static screen is indistinguishable from life.
|
|
279
|
+
clock = FakeClock()
|
|
280
|
+
monitor = PerformanceMonitor(dead_after_seconds=10.0, clock=clock)
|
|
281
|
+
stream = FakeStream()
|
|
282
|
+
lost = []
|
|
283
|
+
monitor.connectionLost.connect(lost.append)
|
|
284
|
+
monitor.add_stream(stream)
|
|
285
|
+
for _ in range(10):
|
|
286
|
+
clock.advance(60.0)
|
|
287
|
+
monitor._on_tick()
|
|
288
|
+
assert lost == []
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_reattaching_a_stream_resets_the_detector(qapp):
|
|
292
|
+
clock = FakeClock()
|
|
293
|
+
monitor = PerformanceMonitor(dead_after_seconds=10.0, clock=clock)
|
|
294
|
+
stream = FakeStream()
|
|
295
|
+
lost = []
|
|
296
|
+
monitor.connectionLost.connect(lost.append)
|
|
297
|
+
monitor.add_stream(stream)
|
|
298
|
+
arm(monitor, stream, clock)
|
|
299
|
+
clock.advance(11.0)
|
|
300
|
+
monitor._on_tick()
|
|
301
|
+
assert lost == [stream]
|
|
302
|
+
# A reconnect detaches and reattaches: the new session starts unarmed
|
|
303
|
+
# with a fresh deadline, so the stale silence is not reported again.
|
|
304
|
+
monitor.remove_stream(stream)
|
|
305
|
+
monitor.add_stream(stream)
|
|
306
|
+
clock.advance(60.0)
|
|
307
|
+
monitor._on_tick()
|
|
308
|
+
assert lost == [stream]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_share_client_drops_the_connection_on_lost_signal(qapp, tmp_path):
|
|
312
|
+
monitor = PerformanceMonitor()
|
|
313
|
+
client = ShareClient(
|
|
314
|
+
identity=IDENTITY,
|
|
315
|
+
known_servers=KnownServers(db.connect(tmp_path / "client.db")),
|
|
316
|
+
performance=monitor,
|
|
317
|
+
)
|
|
318
|
+
statuses, disconnects = [], []
|
|
319
|
+
client.status.connect(statuses.append)
|
|
320
|
+
client.disconnected.connect(lambda: disconnects.append(True))
|
|
321
|
+
monitor.connectionLost.emit(FakeStream()) # someone else's stream: ignored
|
|
322
|
+
assert disconnects == []
|
|
323
|
+
monitor.connectionLost.emit(client._stream)
|
|
324
|
+
assert disconnects == [True]
|
|
325
|
+
assert any("Connection lost" in s and "10 s" in s for s in statuses)
|
|
326
|
+
|
|
327
|
+
|
|
219
328
|
def test_graph_widgets_render_headless(qapp):
|
|
220
329
|
seeded = PerformanceMonitor()
|
|
221
330
|
seeded.send_bps.add(100.0)
|
|
@@ -28,6 +28,53 @@ def make_window(credentials, tmp_path, *, discovery_port=None, connect_port=0):
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def test_get_client_log_button_needs_a_client(qapp, credentials, tmp_path):
|
|
32
|
+
window = make_window(credentials, tmp_path)
|
|
33
|
+
try:
|
|
34
|
+
window.get_log_button.click()
|
|
35
|
+
assert "No connected client" in window.connection_log.toPlainText()
|
|
36
|
+
finally:
|
|
37
|
+
window.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_received_client_log_opens_a_viewer_dialog(qapp, credentials, tmp_path):
|
|
41
|
+
from remotedesktop.logs import PeerLogDialog
|
|
42
|
+
|
|
43
|
+
window = make_window(credentials, tmp_path)
|
|
44
|
+
try:
|
|
45
|
+
window._show_client_log("laptop", "some log text")
|
|
46
|
+
dialog = window.findChild(PeerLogDialog)
|
|
47
|
+
assert dialog is not None
|
|
48
|
+
assert 'Log from client "laptop"' == dialog.windowTitle()
|
|
49
|
+
dialog.close()
|
|
50
|
+
finally:
|
|
51
|
+
window.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_native_size_move_messages_drive_the_modal_pump(qapp, credentials, tmp_path):
|
|
55
|
+
import ctypes
|
|
56
|
+
from ctypes import wintypes
|
|
57
|
+
|
|
58
|
+
from shiboken6 import VoidPtr
|
|
59
|
+
|
|
60
|
+
from remotedesktop.modal_loop import WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE, ModalLoopPump
|
|
61
|
+
from test_modal_loop import FakeTimers
|
|
62
|
+
|
|
63
|
+
window = make_window(credentials, tmp_path)
|
|
64
|
+
try:
|
|
65
|
+
timers = FakeTimers()
|
|
66
|
+
window._modal_pump = ModalLoopPump(pump=lambda: None, timers=timers)
|
|
67
|
+
msg = wintypes.MSG()
|
|
68
|
+
msg.hWnd, msg.message = 0xBEEF, WM_ENTERSIZEMOVE
|
|
69
|
+
# Qt hands nativeEvent a void*; VoidPtr is that shape from Python.
|
|
70
|
+
window.nativeEvent(b"windows_generic_MSG", VoidPtr(ctypes.addressof(msg)))
|
|
71
|
+
msg.message = WM_EXITSIZEMOVE
|
|
72
|
+
window.nativeEvent(b"windows_generic_MSG", VoidPtr(ctypes.addressof(msg)))
|
|
73
|
+
assert timers.calls == [("start", 0xBEEF), ("stop", 0xBEEF)]
|
|
74
|
+
finally:
|
|
75
|
+
window.close()
|
|
76
|
+
|
|
77
|
+
|
|
31
78
|
def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
|
|
32
79
|
window = make_window(credentials, tmp_path)
|
|
33
80
|
try:
|
|
@@ -22,7 +22,9 @@ def pump(qapp, condition, timeout=10.0):
|
|
|
22
22
|
qapp.processEvents(QEventLoop.ProcessEventsFlag.AllEvents, 50)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
def make_server(
|
|
25
|
+
def make_server(
|
|
26
|
+
credentials, tmp_path, *, approve, injector=None, clipboard=None, log_provider=None
|
|
27
|
+
):
|
|
26
28
|
# The server is a distinct "machine" from the client -> its own database.
|
|
27
29
|
server = ShareServer(
|
|
28
30
|
approve_client=approve,
|
|
@@ -30,16 +32,18 @@ def make_server(credentials, tmp_path, *, approve, injector=None, clipboard=None
|
|
|
30
32
|
paired=PairedClients(db.connect(tmp_path / "server.db")),
|
|
31
33
|
injector=injector,
|
|
32
34
|
clipboard=clipboard,
|
|
35
|
+
log_provider=log_provider,
|
|
33
36
|
)
|
|
34
37
|
assert server.listen(0)
|
|
35
38
|
return server
|
|
36
39
|
|
|
37
40
|
|
|
38
|
-
def make_client(tmp_path, *, clipboard=None):
|
|
41
|
+
def make_client(tmp_path, *, clipboard=None, log_provider=None):
|
|
39
42
|
return ShareClient(
|
|
40
43
|
identity=IDENTITY,
|
|
41
44
|
known_servers=KnownServers(db.connect(tmp_path / "client.db")),
|
|
42
45
|
clipboard=clipboard,
|
|
46
|
+
log_provider=log_provider,
|
|
43
47
|
)
|
|
44
48
|
|
|
45
49
|
|
|
@@ -537,6 +541,62 @@ def test_oversized_preauth_message_aborts_and_logs(qapp, credentials, tmp_path,
|
|
|
537
541
|
server.close()
|
|
538
542
|
|
|
539
543
|
|
|
544
|
+
def test_client_fetches_the_server_log(qapp, credentials, tmp_path):
|
|
545
|
+
server = make_server(
|
|
546
|
+
credentials, tmp_path, approve=lambda *_: True,
|
|
547
|
+
log_provider=lambda: "SERVER LOG TAIL",
|
|
548
|
+
)
|
|
549
|
+
client = make_client(tmp_path)
|
|
550
|
+
connected, received = [], []
|
|
551
|
+
client.connected.connect(connected.append)
|
|
552
|
+
client.logReceived.connect(received.append)
|
|
553
|
+
client.connect_to("127.0.0.1", server.port)
|
|
554
|
+
try:
|
|
555
|
+
pump(qapp, lambda: connected)
|
|
556
|
+
client.request_log()
|
|
557
|
+
pump(qapp, lambda: received)
|
|
558
|
+
assert received == ["SERVER LOG TAIL"]
|
|
559
|
+
finally:
|
|
560
|
+
client.close()
|
|
561
|
+
server.close()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def test_server_fetches_the_client_log(qapp, credentials, tmp_path):
|
|
565
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
566
|
+
client = make_client(tmp_path, log_provider=lambda: "CLIENT LOG TAIL")
|
|
567
|
+
connected, received = [], []
|
|
568
|
+
client.connected.connect(connected.append)
|
|
569
|
+
server.logReceived.connect(lambda name, text: received.append((name, text)))
|
|
570
|
+
client.connect_to("127.0.0.1", server.port)
|
|
571
|
+
try:
|
|
572
|
+
pump(qapp, lambda: connected)
|
|
573
|
+
server.request_log()
|
|
574
|
+
pump(qapp, lambda: received)
|
|
575
|
+
assert received == [("test-client", "CLIENT LOG TAIL")]
|
|
576
|
+
finally:
|
|
577
|
+
client.close()
|
|
578
|
+
server.close()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def test_log_request_without_a_provider_still_answers(qapp, credentials, tmp_path):
|
|
582
|
+
# The requesting side must never hang waiting: a peer with no log file
|
|
583
|
+
# (or none configured) answers with a placeholder.
|
|
584
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
585
|
+
client = make_client(tmp_path)
|
|
586
|
+
connected, received = [], []
|
|
587
|
+
client.connected.connect(connected.append)
|
|
588
|
+
client.logReceived.connect(received.append)
|
|
589
|
+
client.connect_to("127.0.0.1", server.port)
|
|
590
|
+
try:
|
|
591
|
+
pump(qapp, lambda: connected)
|
|
592
|
+
client.request_log()
|
|
593
|
+
pump(qapp, lambda: received)
|
|
594
|
+
assert "no log available" in received[0]
|
|
595
|
+
finally:
|
|
596
|
+
client.close()
|
|
597
|
+
server.close()
|
|
598
|
+
|
|
599
|
+
|
|
540
600
|
def test_server_reports_phases_in_status(qapp, credentials, tmp_path):
|
|
541
601
|
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
542
602
|
statuses: list[str] = []
|
|
@@ -1,46 +0,0 @@
|
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
def default_log_dir() -> Path:
|
|
22
|
-
return Path(platformdirs.user_log_dir("remotedesktop", appauthor=False))
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def init_logging(app_name: str, *, directory: Path | None = None) -> Path:
|
|
26
|
-
"""Write "remotedesktop" logger output to <log dir>/<app_name>.log.
|
|
27
|
-
|
|
28
|
-
Returns the log file path. `directory` exists for tests; the apps use
|
|
29
|
-
the platformdirs log dir.
|
|
30
|
-
"""
|
|
31
|
-
log_dir = directory if directory is not None else default_log_dir()
|
|
32
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
-
path = log_dir / f"{app_name}.log"
|
|
34
|
-
handler = logging.handlers.RotatingFileHandler(
|
|
35
|
-
path, maxBytes=2_000_000, backupCount=3, encoding="utf-8"
|
|
36
|
-
)
|
|
37
|
-
handler.setFormatter(
|
|
38
|
-
logging.Formatter(
|
|
39
|
-
"%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s %(message)s",
|
|
40
|
-
datefmt="%Y-%m-%d %H:%M:%S",
|
|
41
|
-
)
|
|
42
|
-
)
|
|
43
|
-
logger = logging.getLogger("remotedesktop")
|
|
44
|
-
logger.addHandler(handler)
|
|
45
|
-
logger.setLevel(logging.DEBUG)
|
|
46
|
-
return path
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|