remotedesktop 0.12.0__tar.gz → 0.13.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.12.0 → remotedesktop-0.13.0}/CLAUDE.md +1 -1
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/PKG-INFO +1 -1
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/__init__.py +1 -1
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/frames.py +25 -14
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/inventory.py +221 -218
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/modal_loop.py +22 -8
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/performance.py +6 -5
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/sharing.py +7 -3
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_frames.py +29 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_inventory.py +125 -113
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_modal_loop.py +46 -1
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_performance.py +6 -5
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/.github/workflows/ci.yml +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/.gitignore +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/LICENSE +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/pyproject.toml +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/readme.md +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/run_claude.bat +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/run_client.bat +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/run_server.bat +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/autostart.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/icon.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/logs.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/preferences.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/server.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/conftest.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_autostart.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_client_window.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_config.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_icon.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_input.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_logs.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_preferences.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_server_window.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/tests/test_window_state.py +0 -0
- {remotedesktop-0.12.0 → remotedesktop-0.13.0}/uv.lock +0 -0
|
@@ -47,7 +47,7 @@ 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`
|
|
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` from `WM_NCLBUTTONDOWN` (a press-and-hold that never moves blocks in DefWindowProc's click tracking without ever sending `WM_ENTERSIZEMOVE`) until `WM_CAPTURECHANGED`/`WM_EXITSIZEMOVE`, whose callback pumps Qt events (user input excluded). Tests inject a fake `timers` backend — never let them create real Win32 timers.
|
|
51
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.
|
|
52
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.
|
|
53
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: remotedesktop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.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
|
|
@@ -23,6 +23,11 @@ from PySide6.QtCore import QBuffer
|
|
|
23
23
|
from PySide6.QtGui import QImage, QPainter
|
|
24
24
|
|
|
25
25
|
BAND_HEIGHT = 64
|
|
26
|
+
# PNG is lossless at any "quality" — the setting only picks the zlib effort.
|
|
27
|
+
# 80 encodes a 4K frame ~35% faster than the default for ~20% more bytes;
|
|
28
|
+
# encoding happens on the GUI thread every tick, so time matters more than
|
|
29
|
+
# size on a LAN.
|
|
30
|
+
PNG_QUALITY = 80
|
|
26
31
|
_HEADER_LEN = struct.Struct(">I")
|
|
27
32
|
|
|
28
33
|
|
|
@@ -63,7 +68,7 @@ def encode_delta(image: QImage, bands: list[tuple[int, int]]) -> bytes:
|
|
|
63
68
|
entries = []
|
|
64
69
|
blobs = []
|
|
65
70
|
for y, h in bands:
|
|
66
|
-
png = encode_image(image.copy(0, y, image.width(), h))
|
|
71
|
+
png = encode_image(image.copy(0, y, image.width(), h), "PNG", PNG_QUALITY)
|
|
67
72
|
entries.append({"y": y, "h": h, "len": len(png)})
|
|
68
73
|
blobs.append(png)
|
|
69
74
|
header = json.dumps({"w": image.width(), "h": image.height(), "bands": entries}).encode()
|
|
@@ -71,11 +76,15 @@ def encode_delta(image: QImage, bands: list[tuple[int, int]]) -> bytes:
|
|
|
71
76
|
|
|
72
77
|
|
|
73
78
|
def apply_delta(canvas: QImage, payload: bytes) -> QImage | None:
|
|
74
|
-
"""Patch a delta payload onto
|
|
75
|
-
|
|
76
|
-
Returns the patched image, or None
|
|
77
|
-
|
|
78
|
-
the next keyframe).
|
|
79
|
+
"""Patch a delta payload onto `canvas`, in place.
|
|
80
|
+
|
|
81
|
+
Returns the patched image (`canvas` itself), or None — with the canvas
|
|
82
|
+
untouched — if the payload is malformed or was produced for a different
|
|
83
|
+
frame size (the caller should then wait for the next keyframe). Every
|
|
84
|
+
band is decoded before any is painted, so a payload that goes bad
|
|
85
|
+
halfway never leaves a half-patched canvas. Painting in place skips a
|
|
86
|
+
full-frame copy per delta; QImage's copy-on-write still protects any
|
|
87
|
+
other holder of the image.
|
|
79
88
|
"""
|
|
80
89
|
try:
|
|
81
90
|
(header_len,) = _HEADER_LEN.unpack_from(payload)
|
|
@@ -86,16 +95,18 @@ def apply_delta(canvas: QImage, payload: bytes) -> QImage | None:
|
|
|
86
95
|
return None
|
|
87
96
|
if not size_ok:
|
|
88
97
|
return None
|
|
89
|
-
|
|
90
|
-
painter = QPainter(image)
|
|
98
|
+
decoded: list[tuple[int, QImage]] = []
|
|
91
99
|
offset = _HEADER_LEN.size + header_len
|
|
100
|
+
for y, h, length in bands:
|
|
101
|
+
band = QImage.fromData(payload[offset : offset + length])
|
|
102
|
+
offset += length
|
|
103
|
+
if band.isNull() or band.height() != h:
|
|
104
|
+
return None
|
|
105
|
+
decoded.append((y, band))
|
|
106
|
+
painter = QPainter(canvas)
|
|
92
107
|
try:
|
|
93
|
-
for y,
|
|
94
|
-
band = QImage.fromData(payload[offset : offset + length])
|
|
95
|
-
offset += length
|
|
96
|
-
if band.isNull() or band.height() != h:
|
|
97
|
-
return None
|
|
108
|
+
for y, band in decoded:
|
|
98
109
|
painter.drawImage(0, y, band)
|
|
99
110
|
finally:
|
|
100
111
|
painter.end()
|
|
101
|
-
return
|
|
112
|
+
return canvas
|
|
@@ -1,218 +1,221 @@
|
|
|
1
|
-
"""A running inventory of every peer this app has seen on the LAN.
|
|
2
|
-
|
|
3
|
-
Both apps keep one `ConnectionInventory`: the server records every client that
|
|
4
|
-
connects or attempts to, the client records every server it discovers or tries
|
|
5
|
-
to reach. `InventoryTab` shows it as a table and, optionally, a button to act
|
|
6
|
-
on the selected peer (revoke a client / forget a server). Persisted in SQLite,
|
|
7
|
-
so it answers "who is using this on the LAN, and who tried?" across restarts.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import sqlite3
|
|
11
|
-
import time
|
|
12
|
-
from collections.abc import Callable
|
|
13
|
-
from dataclasses import astuple, dataclass, fields
|
|
14
|
-
|
|
15
|
-
from PySide6.QtCore import Qt, QObject, Signal
|
|
16
|
-
from PySide6.QtWidgets import (
|
|
17
|
-
QAbstractItemView,
|
|
18
|
-
QHeaderView,
|
|
19
|
-
QPushButton,
|
|
20
|
-
QTableWidget,
|
|
21
|
-
QTableWidgetItem,
|
|
22
|
-
QVBoxLayout,
|
|
23
|
-
QWidget,
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
from remotedesktop import db
|
|
27
|
-
|
|
28
|
-
# Event -> the state to display for the peer after it happens.
|
|
29
|
-
_EVENT_STATE = {
|
|
30
|
-
"discovered": "discovered",
|
|
31
|
-
"attempt": "connecting",
|
|
32
|
-
"connected": "connected",
|
|
33
|
-
"authenticated": "connected (token)",
|
|
34
|
-
"paired": "connected (paired)",
|
|
35
|
-
"denied": "denied",
|
|
36
|
-
"refused": "refused",
|
|
37
|
-
"disconnected": "disconnected",
|
|
38
|
-
"revoked": "revoked",
|
|
39
|
-
"forgotten": "forgotten",
|
|
40
|
-
"error": "error",
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@dataclass
|
|
45
|
-
class PeerRecord:
|
|
46
|
-
key: str
|
|
47
|
-
name: str
|
|
48
|
-
address: str
|
|
49
|
-
detail: str
|
|
50
|
-
first_seen: str
|
|
51
|
-
last_seen: str
|
|
52
|
-
attempts: int
|
|
53
|
-
state: str
|
|
54
|
-
last_event: str
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class ConnectionInventory(QObject):
|
|
58
|
-
"""Peers seen on the LAN, backed by SQLite so it persists across restarts.
|
|
59
|
-
|
|
60
|
-
Pass a `sqlite3.Connection` from `db.connect`; omit it for an in-memory
|
|
61
|
-
database that lives only as long as the object (used by tests).
|
|
62
|
-
"""
|
|
63
|
-
|
|
64
|
-
changed = Signal()
|
|
65
|
-
|
|
66
|
-
_COLUMNS = [f.name for f in fields(PeerRecord)]
|
|
67
|
-
|
|
68
|
-
def __init__(
|
|
69
|
-
self,
|
|
70
|
-
connection: sqlite3.Connection | None = None,
|
|
71
|
-
table: str = "peers",
|
|
72
|
-
parent: QObject | None = None,
|
|
73
|
-
) -> None:
|
|
74
|
-
super().__init__(parent)
|
|
75
|
-
if table not in db.PEER_TABLES:
|
|
76
|
-
raise ValueError(f"unknown inventory table: {table!r}")
|
|
77
|
-
self._table = table
|
|
78
|
-
self._peers: dict[str, PeerRecord] = {}
|
|
79
|
-
self._db = connection if connection is not None else db.connect(None)
|
|
80
|
-
self._load()
|
|
81
|
-
|
|
82
|
-
@staticmethod
|
|
83
|
-
def _now() -> str:
|
|
84
|
-
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
85
|
-
|
|
86
|
-
def _load(self) -> None:
|
|
87
|
-
try:
|
|
88
|
-
rows = self._db.execute(f"SELECT {', '.join(self._COLUMNS)} FROM {self._table}")
|
|
89
|
-
for row in rows:
|
|
90
|
-
record = PeerRecord(*row)
|
|
91
|
-
self._peers[record.key] = record
|
|
92
|
-
except sqlite3.Error:
|
|
93
|
-
self._peers.clear()
|
|
94
|
-
|
|
95
|
-
def _save(self, record: PeerRecord) -> None:
|
|
96
|
-
placeholders = ", ".join("?" for _ in self._COLUMNS)
|
|
97
|
-
updates = ", ".join(f"{c}=excluded.{c}" for c in self._COLUMNS if c != "key")
|
|
98
|
-
try:
|
|
99
|
-
self._db.execute(
|
|
100
|
-
f"INSERT INTO {self._table} ({', '.join(self._COLUMNS)}) "
|
|
101
|
-
f"VALUES ({placeholders}) ON CONFLICT(key) DO UPDATE SET {updates}",
|
|
102
|
-
astuple(record),
|
|
103
|
-
)
|
|
104
|
-
self._db.commit()
|
|
105
|
-
except sqlite3.Error:
|
|
106
|
-
pass # persistence failure must never break connectivity
|
|
107
|
-
|
|
108
|
-
def record(
|
|
109
|
-
self,
|
|
110
|
-
key: str,
|
|
111
|
-
event: str,
|
|
112
|
-
*,
|
|
113
|
-
name: str = "",
|
|
114
|
-
address: str = "",
|
|
115
|
-
detail: str = "",
|
|
116
|
-
) -> None:
|
|
117
|
-
now = self._now()
|
|
118
|
-
record = self._peers.get(key)
|
|
119
|
-
if record is None:
|
|
120
|
-
record = PeerRecord(key, name, address, detail, now, now, 0, "", "")
|
|
121
|
-
self._peers[key] = record
|
|
122
|
-
record.last_seen = now
|
|
123
|
-
if name:
|
|
124
|
-
record.name = name
|
|
125
|
-
if address:
|
|
126
|
-
record.address = address
|
|
127
|
-
if detail:
|
|
128
|
-
record.detail = detail
|
|
129
|
-
if event == "attempt":
|
|
130
|
-
record.attempts += 1
|
|
131
|
-
record.state = _EVENT_STATE.get(event, event)
|
|
132
|
-
record.last_event = event
|
|
133
|
-
self._save(record)
|
|
134
|
-
self.changed.emit()
|
|
135
|
-
|
|
136
|
-
def peers(self) -> list[PeerRecord]:
|
|
137
|
-
return sorted(self._peers.values(), key=lambda r: r.last_seen, reverse=True)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
class InventoryTab(QWidget):
|
|
141
|
-
"""Table view of a ConnectionInventory, refreshed as it changes.
|
|
142
|
-
|
|
143
|
-
If `action_label`/`action_callback` are given, a button below the table is
|
|
144
|
-
enabled when a row is selected and calls `action_callback(peer_key)`.
|
|
145
|
-
"""
|
|
146
|
-
|
|
147
|
-
_COLUMNS = ["Name", "Address", "Identifier", "State", "Attempts", "First seen", "Last seen"]
|
|
148
|
-
|
|
149
|
-
def __init__(
|
|
150
|
-
self,
|
|
151
|
-
inventory: ConnectionInventory,
|
|
152
|
-
action_label: str | None = None,
|
|
153
|
-
action_callback: Callable[[str], None] | None = None,
|
|
154
|
-
parent: QWidget | None = None,
|
|
155
|
-
) -> None:
|
|
156
|
-
super().__init__(parent)
|
|
157
|
-
self._inventory = inventory
|
|
158
|
-
self._action_callback = action_callback
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
self._table
|
|
163
|
-
self._table.
|
|
164
|
-
self._table.
|
|
165
|
-
self._table.
|
|
166
|
-
|
|
167
|
-
)
|
|
168
|
-
self._table.horizontalHeader().
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
self.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
peer.
|
|
208
|
-
|
|
209
|
-
peer.
|
|
210
|
-
peer.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
1
|
+
"""A running inventory of every peer this app has seen on the LAN.
|
|
2
|
+
|
|
3
|
+
Both apps keep one `ConnectionInventory`: the server records every client that
|
|
4
|
+
connects or attempts to, the client records every server it discovers or tries
|
|
5
|
+
to reach. `InventoryTab` shows it as a table and, optionally, a button to act
|
|
6
|
+
on the selected peer (revoke a client / forget a server). Persisted in SQLite,
|
|
7
|
+
so it answers "who is using this on the LAN, and who tried?" across restarts.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import astuple, dataclass, fields
|
|
14
|
+
|
|
15
|
+
from PySide6.QtCore import Qt, QObject, Signal
|
|
16
|
+
from PySide6.QtWidgets import (
|
|
17
|
+
QAbstractItemView,
|
|
18
|
+
QHeaderView,
|
|
19
|
+
QPushButton,
|
|
20
|
+
QTableWidget,
|
|
21
|
+
QTableWidgetItem,
|
|
22
|
+
QVBoxLayout,
|
|
23
|
+
QWidget,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from remotedesktop import db
|
|
27
|
+
|
|
28
|
+
# Event -> the state to display for the peer after it happens.
|
|
29
|
+
_EVENT_STATE = {
|
|
30
|
+
"discovered": "discovered",
|
|
31
|
+
"attempt": "connecting",
|
|
32
|
+
"connected": "connected",
|
|
33
|
+
"authenticated": "connected (token)",
|
|
34
|
+
"paired": "connected (paired)",
|
|
35
|
+
"denied": "denied",
|
|
36
|
+
"refused": "refused",
|
|
37
|
+
"disconnected": "disconnected",
|
|
38
|
+
"revoked": "revoked",
|
|
39
|
+
"forgotten": "forgotten",
|
|
40
|
+
"error": "error",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PeerRecord:
|
|
46
|
+
key: str
|
|
47
|
+
name: str
|
|
48
|
+
address: str
|
|
49
|
+
detail: str
|
|
50
|
+
first_seen: str
|
|
51
|
+
last_seen: str
|
|
52
|
+
attempts: int
|
|
53
|
+
state: str
|
|
54
|
+
last_event: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ConnectionInventory(QObject):
|
|
58
|
+
"""Peers seen on the LAN, backed by SQLite so it persists across restarts.
|
|
59
|
+
|
|
60
|
+
Pass a `sqlite3.Connection` from `db.connect`; omit it for an in-memory
|
|
61
|
+
database that lives only as long as the object (used by tests).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
changed = Signal()
|
|
65
|
+
|
|
66
|
+
_COLUMNS = [f.name for f in fields(PeerRecord)]
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
connection: sqlite3.Connection | None = None,
|
|
71
|
+
table: str = "peers",
|
|
72
|
+
parent: QObject | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__(parent)
|
|
75
|
+
if table not in db.PEER_TABLES:
|
|
76
|
+
raise ValueError(f"unknown inventory table: {table!r}")
|
|
77
|
+
self._table = table
|
|
78
|
+
self._peers: dict[str, PeerRecord] = {}
|
|
79
|
+
self._db = connection if connection is not None else db.connect(None)
|
|
80
|
+
self._load()
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _now() -> str:
|
|
84
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
85
|
+
|
|
86
|
+
def _load(self) -> None:
|
|
87
|
+
try:
|
|
88
|
+
rows = self._db.execute(f"SELECT {', '.join(self._COLUMNS)} FROM {self._table}")
|
|
89
|
+
for row in rows:
|
|
90
|
+
record = PeerRecord(*row)
|
|
91
|
+
self._peers[record.key] = record
|
|
92
|
+
except sqlite3.Error:
|
|
93
|
+
self._peers.clear()
|
|
94
|
+
|
|
95
|
+
def _save(self, record: PeerRecord) -> None:
|
|
96
|
+
placeholders = ", ".join("?" for _ in self._COLUMNS)
|
|
97
|
+
updates = ", ".join(f"{c}=excluded.{c}" for c in self._COLUMNS if c != "key")
|
|
98
|
+
try:
|
|
99
|
+
self._db.execute(
|
|
100
|
+
f"INSERT INTO {self._table} ({', '.join(self._COLUMNS)}) "
|
|
101
|
+
f"VALUES ({placeholders}) ON CONFLICT(key) DO UPDATE SET {updates}",
|
|
102
|
+
astuple(record),
|
|
103
|
+
)
|
|
104
|
+
self._db.commit()
|
|
105
|
+
except sqlite3.Error:
|
|
106
|
+
pass # persistence failure must never break connectivity
|
|
107
|
+
|
|
108
|
+
def record(
|
|
109
|
+
self,
|
|
110
|
+
key: str,
|
|
111
|
+
event: str,
|
|
112
|
+
*,
|
|
113
|
+
name: str = "",
|
|
114
|
+
address: str = "",
|
|
115
|
+
detail: str = "",
|
|
116
|
+
) -> None:
|
|
117
|
+
now = self._now()
|
|
118
|
+
record = self._peers.get(key)
|
|
119
|
+
if record is None:
|
|
120
|
+
record = PeerRecord(key, name, address, detail, now, now, 0, "", "")
|
|
121
|
+
self._peers[key] = record
|
|
122
|
+
record.last_seen = now
|
|
123
|
+
if name:
|
|
124
|
+
record.name = name
|
|
125
|
+
if address:
|
|
126
|
+
record.address = address
|
|
127
|
+
if detail:
|
|
128
|
+
record.detail = detail
|
|
129
|
+
if event == "attempt":
|
|
130
|
+
record.attempts += 1
|
|
131
|
+
record.state = _EVENT_STATE.get(event, event)
|
|
132
|
+
record.last_event = event
|
|
133
|
+
self._save(record)
|
|
134
|
+
self.changed.emit()
|
|
135
|
+
|
|
136
|
+
def peers(self) -> list[PeerRecord]:
|
|
137
|
+
return sorted(self._peers.values(), key=lambda r: r.last_seen, reverse=True)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class InventoryTab(QWidget):
|
|
141
|
+
"""Table view of a ConnectionInventory, refreshed as it changes.
|
|
142
|
+
|
|
143
|
+
If `action_label`/`action_callback` are given, a button below the table is
|
|
144
|
+
enabled when a row is selected and calls `action_callback(peer_key)`.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
_COLUMNS = ["Name", "Address", "Identifier", "State", "Attempts", "First seen", "Last seen"]
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
inventory: ConnectionInventory,
|
|
152
|
+
action_label: str | None = None,
|
|
153
|
+
action_callback: Callable[[str], None] | None = None,
|
|
154
|
+
parent: QWidget | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
super().__init__(parent)
|
|
157
|
+
self._inventory = inventory
|
|
158
|
+
self._action_callback = action_callback
|
|
159
|
+
# One extra headerless column on the right: with stretchLastSection it
|
|
160
|
+
# absorbs the leftover width, so the data columns (including "Last
|
|
161
|
+
# seen") stay sized to their contents.
|
|
162
|
+
self._table = QTableWidget(0, len(self._COLUMNS) + 1)
|
|
163
|
+
self._table.setHorizontalHeaderLabels(self._COLUMNS + [""])
|
|
164
|
+
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
165
|
+
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
166
|
+
self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
167
|
+
self._table.verticalHeader().setVisible(False)
|
|
168
|
+
self._table.horizontalHeader().setSectionResizeMode(
|
|
169
|
+
QHeaderView.ResizeMode.ResizeToContents
|
|
170
|
+
)
|
|
171
|
+
self._table.horizontalHeader().setStretchLastSection(True)
|
|
172
|
+
layout = QVBoxLayout(self)
|
|
173
|
+
layout.addWidget(self._table)
|
|
174
|
+
|
|
175
|
+
self._action_button: QPushButton | None = None
|
|
176
|
+
if action_label and action_callback:
|
|
177
|
+
self._action_button = QPushButton(action_label)
|
|
178
|
+
self._action_button.setEnabled(False)
|
|
179
|
+
self._action_button.clicked.connect(self._on_action)
|
|
180
|
+
self._table.itemSelectionChanged.connect(self._on_selection_changed)
|
|
181
|
+
layout.addWidget(self._action_button)
|
|
182
|
+
|
|
183
|
+
inventory.changed.connect(self.refresh)
|
|
184
|
+
self.refresh()
|
|
185
|
+
|
|
186
|
+
def _selected_key(self) -> str | None:
|
|
187
|
+
row = self._table.currentRow()
|
|
188
|
+
if row < 0:
|
|
189
|
+
return None
|
|
190
|
+
item = self._table.item(row, 0)
|
|
191
|
+
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
|
192
|
+
|
|
193
|
+
def _on_selection_changed(self) -> None:
|
|
194
|
+
if self._action_button is not None:
|
|
195
|
+
self._action_button.setEnabled(self._selected_key() is not None)
|
|
196
|
+
|
|
197
|
+
def _on_action(self) -> None:
|
|
198
|
+
key = self._selected_key()
|
|
199
|
+
if key and self._action_callback is not None:
|
|
200
|
+
self._action_callback(key)
|
|
201
|
+
|
|
202
|
+
def refresh(self) -> None:
|
|
203
|
+
peers = self._inventory.peers()
|
|
204
|
+
self._table.setRowCount(len(peers))
|
|
205
|
+
for row, peer in enumerate(peers):
|
|
206
|
+
values = [
|
|
207
|
+
peer.name or "(unknown)",
|
|
208
|
+
peer.address,
|
|
209
|
+
peer.detail,
|
|
210
|
+
peer.state,
|
|
211
|
+
str(peer.attempts),
|
|
212
|
+
peer.first_seen,
|
|
213
|
+
peer.last_seen,
|
|
214
|
+
]
|
|
215
|
+
for column, value in enumerate(values):
|
|
216
|
+
item = QTableWidgetItem(value)
|
|
217
|
+
if column == 0:
|
|
218
|
+
item.setData(Qt.ItemDataRole.UserRole, peer.key)
|
|
219
|
+
self._table.setItem(row, column, item)
|
|
220
|
+
if self._action_button is not None:
|
|
221
|
+
self._action_button.setEnabled(self._selected_key() is not None)
|
|
@@ -8,10 +8,14 @@ the server window's own title bar enters the loop, and the mouse-up that
|
|
|
8
8
|
would end it sits unread on a socket the frozen event loop never services,
|
|
9
9
|
until someone at the server machine intervenes.
|
|
10
10
|
|
|
11
|
-
Native WM_TIMER callbacks *are* dispatched inside modal loops, so
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
Native WM_TIMER callbacks *are* dispatched inside modal loops, so from the
|
|
12
|
+
non-client mouse press (WM_NCLBUTTONDOWN — a press-and-hold that never
|
|
13
|
+
moves blocks in DefWindowProc's click tracking without ever sending
|
|
14
|
+
WM_ENTERSIZEMOVE) until the tracking loop releases mouse capture
|
|
15
|
+
(WM_CAPTURECHANGED, or WM_EXITSIZEMOVE after an actual drag), a SetTimer
|
|
16
|
+
callback pumps Qt events (user input excluded) every few milliseconds. A
|
|
17
|
+
side benefit: screen sharing keeps streaming while the local user drags
|
|
18
|
+
the server window.
|
|
15
19
|
|
|
16
20
|
Feed every message from the window's `nativeEvent` to
|
|
17
21
|
`handle_native_event`; the pump is inert off Windows. Tests inject a fake
|
|
@@ -28,6 +32,8 @@ from PySide6.QtCore import QCoreApplication, QEventLoop
|
|
|
28
32
|
|
|
29
33
|
_log = logging.getLogger("remotedesktop.modal_loop")
|
|
30
34
|
|
|
35
|
+
WM_NCLBUTTONDOWN = 0x00A1
|
|
36
|
+
WM_CAPTURECHANGED = 0x0215
|
|
31
37
|
WM_ENTERSIZEMOVE = 0x0231
|
|
32
38
|
WM_EXITSIZEMOVE = 0x0232
|
|
33
39
|
_TIMER_ID = 0x5244 # arbitrary but stable; scoped to the window's hwnd
|
|
@@ -74,24 +80,32 @@ class ModalLoopPump:
|
|
|
74
80
|
if self._timers is None or bytes(event_type) != b"windows_generic_MSG":
|
|
75
81
|
return
|
|
76
82
|
msg = wintypes.MSG.from_address(int(message))
|
|
77
|
-
|
|
83
|
+
# WM_ENTERSIZEMOVE alone is not enough: a press-and-hold on the title
|
|
84
|
+
# bar that never moves blocks the thread inside DefWindowProc's click
|
|
85
|
+
# tracking WITHOUT ever sending WM_ENTERSIZEMOVE, so the pump must
|
|
86
|
+
# arm on the non-client press itself. The tracking loop takes mouse
|
|
87
|
+
# capture, so WM_CAPTURECHANGED marks its end whether or not a drag
|
|
88
|
+
# (and its WM_EXITSIZEMOVE) ever happened.
|
|
89
|
+
if msg.message in (WM_NCLBUTTONDOWN, WM_ENTERSIZEMOVE):
|
|
78
90
|
self._enter(msg.hWnd or 0)
|
|
79
|
-
elif msg.message
|
|
91
|
+
elif msg.message in (WM_CAPTURECHANGED, WM_EXITSIZEMOVE):
|
|
80
92
|
self._exit()
|
|
81
93
|
|
|
82
94
|
def _enter(self, hwnd: int) -> None:
|
|
95
|
+
if self._hwnd == hwnd: # NCLBUTTONDOWN then ENTERSIZEMOVE: already armed
|
|
96
|
+
return
|
|
83
97
|
if self._hwnd is not None: # unbalanced enter: replace the old timer
|
|
84
98
|
self._timers.stop(self._hwnd)
|
|
85
99
|
self._hwnd = hwnd
|
|
86
100
|
self._timers.start(hwnd)
|
|
87
|
-
_log.debug("
|
|
101
|
+
_log.debug("Native mouse tracking started — pumping Qt from a native timer")
|
|
88
102
|
|
|
89
103
|
def _exit(self) -> None:
|
|
90
104
|
if self._hwnd is None:
|
|
91
105
|
return
|
|
92
106
|
self._timers.stop(self._hwnd)
|
|
93
107
|
self._hwnd = None
|
|
94
|
-
_log.debug("
|
|
108
|
+
_log.debug("Native mouse tracking ended")
|
|
95
109
|
|
|
96
110
|
def _on_timer(self) -> None:
|
|
97
111
|
if self._pumping: # processEvents can dispatch this timer re-entrantly
|
|
@@ -438,14 +438,15 @@ class PerformanceTab(QWidget):
|
|
|
438
438
|
format_rate,
|
|
439
439
|
tick_unit=rate_unit,
|
|
440
440
|
)
|
|
441
|
-
# Each line is named for the side that sent the
|
|
442
|
-
#
|
|
443
|
-
#
|
|
441
|
+
# Each line is a full round trip named for the side that sent the
|
|
442
|
+
# ping, with the whole loop spelled out — "client → server" alone
|
|
443
|
+
# read as a one-way leg and had users looking for a "total" line.
|
|
444
|
+
# One-way legs aren't measurable without synchronized clocks.
|
|
444
445
|
self.ping_graph = GraphWidget(
|
|
445
446
|
"Round-trip time",
|
|
446
447
|
[
|
|
447
|
-
(f"{local} → {remote}", QColor("#ff9800"), monitor.rtt_ms),
|
|
448
|
-
(f"{remote} → {local}", QColor("#9c27b0"), monitor.peer_rtt_ms),
|
|
448
|
+
(f"{local} → {remote} → {local}", QColor("#ff9800"), monitor.rtt_ms),
|
|
449
|
+
(f"{remote} → {local} → {remote}", QColor("#9c27b0"), monitor.peer_rtt_ms),
|
|
449
450
|
],
|
|
450
451
|
monitor,
|
|
451
452
|
format_ms,
|
|
@@ -53,8 +53,12 @@ DEFAULT_FPS = 30
|
|
|
53
53
|
# Only for full frames to legacy (0.5.0) clients, which force-decode frames
|
|
54
54
|
# as JPEG. Delta-capable clients get lossless PNG keyframes and delta bands.
|
|
55
55
|
JPEG_QUALITY = 70
|
|
56
|
-
# Skip sending to a client whose socket buffer is this far behind.
|
|
57
|
-
|
|
56
|
+
# Skip sending to a client whose socket buffer is this far behind. Unsent
|
|
57
|
+
# bytes are queued latency (the client renders them all before showing
|
|
58
|
+
# anything current), so the cap is kept tight: ~160 ms of queue on 100 Mbit
|
|
59
|
+
# WiFi, with headroom above one 4K keyframe (~1 MB) so a fresh keyframe
|
|
60
|
+
# never trips it by itself.
|
|
61
|
+
_MAX_SEND_BACKLOG = 2 * 1024 * 1024
|
|
58
62
|
# Until a client passes the approval handshake it may only send small
|
|
59
63
|
# messages (a hello is well under 1 KB); the cap is lifted on admission.
|
|
60
64
|
_PREAUTH_MAX_PAYLOAD = 64 * 1024
|
|
@@ -539,7 +543,7 @@ class ShareServer(QObject):
|
|
|
539
543
|
stream.send_frame(legacy_jpeg)
|
|
540
544
|
elif stream in self._needs_keyframe or bands is None:
|
|
541
545
|
if keyframe_png is None:
|
|
542
|
-
keyframe_png = frames.encode_image(image)
|
|
546
|
+
keyframe_png = frames.encode_image(image, "PNG", frames.PNG_QUALITY)
|
|
543
547
|
_log.debug("Keyframe: %d KB PNG", len(keyframe_png) // 1024)
|
|
544
548
|
stream.send_frame(keyframe_png)
|
|
545
549
|
self._needs_keyframe.discard(stream)
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import struct
|
|
3
|
+
|
|
1
4
|
from PySide6.QtGui import QColor, QImage, QPainter
|
|
2
5
|
|
|
3
6
|
from remotedesktop import frames
|
|
@@ -54,6 +57,32 @@ def test_delta_round_trip_is_lossless(qapp):
|
|
|
54
57
|
assert patched.pixelColor(5, 100).name() == "#0000ff"
|
|
55
58
|
|
|
56
59
|
|
|
60
|
+
def test_apply_delta_patches_in_place(qapp):
|
|
61
|
+
base = solid(64, 128, "red")
|
|
62
|
+
modified = solid(64, 128, "red")
|
|
63
|
+
fill_rect(modified, 0, 64, 64, 64, "blue")
|
|
64
|
+
payload = frames.encode_delta(modified, [(64, 64)])
|
|
65
|
+
patched = frames.apply_delta(base, payload)
|
|
66
|
+
assert patched is base # no full-canvas copy per delta
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_bad_band_leaves_the_canvas_untouched(qapp):
|
|
70
|
+
base = solid(64, 128, "red")
|
|
71
|
+
# A valid first band followed by an undecodable second band. Nothing may
|
|
72
|
+
# be painted, or the canvas would be half-patched with no keyframe
|
|
73
|
+
# recovery covering the first band.
|
|
74
|
+
good_band = frames.encode_image(solid(64, 64, "blue"))
|
|
75
|
+
header = json.dumps(
|
|
76
|
+
{"w": 64, "h": 128, "bands": [
|
|
77
|
+
{"y": 0, "h": 64, "len": len(good_band)},
|
|
78
|
+
{"y": 64, "h": 64, "len": 10},
|
|
79
|
+
]}
|
|
80
|
+
).encode()
|
|
81
|
+
payload = struct.pack(">I", len(header)) + header + good_band + b"\0" * 10
|
|
82
|
+
assert frames.apply_delta(base, payload) is None
|
|
83
|
+
assert base == solid(64, 128, "red")
|
|
84
|
+
|
|
85
|
+
|
|
57
86
|
def test_delta_for_a_different_size_is_rejected(qapp):
|
|
58
87
|
modified = solid(64, 128, "blue")
|
|
59
88
|
payload = frames.encode_delta(modified, [(0, 64)])
|
|
@@ -1,113 +1,125 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from remotedesktop import db
|
|
4
|
-
from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
5
|
-
|
|
6
|
-
from test_sharing import make_client, make_server, pump
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_unknown_table_is_rejected(qapp):
|
|
10
|
-
with pytest.raises(ValueError):
|
|
11
|
-
ConnectionInventory(table="not_a_table")
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_database_errors_never_break_the_inventory(qapp, tmp_path):
|
|
15
|
-
connection = db.connect(tmp_path / "app.db")
|
|
16
|
-
connection.close()
|
|
17
|
-
# Loading from a dead connection yields an empty inventory...
|
|
18
|
-
inv = ConnectionInventory(connection)
|
|
19
|
-
assert inv.peers() == []
|
|
20
|
-
# ...and recording still updates the in-memory state despite save failing.
|
|
21
|
-
inv.record("peer", "attempt", name="Bob")
|
|
22
|
-
assert inv.peers()[0].name == "Bob"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_inventory_tracks_attempts_and_state(qapp):
|
|
26
|
-
inv = ConnectionInventory()
|
|
27
|
-
inv.record("c1", "discovered", name="Alice", address="10.0.0.5:48654")
|
|
28
|
-
inv.record("c1", "attempt", name="Alice")
|
|
29
|
-
inv.record("c1", "connected", name="Alice")
|
|
30
|
-
peers = inv.peers()
|
|
31
|
-
assert len(peers) == 1
|
|
32
|
-
assert peers[0].name == "Alice"
|
|
33
|
-
assert peers[0].attempts == 1
|
|
34
|
-
assert peers[0].state == "connected"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def test_inventory_counts_multiple_attempts_and_orders_recent_first(qapp):
|
|
38
|
-
inv = ConnectionInventory()
|
|
39
|
-
inv.record("a", "attempt", name="A")
|
|
40
|
-
inv.record("b", "attempt", name="B")
|
|
41
|
-
inv.record("a", "attempt", name="A")
|
|
42
|
-
inv.record("a", "denied", name="A")
|
|
43
|
-
by_key = {p.key: p for p in inv.peers()}
|
|
44
|
-
assert by_key["a"].attempts == 2
|
|
45
|
-
assert by_key["a"].state == "denied"
|
|
46
|
-
assert by_key["b"].attempts == 1
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_inventory_persists_across_restarts(qapp, tmp_path):
|
|
50
|
-
path = tmp_path / "app.db"
|
|
51
|
-
inv = ConnectionInventory(db.connect(path))
|
|
52
|
-
inv.record("peer", "attempt", name="Bob", address="10.0.0.9:48654")
|
|
53
|
-
inv.record("peer", "connected", name="Bob")
|
|
54
|
-
|
|
55
|
-
# A fresh inventory on the same database sees the earlier peer.
|
|
56
|
-
reopened = ConnectionInventory(db.connect(path))
|
|
57
|
-
peers = reopened.peers()
|
|
58
|
-
assert len(peers) == 1
|
|
59
|
-
assert peers[0].name == "Bob"
|
|
60
|
-
assert peers[0].attempts == 1
|
|
61
|
-
assert peers[0].state == "connected"
|
|
62
|
-
# And it keeps accumulating rather than starting over.
|
|
63
|
-
reopened.record("peer", "attempt", name="Bob")
|
|
64
|
-
assert reopened.peers()[0].attempts == 2
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def test_inventory_tab_shows_rows(qapp):
|
|
68
|
-
inv = ConnectionInventory()
|
|
69
|
-
tab = InventoryTab(inv)
|
|
70
|
-
assert tab._table.rowCount() == 0
|
|
71
|
-
inv.record("a", "attempt", name="A", address="host:1")
|
|
72
|
-
assert tab._table.rowCount() == 1
|
|
73
|
-
item = tab._table.item(0, 0)
|
|
74
|
-
assert item is not None and item.text() == "A"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def
|
|
78
|
-
inv = ConnectionInventory()
|
|
79
|
-
inv.record("
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
assert
|
|
86
|
-
tab._table.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from remotedesktop import db
|
|
4
|
+
from remotedesktop.inventory import ConnectionInventory, InventoryTab
|
|
5
|
+
|
|
6
|
+
from test_sharing import make_client, make_server, pump
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_unknown_table_is_rejected(qapp):
|
|
10
|
+
with pytest.raises(ValueError):
|
|
11
|
+
ConnectionInventory(table="not_a_table")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_database_errors_never_break_the_inventory(qapp, tmp_path):
|
|
15
|
+
connection = db.connect(tmp_path / "app.db")
|
|
16
|
+
connection.close()
|
|
17
|
+
# Loading from a dead connection yields an empty inventory...
|
|
18
|
+
inv = ConnectionInventory(connection)
|
|
19
|
+
assert inv.peers() == []
|
|
20
|
+
# ...and recording still updates the in-memory state despite save failing.
|
|
21
|
+
inv.record("peer", "attempt", name="Bob")
|
|
22
|
+
assert inv.peers()[0].name == "Bob"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_inventory_tracks_attempts_and_state(qapp):
|
|
26
|
+
inv = ConnectionInventory()
|
|
27
|
+
inv.record("c1", "discovered", name="Alice", address="10.0.0.5:48654")
|
|
28
|
+
inv.record("c1", "attempt", name="Alice")
|
|
29
|
+
inv.record("c1", "connected", name="Alice")
|
|
30
|
+
peers = inv.peers()
|
|
31
|
+
assert len(peers) == 1
|
|
32
|
+
assert peers[0].name == "Alice"
|
|
33
|
+
assert peers[0].attempts == 1
|
|
34
|
+
assert peers[0].state == "connected"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_inventory_counts_multiple_attempts_and_orders_recent_first(qapp):
|
|
38
|
+
inv = ConnectionInventory()
|
|
39
|
+
inv.record("a", "attempt", name="A")
|
|
40
|
+
inv.record("b", "attempt", name="B")
|
|
41
|
+
inv.record("a", "attempt", name="A")
|
|
42
|
+
inv.record("a", "denied", name="A")
|
|
43
|
+
by_key = {p.key: p for p in inv.peers()}
|
|
44
|
+
assert by_key["a"].attempts == 2
|
|
45
|
+
assert by_key["a"].state == "denied"
|
|
46
|
+
assert by_key["b"].attempts == 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_inventory_persists_across_restarts(qapp, tmp_path):
|
|
50
|
+
path = tmp_path / "app.db"
|
|
51
|
+
inv = ConnectionInventory(db.connect(path))
|
|
52
|
+
inv.record("peer", "attempt", name="Bob", address="10.0.0.9:48654")
|
|
53
|
+
inv.record("peer", "connected", name="Bob")
|
|
54
|
+
|
|
55
|
+
# A fresh inventory on the same database sees the earlier peer.
|
|
56
|
+
reopened = ConnectionInventory(db.connect(path))
|
|
57
|
+
peers = reopened.peers()
|
|
58
|
+
assert len(peers) == 1
|
|
59
|
+
assert peers[0].name == "Bob"
|
|
60
|
+
assert peers[0].attempts == 1
|
|
61
|
+
assert peers[0].state == "connected"
|
|
62
|
+
# And it keeps accumulating rather than starting over.
|
|
63
|
+
reopened.record("peer", "attempt", name="Bob")
|
|
64
|
+
assert reopened.peers()[0].attempts == 2
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_inventory_tab_shows_rows(qapp):
|
|
68
|
+
inv = ConnectionInventory()
|
|
69
|
+
tab = InventoryTab(inv)
|
|
70
|
+
assert tab._table.rowCount() == 0
|
|
71
|
+
inv.record("a", "attempt", name="A", address="host:1")
|
|
72
|
+
assert tab._table.rowCount() == 1
|
|
73
|
+
item = tab._table.item(0, 0)
|
|
74
|
+
assert item is not None and item.text() == "A"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_inventory_tab_stretches_a_spacer_not_the_last_data_column(qapp):
|
|
78
|
+
inv = ConnectionInventory()
|
|
79
|
+
inv.record("a", "attempt", name="A", address="host:1")
|
|
80
|
+
tab = InventoryTab(inv)
|
|
81
|
+
spacer = tab._table.columnCount() - 1
|
|
82
|
+
assert spacer == len(InventoryTab._COLUMNS) # one extra column on the right
|
|
83
|
+
header_item = tab._table.horizontalHeaderItem(spacer)
|
|
84
|
+
assert header_item is not None and header_item.text() == ""
|
|
85
|
+
assert tab._table.item(0, spacer) is None # never populated
|
|
86
|
+
assert tab._table.horizontalHeader().stretchLastSection()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_inventory_tab_action_button_passes_selected_key(qapp):
|
|
90
|
+
inv = ConnectionInventory()
|
|
91
|
+
inv.record("client-42", "connected", name="Bob")
|
|
92
|
+
acted = []
|
|
93
|
+
tab = InventoryTab(inv, "Revoke", acted.append)
|
|
94
|
+
button = tab._action_button
|
|
95
|
+
assert button is not None
|
|
96
|
+
# No selection yet -> button disabled and does nothing useful.
|
|
97
|
+
assert not button.isEnabled()
|
|
98
|
+
tab._table.selectRow(0)
|
|
99
|
+
assert button.isEnabled()
|
|
100
|
+
button.click()
|
|
101
|
+
assert acted == ["client-42"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_server_populates_inventory_via_peer_events(qapp, credentials, tmp_path):
|
|
105
|
+
server = make_server(credentials, tmp_path, approve=lambda *_: True)
|
|
106
|
+
inv = ConnectionInventory()
|
|
107
|
+
server.peerEvent.connect(
|
|
108
|
+
lambda e: inv.record(
|
|
109
|
+
e["key"], e["event"], name=e.get("name", ""), address=e.get("address", "")
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
client = make_client(tmp_path)
|
|
113
|
+
connected = []
|
|
114
|
+
client.connected.connect(connected.append)
|
|
115
|
+
client.connect_to("127.0.0.1", server.port)
|
|
116
|
+
try:
|
|
117
|
+
pump(qapp, lambda: connected)
|
|
118
|
+
peers = inv.peers()
|
|
119
|
+
assert len(peers) == 1
|
|
120
|
+
assert peers[0].name == "test-client"
|
|
121
|
+
assert peers[0].attempts == 1
|
|
122
|
+
assert "connected" in peers[0].state # "connected (paired)"
|
|
123
|
+
finally:
|
|
124
|
+
client.close()
|
|
125
|
+
server.close()
|
|
@@ -7,7 +7,13 @@ All tests inject a fake timer backend, so no real Win32 timer is created.
|
|
|
7
7
|
import ctypes
|
|
8
8
|
from ctypes import wintypes
|
|
9
9
|
|
|
10
|
-
from remotedesktop.modal_loop import
|
|
10
|
+
from remotedesktop.modal_loop import (
|
|
11
|
+
WM_CAPTURECHANGED,
|
|
12
|
+
WM_ENTERSIZEMOVE,
|
|
13
|
+
WM_EXITSIZEMOVE,
|
|
14
|
+
WM_NCLBUTTONDOWN,
|
|
15
|
+
ModalLoopPump,
|
|
16
|
+
)
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
class FakeTimers:
|
|
@@ -57,6 +63,45 @@ def test_other_messages_and_event_types_are_ignored():
|
|
|
57
63
|
assert timers.calls == []
|
|
58
64
|
|
|
59
65
|
|
|
66
|
+
def test_click_and_hold_without_drag_is_pumped():
|
|
67
|
+
# A press-and-hold on the title bar blocks in DefWindowProc's click
|
|
68
|
+
# tracking without ever sending WM_ENTERSIZEMOVE; the pump must arm on
|
|
69
|
+
# the press itself and disarm when the tracking loop releases capture.
|
|
70
|
+
timers = FakeTimers()
|
|
71
|
+
pump = ModalLoopPump(pump=lambda: None, timers=timers)
|
|
72
|
+
press = native_message(WM_NCLBUTTONDOWN)
|
|
73
|
+
release = native_message(WM_CAPTURECHANGED)
|
|
74
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(press))
|
|
75
|
+
assert timers.calls == [("start", 0xBEEF)]
|
|
76
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(release))
|
|
77
|
+
assert timers.calls == [("start", 0xBEEF), ("stop", 0xBEEF)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_press_followed_by_real_drag_keeps_one_timer():
|
|
81
|
+
# NCLBUTTONDOWN then ENTERSIZEMOVE (the hold turned into a drag) must not
|
|
82
|
+
# restart the timer, and either end message stops it exactly once.
|
|
83
|
+
timers = FakeTimers()
|
|
84
|
+
pump = ModalLoopPump(pump=lambda: None, timers=timers)
|
|
85
|
+
for message_id in (WM_NCLBUTTONDOWN, WM_ENTERSIZEMOVE):
|
|
86
|
+
msg = native_message(message_id)
|
|
87
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(msg))
|
|
88
|
+
assert timers.calls == [("start", 0xBEEF)]
|
|
89
|
+
for message_id in (WM_CAPTURECHANGED, WM_EXITSIZEMOVE):
|
|
90
|
+
msg = native_message(message_id)
|
|
91
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(msg))
|
|
92
|
+
assert timers.calls == [("start", 0xBEEF), ("stop", 0xBEEF)]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_capture_change_without_press_is_ignored():
|
|
96
|
+
# Qt widgets take and release mouse capture during ordinary client-area
|
|
97
|
+
# clicks; a WM_CAPTURECHANGED with no pump running must do nothing.
|
|
98
|
+
timers = FakeTimers()
|
|
99
|
+
pump = ModalLoopPump(pump=lambda: None, timers=timers)
|
|
100
|
+
msg = native_message(WM_CAPTURECHANGED)
|
|
101
|
+
pump.handle_native_event(b"windows_generic_MSG", ctypes.addressof(msg))
|
|
102
|
+
assert timers.calls == []
|
|
103
|
+
|
|
104
|
+
|
|
60
105
|
def test_timer_callback_pumps_but_never_reentrantly():
|
|
61
106
|
pumped = []
|
|
62
107
|
pump = ModalLoopPump(pump=lambda: pumped.append(True) or pump._on_timer(), timers=FakeTimers())
|
|
@@ -356,14 +356,15 @@ def test_rtt_lines_are_named_for_the_side_that_pings(qapp):
|
|
|
356
356
|
client_tab = PerformanceTab(monitor, local="client", remote="server")
|
|
357
357
|
server_tab = PerformanceTab(monitor, local="server", remote="client")
|
|
358
358
|
# This side's own measurement (rtt_ms) is listed first; the same physical
|
|
359
|
-
# measurement carries the same name in both apps
|
|
359
|
+
# measurement carries the same name in both apps, and each label spells
|
|
360
|
+
# out the full loop so it can't be read as a one-way leg.
|
|
360
361
|
assert [label for label, _c, series in client_tab.ping_graph._series] == [
|
|
361
|
-
"client → server",
|
|
362
|
-
"server → client",
|
|
362
|
+
"client → server → client",
|
|
363
|
+
"server → client → server",
|
|
363
364
|
]
|
|
364
365
|
assert [label for label, _c, series in server_tab.ping_graph._series] == [
|
|
365
|
-
"server → client",
|
|
366
|
-
"client → server",
|
|
366
|
+
"server → client → server",
|
|
367
|
+
"client → server → client",
|
|
367
368
|
]
|
|
368
369
|
|
|
369
370
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|