remotedesktop 0.11.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.
Files changed (55) hide show
  1. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/CLAUDE.md +1 -1
  2. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/PKG-INFO +1 -1
  3. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/__init__.py +1 -1
  4. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/client.py +4 -1
  5. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/frames.py +25 -14
  6. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/inventory.py +221 -218
  7. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/modal_loop.py +22 -8
  8. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/performance.py +14 -3
  9. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/server.py +4 -1
  10. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/sharing.py +7 -3
  11. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_frames.py +29 -0
  12. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_inventory.py +125 -113
  13. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_modal_loop.py +46 -1
  14. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_performance.py +17 -0
  15. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/.github/workflows/ci.yml +0 -0
  16. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/.gitignore +0 -0
  17. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/LICENSE +0 -0
  18. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/pyproject.toml +0 -0
  19. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/readme.md +0 -0
  20. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/run_claude.bat +0 -0
  21. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/run_client.bat +0 -0
  22. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/run_server.bat +0 -0
  23. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/scripts/make_coverage_badge.py +0 -0
  24. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/scripts/make_venv.bat +0 -0
  25. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/autostart.py +0 -0
  26. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/clipboard.py +0 -0
  27. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/config.py +0 -0
  28. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/db.py +0 -0
  29. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/discovery.py +0 -0
  30. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/icon.py +0 -0
  31. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/input_injection.py +0 -0
  32. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/logs.py +0 -0
  33. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/preferences.py +0 -0
  34. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/protocol.py +0 -0
  35. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/tls.py +0 -0
  36. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/viewer.py +0 -0
  37. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/src/remotedesktop/window_state.py +0 -0
  38. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/conftest.py +0 -0
  39. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_autostart.py +0 -0
  40. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_client_window.py +0 -0
  41. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_clipboard.py +0 -0
  42. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_config.py +0 -0
  43. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_discovery.py +0 -0
  44. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_icon.py +0 -0
  45. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_input.py +0 -0
  46. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_input_injection.py +0 -0
  47. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_logs.py +0 -0
  48. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_preferences.py +0 -0
  49. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_protocol.py +0 -0
  50. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_server_window.py +0 -0
  51. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_sharing.py +0 -0
  52. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_smoke.py +0 -0
  53. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_tls.py +0 -0
  54. {remotedesktop-0.11.0 → remotedesktop-0.13.0}/tests/test_window_state.py +0 -0
  55. {remotedesktop-0.11.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` 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.
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.11.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
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.11.0"
3
+ __version__ = "0.13.0"
@@ -110,7 +110,10 @@ class ClientWindow(QMainWindow):
110
110
  InventoryTab(self.inventory, "Forget server", self._forget_server),
111
111
  "Servers on LAN",
112
112
  )
113
- tabs.addTab(PerformanceTab(self.performance), "Performance")
113
+ tabs.addTab(
114
+ PerformanceTab(self.performance, local="client", remote="server"),
115
+ "Performance",
116
+ )
114
117
  self.setCentralWidget(tabs)
115
118
 
116
119
  self.discovery_panel = DiscoveryPanel(self)
@@ -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 a copy of `canvas`.
75
-
76
- Returns the patched image, or None if the payload is malformed or was
77
- produced for a different frame size (the caller should then wait for
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
- image = canvas.copy()
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, h, length in bands:
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 image
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
- self._table = QTableWidget(0, len(self._COLUMNS))
160
- self._table.setHorizontalHeaderLabels(self._COLUMNS)
161
- self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
162
- self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
163
- self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
164
- self._table.verticalHeader().setVisible(False)
165
- self._table.horizontalHeader().setSectionResizeMode(
166
- QHeaderView.ResizeMode.ResizeToContents
167
- )
168
- self._table.horizontalHeader().setStretchLastSection(True)
169
- layout = QVBoxLayout(self)
170
- layout.addWidget(self._table)
171
-
172
- self._action_button: QPushButton | None = None
173
- if action_label and action_callback:
174
- self._action_button = QPushButton(action_label)
175
- self._action_button.setEnabled(False)
176
- self._action_button.clicked.connect(self._on_action)
177
- self._table.itemSelectionChanged.connect(self._on_selection_changed)
178
- layout.addWidget(self._action_button)
179
-
180
- inventory.changed.connect(self.refresh)
181
- self.refresh()
182
-
183
- def _selected_key(self) -> str | None:
184
- row = self._table.currentRow()
185
- if row < 0:
186
- return None
187
- item = self._table.item(row, 0)
188
- return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
189
-
190
- def _on_selection_changed(self) -> None:
191
- if self._action_button is not None:
192
- self._action_button.setEnabled(self._selected_key() is not None)
193
-
194
- def _on_action(self) -> None:
195
- key = self._selected_key()
196
- if key and self._action_callback is not None:
197
- self._action_callback(key)
198
-
199
- def refresh(self) -> None:
200
- peers = self._inventory.peers()
201
- self._table.setRowCount(len(peers))
202
- for row, peer in enumerate(peers):
203
- values = [
204
- peer.name or "(unknown)",
205
- peer.address,
206
- peer.detail,
207
- peer.state,
208
- str(peer.attempts),
209
- peer.first_seen,
210
- peer.last_seen,
211
- ]
212
- for column, value in enumerate(values):
213
- item = QTableWidgetItem(value)
214
- if column == 0:
215
- item.setData(Qt.ItemDataRole.UserRole, peer.key)
216
- self._table.setItem(row, column, item)
217
- if self._action_button is not None:
218
- self._action_button.setEnabled(self._selected_key() is not None)
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 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.
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
- if msg.message == WM_ENTERSIZEMOVE:
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 == WM_EXITSIZEMOVE:
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("Modal move/size loop entered — pumping Qt from a native timer")
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("Modal move/size loop exited")
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
@@ -418,7 +418,14 @@ class PerformanceTab(QWidget):
418
418
  monitor regardless, but a background tab schedules zero paint work.
419
419
  """
420
420
 
421
- def __init__(self, monitor: PerformanceMonitor, parent: QWidget | None = None) -> None:
421
+ def __init__(
422
+ self,
423
+ monitor: PerformanceMonitor,
424
+ *,
425
+ local: str = "client",
426
+ remote: str = "server",
427
+ parent: QWidget | None = None,
428
+ ) -> None:
422
429
  super().__init__(parent)
423
430
  self._monitor = monitor
424
431
  self.bandwidth_graph = GraphWidget(
@@ -431,11 +438,15 @@ class PerformanceTab(QWidget):
431
438
  format_rate,
432
439
  tick_unit=rate_unit,
433
440
  )
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.
434
445
  self.ping_graph = GraphWidget(
435
446
  "Round-trip time",
436
447
  [
437
- ("this side", QColor("#ff9800"), monitor.rtt_ms),
438
- ("peer", 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),
439
450
  ],
440
451
  monitor,
441
452
  format_ms,
@@ -108,7 +108,10 @@ class ServerWindow(QMainWindow):
108
108
  InventoryTab(self.inventory, "Revoke access", self._revoke_client),
109
109
  "Clients on LAN",
110
110
  )
111
- tabs.addTab(PerformanceTab(self.performance), "Performance")
111
+ tabs.addTab(
112
+ PerformanceTab(self.performance, local="server", remote="client"),
113
+ "Performance",
114
+ )
112
115
  tabs.addTab(log_tab, "Connection log")
113
116
  tabs.addTab(PreferencesTab(self._settings, self.performance), "Preferences")
114
117
  self.setCentralWidget(tabs)
@@ -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
- _MAX_SEND_BACKLOG = 8 * 1024 * 1024
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 test_inventory_tab_action_button_passes_selected_key(qapp):
78
- inv = ConnectionInventory()
79
- inv.record("client-42", "connected", name="Bob")
80
- acted = []
81
- tab = InventoryTab(inv, "Revoke", acted.append)
82
- button = tab._action_button
83
- assert button is not None
84
- # No selection yet -> button disabled and does nothing useful.
85
- assert not button.isEnabled()
86
- tab._table.selectRow(0)
87
- assert button.isEnabled()
88
- button.click()
89
- assert acted == ["client-42"]
90
-
91
-
92
- def test_server_populates_inventory_via_peer_events(qapp, credentials, tmp_path):
93
- server = make_server(credentials, tmp_path, approve=lambda *_: True)
94
- inv = ConnectionInventory()
95
- server.peerEvent.connect(
96
- lambda e: inv.record(
97
- e["key"], e["event"], name=e.get("name", ""), address=e.get("address", "")
98
- )
99
- )
100
- client = make_client(tmp_path)
101
- connected = []
102
- client.connected.connect(connected.append)
103
- client.connect_to("127.0.0.1", server.port)
104
- try:
105
- pump(qapp, lambda: connected)
106
- peers = inv.peers()
107
- assert len(peers) == 1
108
- assert peers[0].name == "test-client"
109
- assert peers[0].attempts == 1
110
- assert "connected" in peers[0].state # "connected (paired)"
111
- finally:
112
- client.close()
113
- server.close()
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 WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE, ModalLoopPump
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())
@@ -351,6 +351,23 @@ def test_rate_unit_matches_format_rate_unit():
351
351
  assert unit * nice_ceiling(70000.0 / unit) == 100.0 * 1024.0
352
352
 
353
353
 
354
+ def test_rtt_lines_are_named_for_the_side_that_pings(qapp):
355
+ monitor = PerformanceMonitor()
356
+ client_tab = PerformanceTab(monitor, local="client", remote="server")
357
+ server_tab = PerformanceTab(monitor, local="server", remote="client")
358
+ # This side's own measurement (rtt_ms) is listed first; the same physical
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.
361
+ assert [label for label, _c, series in client_tab.ping_graph._series] == [
362
+ "client → server → client",
363
+ "server → client → server",
364
+ ]
365
+ assert [label for label, _c, series in server_tab.ping_graph._series] == [
366
+ "server → client → server",
367
+ "client → server → client",
368
+ ]
369
+
370
+
354
371
  def test_graph_widgets_render_headless(qapp):
355
372
  seeded = PerformanceMonitor()
356
373
  seeded.send_bps.add(100.0)
File without changes
File without changes
File without changes