remotedesktop 0.3.0__tar.gz → 0.5.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 (43) hide show
  1. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/PKG-INFO +11 -1
  2. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/readme.md +10 -0
  3. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/__init__.py +1 -1
  4. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/client.py +16 -1
  5. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/server.py +12 -3
  6. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/sharing.py +8 -0
  7. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_client_window.py +187 -164
  8. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_server_window.py +15 -6
  9. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_sharing.py +29 -0
  10. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_smoke.py +58 -58
  11. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/.github/workflows/ci.yml +0 -0
  12. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/.gitignore +0 -0
  13. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/CLAUDE.md +0 -0
  14. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/LICENSE +0 -0
  15. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/pyproject.toml +0 -0
  16. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/run_claude.bat +0 -0
  17. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/run_client.bat +0 -0
  18. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/run_server.bat +0 -0
  19. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/scripts/make_coverage_badge.py +0 -0
  20. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/scripts/make_venv.bat +0 -0
  21. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/autostart.py +0 -0
  22. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/clipboard.py +0 -0
  23. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/config.py +0 -0
  24. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/db.py +0 -0
  25. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/discovery.py +0 -0
  26. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/input_injection.py +0 -0
  27. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/inventory.py +0 -0
  28. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/protocol.py +0 -0
  29. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/tls.py +0 -0
  30. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/viewer.py +0 -0
  31. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/src/remotedesktop/window_state.py +0 -0
  32. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/conftest.py +0 -0
  33. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_autostart.py +0 -0
  34. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_clipboard.py +0 -0
  35. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_config.py +0 -0
  36. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_discovery.py +0 -0
  37. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_input.py +0 -0
  38. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_input_injection.py +0 -0
  39. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_inventory.py +0 -0
  40. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_protocol.py +0 -0
  41. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_tls.py +0 -0
  42. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/tests/test_window_state.py +0 -0
  43. {remotedesktop-0.3.0 → remotedesktop-0.5.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.3.0
3
+ Version: 0.5.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
@@ -115,3 +115,13 @@ uv run pytest # run the tests
115
115
  Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
116
116
  MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
117
117
  From PowerShell, Qt uses the Windows schannel backend as intended.
118
+
119
+ ### The `badges` branch
120
+
121
+ The coverage badge above is served from the `badges` branch
122
+ (`raw.githubusercontent.com/.../badges/coverage.svg`). CI regenerates the
123
+ SVG after each test run on `master` and force-pushes it there as a single
124
+ orphan commit. It lives on its own branch because `master` only accepts
125
+ pull requests (a repository ruleset), so CI cannot commit to it directly;
126
+ keeping the badge in the repo avoids depending on an external coverage
127
+ service. The branch is generated output — never branch from it or merge it.
@@ -96,3 +96,13 @@ uv run pytest # run the tests
96
96
  Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
97
97
  MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
98
98
  From PowerShell, Qt uses the Windows schannel backend as intended.
99
+
100
+ ### The `badges` branch
101
+
102
+ The coverage badge above is served from the `badges` branch
103
+ (`raw.githubusercontent.com/.../badges/coverage.svg`). CI regenerates the
104
+ SVG after each test run on `master` and force-pushes it there as a single
105
+ orphan commit. It lives on its own branch because `master` only accepts
106
+ pull requests (a repository ruleset), so CI cannot commit to it directly;
107
+ keeping the badge in the repo avoids depending on an external coverage
108
+ service. The branch is generated output — never branch from it or merge it.
@@ -1,3 +1,3 @@
1
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.5.0"
@@ -83,7 +83,9 @@ class DiscoveryPanel(QWidget):
83
83
 
84
84
 
85
85
  class ClientWindow(QMainWindow):
86
- def __init__(self, *, connection: sqlite3.Connection | None = None) -> None:
86
+ def __init__(
87
+ self, *, connection: sqlite3.Connection | None = None, auto_scan: bool = True
88
+ ) -> None:
87
89
  super().__init__()
88
90
  self.setWindowTitle("Remote Desktop Client")
89
91
  # Tests inject a connection to a temp database; the app uses the default.
@@ -128,6 +130,9 @@ class ClientWindow(QMainWindow):
128
130
  self._settings = Settings(self._db)
129
131
  window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
130
132
  self.log("Client started")
133
+ # Tests pass auto_scan=False so window tests never broadcast on the LAN.
134
+ if auto_scan:
135
+ self.discovery_panel.refresh()
131
136
 
132
137
  def log(self, message: str) -> None:
133
138
  self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
@@ -180,6 +185,7 @@ class ClientWindow(QMainWindow):
180
185
  self._client = client
181
186
  client.status.connect(self.log)
182
187
  client.connected.connect(self._on_connected)
188
+ client.approvalPending.connect(self._on_approval_pending)
183
189
  client.denied.connect(self._on_denied)
184
190
  client.disconnected.connect(self._on_disconnected)
185
191
  client.frameReceived.connect(self._on_frame)
@@ -187,6 +193,15 @@ class ClientWindow(QMainWindow):
187
193
  self.statusBar().showMessage(f"Connecting to {server.name} ({server.host}:{server.port}) …")
188
194
  client.connect_to(server.host, server.port)
189
195
 
196
+ def _on_approval_pending(self) -> None:
197
+ self.viewer.clear(
198
+ f"Waiting for approval — someone at {self._server_name} "
199
+ "must allow this connection"
200
+ )
201
+ self.statusBar().showMessage(
202
+ f"Waiting for the user on {self._server_name} to approve this computer …"
203
+ )
204
+
190
205
  def _on_connected(self, server_name: str) -> None:
191
206
  self._server_name = server_name or self._server_name
192
207
  self._connected = True
@@ -163,12 +163,21 @@ class ServerWindow(QMainWindow):
163
163
  self.share_server.revoke_client(client_id)
164
164
 
165
165
  def _ask_approval(self, client_id: str, client_name: str) -> bool:
166
- answer = QMessageBox.question(
167
- self,
166
+ box = QMessageBox(
167
+ QMessageBox.Icon.Question,
168
168
  "Connection request",
169
169
  f'Allow "{client_name}" to view this desktop?\n\nClient id: {client_id}',
170
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
171
+ self,
170
172
  )
171
- return answer == QMessageBox.StandardButton.Yes
173
+ # The server usually isn't the foreground app when a request arrives,
174
+ # and Windows won't give focus to a background app's dialog — keep it
175
+ # on top so it can't sit unnoticed behind other windows.
176
+ box.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
177
+ box.show()
178
+ box.raise_()
179
+ box.activateWindow()
180
+ return box.exec() == QMessageBox.StandardButton.Yes
172
181
 
173
182
  def closeEvent(self, event: QCloseEvent) -> None:
174
183
  window_state.save_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
@@ -256,6 +256,8 @@ class ShareServer(QObject):
256
256
  )
257
257
  else:
258
258
  self.status.emit(f'"{client_name}" is new — asking for permission')
259
+ # Tell the client it's waiting on a human, not a stalled connection.
260
+ stream.send_json({"type": "pending"})
259
261
  # The approval prompt is modal: a nested event loop runs while it is
260
262
  # open, so this stream can disconnect (and _drop can run) meanwhile.
261
263
  self._prompting.add(stream)
@@ -407,6 +409,7 @@ class ShareClient(QObject):
407
409
  """Connects to a ShareServer over TLS, authenticates, and emits frames."""
408
410
 
409
411
  connected = Signal(str) # server name
412
+ approvalPending = Signal() # server is asking its user for permission
410
413
  denied = Signal(str) # reason
411
414
  disconnected = Signal()
412
415
  frameReceived = Signal(QImage)
@@ -521,6 +524,11 @@ class ShareClient(QObject):
521
524
  f'Server "{name}" accepted the connection — waiting for first frame'
522
525
  )
523
526
  self.connected.emit(name)
527
+ case "pending":
528
+ self.status.emit(
529
+ "Server is asking its user for permission — waiting for approval"
530
+ )
531
+ self.approvalPending.emit()
524
532
  case "denied":
525
533
  reason = str(message.get("reason", "denied"))
526
534
  self.status.emit(f"Server denied the connection: {reason}")
@@ -1,164 +1,187 @@
1
- from PySide6.QtCore import Qt
2
- from PySide6.QtWidgets import QListWidgetItem, QMessageBox
3
-
4
- from remotedesktop import client as client_module
5
- from remotedesktop import db
6
- from remotedesktop.client import ClientWindow, DiscoveryPanel
7
- from remotedesktop.config import PairedClients
8
- from remotedesktop.discovery import ServerInfo
9
- from remotedesktop.sharing import ShareServer
10
-
11
- from test_sharing import pump
12
-
13
-
14
- def make_window(tmp_path):
15
- return ClientWindow(connection=db.connect(tmp_path / "client.db"))
16
-
17
-
18
- def make_share_server(credentials, tmp_path, *, approve=lambda *_: True):
19
- server = ShareServer(
20
- approve_client=approve,
21
- credentials=credentials,
22
- paired=PairedClients(db.connect(tmp_path / "server.db")),
23
- )
24
- assert server.listen(0)
25
- return server
26
-
27
-
28
- def test_window_starts_disconnected(qapp, tmp_path):
29
- window = make_window(tmp_path)
30
- assert window.statusBar().currentMessage() == "Not connected"
31
- assert "Client started" in window.connection_log.toPlainText()
32
- window.close()
33
-
34
-
35
- def test_discovered_servers_are_recorded(qapp, tmp_path):
36
- window = make_window(tmp_path)
37
- info = ServerInfo(name="box", host="10.0.0.7", port=1234)
38
- window._record_discovered([info])
39
- peers = window.inventory.peers()
40
- assert len(peers) == 1
41
- assert peers[0].state == "discovered"
42
- window.close()
43
-
44
-
45
- def test_connect_view_and_disconnect_full_flow(qapp, credentials, tmp_path):
46
- server = make_share_server(credentials, tmp_path)
47
- window = make_window(tmp_path)
48
- info = ServerInfo(name="box", host="127.0.0.1", port=server.port)
49
- try:
50
- window._on_server_activated(info)
51
- pump(qapp, lambda: window.viewer.has_frame)
52
- assert window._connected
53
- assert "Viewing" in window.statusBar().currentMessage()
54
- key = f"127.0.0.1:{server.port}"
55
- assert window.inventory._peers[key].state == "connected"
56
-
57
- # Input from the viewer is forwarded while connected.
58
- window._on_input_event({"action": "move", "x": 0.5, "y": 0.5})
59
-
60
- # A re-scan that finds the connected server must not downgrade it.
61
- window._record_discovered([info])
62
- assert window.inventory._peers[key].state == "connected"
63
-
64
- server.close()
65
- pump(qapp, lambda: not window._connected)
66
- assert window.inventory._peers[key].state == "disconnected"
67
- finally:
68
- window.close()
69
- server.close()
70
-
71
-
72
- def test_denied_connection_keeps_denied_state(qapp, credentials, tmp_path):
73
- server = make_share_server(credentials, tmp_path, approve=lambda *_: False)
74
- window = make_window(tmp_path)
75
- try:
76
- window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
77
- pump(qapp, lambda: window._denied)
78
- pump(qapp, lambda: "Denied" in window.statusBar().currentMessage())
79
- key = f"127.0.0.1:{server.port}"
80
- pump(qapp, lambda: window.inventory._peers[key].state == "denied")
81
- # The trailing socket disconnect must not overwrite "denied".
82
- pump(qapp, lambda: True, timeout=0.3)
83
- assert window.inventory._peers[key].state == "denied"
84
- finally:
85
- window.close()
86
- server.close()
87
-
88
-
89
- def test_activating_a_second_server_replaces_the_connection(qapp, credentials, tmp_path):
90
- server = make_share_server(credentials, tmp_path)
91
- window = make_window(tmp_path)
92
- try:
93
- window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
94
- pump(qapp, lambda: window._connected)
95
- first_client = window._client
96
- window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
97
- assert window._client is not first_client
98
- pump(qapp, lambda: window._connected)
99
- finally:
100
- window.close()
101
- server.close()
102
-
103
-
104
- def test_forget_server_disconnects_and_forgets(qapp, credentials, tmp_path, monkeypatch):
105
- server = make_share_server(credentials, tmp_path)
106
- window = make_window(tmp_path)
107
- key = f"127.0.0.1:{server.port}"
108
- try:
109
- window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
110
- pump(qapp, lambda: window._connected)
111
- assert window._known_servers.get(key) is not None
112
-
113
- monkeypatch.setattr(
114
- QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
115
- )
116
- window._forget_server(key)
117
- pump(qapp, lambda: not window._connected)
118
- assert window._known_servers.get(key) is None
119
- assert window.inventory._peers[key].state == "forgotten"
120
-
121
- # Answering "No" leaves a known server alone.
122
- monkeypatch.setattr(
123
- QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)
124
- )
125
- window._forget_server(key)
126
- finally:
127
- window.close()
128
- server.close()
129
-
130
-
131
- def test_discovery_panel_lists_scan_results(qapp, monkeypatch):
132
- info = ServerInfo(name="box", host="10.0.0.7", port=1234)
133
- monkeypatch.setattr(client_module, "discover_servers", lambda: [info])
134
- panel = DiscoveryPanel()
135
- found: list[list] = []
136
- activated: list[ServerInfo] = []
137
- panel.serversFound.connect(found.append)
138
- panel.serverActivated.connect(activated.append)
139
- panel.refresh()
140
- assert not panel._refresh_button.isEnabled()
141
- pump(qapp, lambda: found)
142
- assert found == [[info]]
143
- assert panel._refresh_button.isEnabled()
144
- assert panel.server_list.count() == 1
145
-
146
- item = panel.server_list.item(0)
147
- assert isinstance(item, QListWidgetItem)
148
- panel._on_item_activated(item)
149
- assert activated == [info]
150
- assert item.data(Qt.ItemDataRole.UserRole) == info
151
-
152
-
153
- def test_discovery_panel_survives_scan_failure(qapp, monkeypatch):
154
- def boom():
155
- raise RuntimeError("scan exploded")
156
-
157
- monkeypatch.setattr(client_module, "discover_servers", boom)
158
- panel = DiscoveryPanel()
159
- found: list[list] = []
160
- panel.serversFound.connect(found.append)
161
- panel.refresh()
162
- pump(qapp, lambda: found)
163
- assert found == [[]]
164
- assert panel._refresh_button.isEnabled()
1
+ from PySide6.QtCore import Qt
2
+ from PySide6.QtWidgets import QListWidgetItem, QMessageBox
3
+
4
+ from remotedesktop import client as client_module
5
+ from remotedesktop import db
6
+ from remotedesktop.client import ClientWindow, DiscoveryPanel
7
+ from remotedesktop.config import PairedClients
8
+ from remotedesktop.discovery import ServerInfo
9
+ from remotedesktop.sharing import ShareServer
10
+
11
+ from test_sharing import pump
12
+
13
+
14
+ def make_window(tmp_path):
15
+ # auto_scan=False: window tests must never broadcast a discovery probe on the LAN.
16
+ return ClientWindow(connection=db.connect(tmp_path / "client.db"), auto_scan=False)
17
+
18
+
19
+ def make_share_server(credentials, tmp_path, *, approve=lambda *_: True):
20
+ server = ShareServer(
21
+ approve_client=approve,
22
+ credentials=credentials,
23
+ paired=PairedClients(db.connect(tmp_path / "server.db")),
24
+ )
25
+ assert server.listen(0)
26
+ return server
27
+
28
+
29
+ def test_window_starts_disconnected(qapp, tmp_path):
30
+ window = make_window(tmp_path)
31
+ assert window.statusBar().currentMessage() == "Not connected"
32
+ assert "Client started" in window.connection_log.toPlainText()
33
+ window.close()
34
+
35
+
36
+ def test_discovered_servers_are_recorded(qapp, tmp_path):
37
+ window = make_window(tmp_path)
38
+ info = ServerInfo(name="box", host="10.0.0.7", port=1234)
39
+ window._record_discovered([info])
40
+ peers = window.inventory.peers()
41
+ assert len(peers) == 1
42
+ assert peers[0].state == "discovered"
43
+ window.close()
44
+
45
+
46
+ def test_connect_view_and_disconnect_full_flow(qapp, credentials, tmp_path):
47
+ server = make_share_server(credentials, tmp_path)
48
+ window = make_window(tmp_path)
49
+ info = ServerInfo(name="box", host="127.0.0.1", port=server.port)
50
+ try:
51
+ window._on_server_activated(info)
52
+ pump(qapp, lambda: window.viewer.has_frame)
53
+ assert window._connected
54
+ assert "Viewing" in window.statusBar().currentMessage()
55
+ key = f"127.0.0.1:{server.port}"
56
+ assert window.inventory._peers[key].state == "connected"
57
+
58
+ # Input from the viewer is forwarded while connected.
59
+ window._on_input_event({"action": "move", "x": 0.5, "y": 0.5})
60
+
61
+ # A re-scan that finds the connected server must not downgrade it.
62
+ window._record_discovered([info])
63
+ assert window.inventory._peers[key].state == "connected"
64
+
65
+ server.close()
66
+ pump(qapp, lambda: not window._connected)
67
+ assert window.inventory._peers[key].state == "disconnected"
68
+ finally:
69
+ window.close()
70
+ server.close()
71
+
72
+
73
+ def test_waiting_for_approval_is_shown_while_server_prompts(qapp, credentials, tmp_path):
74
+ window = make_window(tmp_path)
75
+
76
+ def approve(cid, name):
77
+ # While the server-side prompt is "open", the client window must show
78
+ # the waiting state (pump until the pending message arrives).
79
+ pump(
80
+ qapp,
81
+ lambda: "Waiting for the user on box" in window.statusBar().currentMessage(),
82
+ )
83
+ return True
84
+
85
+ server = make_share_server(credentials, tmp_path, approve=approve)
86
+ try:
87
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
88
+ pump(qapp, lambda: window.viewer.has_frame)
89
+ assert "asking its user for permission" in window.connection_log.toPlainText()
90
+ finally:
91
+ window.close()
92
+ server.close()
93
+
94
+
95
+ def test_denied_connection_keeps_denied_state(qapp, credentials, tmp_path):
96
+ server = make_share_server(credentials, tmp_path, approve=lambda *_: False)
97
+ window = make_window(tmp_path)
98
+ try:
99
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
100
+ pump(qapp, lambda: window._denied)
101
+ pump(qapp, lambda: "Denied" in window.statusBar().currentMessage())
102
+ key = f"127.0.0.1:{server.port}"
103
+ pump(qapp, lambda: window.inventory._peers[key].state == "denied")
104
+ # The trailing socket disconnect must not overwrite "denied".
105
+ pump(qapp, lambda: True, timeout=0.3)
106
+ assert window.inventory._peers[key].state == "denied"
107
+ finally:
108
+ window.close()
109
+ server.close()
110
+
111
+
112
+ def test_activating_a_second_server_replaces_the_connection(qapp, credentials, tmp_path):
113
+ server = make_share_server(credentials, tmp_path)
114
+ window = make_window(tmp_path)
115
+ try:
116
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
117
+ pump(qapp, lambda: window._connected)
118
+ first_client = window._client
119
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
120
+ assert window._client is not first_client
121
+ pump(qapp, lambda: window._connected)
122
+ finally:
123
+ window.close()
124
+ server.close()
125
+
126
+
127
+ def test_forget_server_disconnects_and_forgets(qapp, credentials, tmp_path, monkeypatch):
128
+ server = make_share_server(credentials, tmp_path)
129
+ window = make_window(tmp_path)
130
+ key = f"127.0.0.1:{server.port}"
131
+ try:
132
+ window._on_server_activated(ServerInfo(name="box", host="127.0.0.1", port=server.port))
133
+ pump(qapp, lambda: window._connected)
134
+ assert window._known_servers.get(key) is not None
135
+
136
+ monkeypatch.setattr(
137
+ QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
138
+ )
139
+ window._forget_server(key)
140
+ pump(qapp, lambda: not window._connected)
141
+ assert window._known_servers.get(key) is None
142
+ assert window.inventory._peers[key].state == "forgotten"
143
+
144
+ # Answering "No" leaves a known server alone.
145
+ monkeypatch.setattr(
146
+ QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)
147
+ )
148
+ window._forget_server(key)
149
+ finally:
150
+ window.close()
151
+ server.close()
152
+
153
+
154
+ def test_discovery_panel_lists_scan_results(qapp, monkeypatch):
155
+ info = ServerInfo(name="box", host="10.0.0.7", port=1234)
156
+ monkeypatch.setattr(client_module, "discover_servers", lambda: [info])
157
+ panel = DiscoveryPanel()
158
+ found: list[list] = []
159
+ activated: list[ServerInfo] = []
160
+ panel.serversFound.connect(found.append)
161
+ panel.serverActivated.connect(activated.append)
162
+ panel.refresh()
163
+ assert not panel._refresh_button.isEnabled()
164
+ pump(qapp, lambda: found)
165
+ assert found == [[info]]
166
+ assert panel._refresh_button.isEnabled()
167
+ assert panel.server_list.count() == 1
168
+
169
+ item = panel.server_list.item(0)
170
+ assert isinstance(item, QListWidgetItem)
171
+ panel._on_item_activated(item)
172
+ assert activated == [info]
173
+ assert item.data(Qt.ItemDataRole.UserRole) == info
174
+
175
+
176
+ def test_discovery_panel_survives_scan_failure(qapp, monkeypatch):
177
+ def boom():
178
+ raise RuntimeError("scan exploded")
179
+
180
+ monkeypatch.setattr(client_module, "discover_servers", boom)
181
+ panel = DiscoveryPanel()
182
+ found: list[list] = []
183
+ panel.serversFound.connect(found.append)
184
+ panel.refresh()
185
+ pump(qapp, lambda: found)
186
+ assert found == [[]]
187
+ assert panel._refresh_button.isEnabled()
@@ -85,10 +85,19 @@ def test_autostart_checkbox_toggles_registration(qapp, credentials, tmp_path):
85
85
  window.close()
86
86
 
87
87
 
88
+ def stub_approval_prompt(monkeypatch, button):
89
+ """Answer _ask_approval's stay-on-top QMessageBox without showing any UI.
90
+
91
+ The approval prompt is an instance QMessageBox (show + exec), not
92
+ QMessageBox.question, so both methods are stubbed: show so nothing pops
93
+ up on screen, exec so the "user" answers immediately.
94
+ """
95
+ monkeypatch.setattr(QMessageBox, "show", lambda self: None)
96
+ monkeypatch.setattr(QMessageBox, "exec", lambda self: button)
97
+
98
+
88
99
  def test_approval_prompt_pairs_client_and_updates_summary(qapp, credentials, tmp_path, monkeypatch):
89
- monkeypatch.setattr(
90
- QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
91
- )
100
+ stub_approval_prompt(monkeypatch, QMessageBox.StandardButton.Yes)
92
101
  window = make_window(credentials, tmp_path)
93
102
  client = make_client(tmp_path)
94
103
  names = []
@@ -105,9 +114,7 @@ def test_approval_prompt_pairs_client_and_updates_summary(qapp, credentials, tmp
105
114
 
106
115
 
107
116
  def test_refused_approval_denies_client(qapp, credentials, tmp_path, monkeypatch):
108
- monkeypatch.setattr(
109
- QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)
110
- )
117
+ stub_approval_prompt(monkeypatch, QMessageBox.StandardButton.No)
111
118
  window = make_window(credentials, tmp_path)
112
119
  client = make_client(tmp_path)
113
120
  denials = []
@@ -122,6 +129,8 @@ def test_refused_approval_denies_client(qapp, credentials, tmp_path, monkeypatch
122
129
 
123
130
 
124
131
  def test_revoke_via_window_disconnects_client(qapp, credentials, tmp_path, monkeypatch):
132
+ stub_approval_prompt(monkeypatch, QMessageBox.StandardButton.Yes)
133
+ # The revoke confirmation is still QMessageBox.question — patch it separately.
125
134
  monkeypatch.setattr(
126
135
  QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
127
136
  )
@@ -99,6 +99,35 @@ def test_reconnect_uses_token_without_prompting(qapp, credentials, tmp_path):
99
99
  server.close()
100
100
 
101
101
 
102
+ def test_approval_pending_is_signaled_only_when_prompting(qapp, credentials, tmp_path):
103
+ server = make_server(credentials, tmp_path, approve=lambda *_: True)
104
+ try:
105
+ # First connection: the server prompts, so the client hears "pending"
106
+ # before it is admitted.
107
+ client = make_client(tmp_path)
108
+ events: list[str] = []
109
+ client.approvalPending.connect(lambda: events.append("pending"))
110
+ client.connected.connect(lambda _name: events.append("connected"))
111
+ client.connect_to("127.0.0.1", server.port)
112
+ pump(qapp, lambda: "connected" in events)
113
+ assert events == ["pending", "connected"]
114
+ client.close()
115
+ pump(qapp, lambda: True, timeout=0.3)
116
+
117
+ # Token reconnect: no prompt on the server, so no pending signal.
118
+ client2 = make_client(tmp_path)
119
+ events2: list[str] = []
120
+ client2.approvalPending.connect(lambda: events2.append("pending"))
121
+ client2.connected.connect(lambda _name: events2.append("connected"))
122
+ client2.connect_to("127.0.0.1", server.port)
123
+ pump(qapp, lambda: "connected" in events2)
124
+ assert events2 == ["connected"]
125
+ finally:
126
+ client2.close()
127
+ client.close()
128
+ server.close()
129
+
130
+
102
131
  def test_revoke_disconnects_and_requires_reapproval(qapp, credentials, tmp_path):
103
132
  prompts = []
104
133
  server = make_server(
@@ -1,58 +1,58 @@
1
- import socket
2
-
3
- import remotedesktop
4
- from remotedesktop import db
5
- from remotedesktop.client import ClientWindow, DiscoveryPanel
6
- from remotedesktop.config import PairedClients
7
- from remotedesktop.discovery import ServerInfo, discover_servers
8
- from remotedesktop.server import ServerWindow
9
- from remotedesktop.viewer import ViewerWidget
10
-
11
- from test_discovery import LOOPBACK, free_udp_port
12
-
13
-
14
- def test_version() -> None:
15
- assert remotedesktop.__version__
16
-
17
-
18
- def test_client_window_hosts_viewer(qapp) -> None:
19
- window = ClientWindow()
20
- assert isinstance(window.viewer, ViewerWidget)
21
- assert isinstance(window.discovery_panel, DiscoveryPanel)
22
-
23
-
24
- def test_discovery_panel_lists_servers(qapp) -> None:
25
- panel = DiscoveryPanel()
26
- server = ServerInfo(name="testbox", host="192.168.1.7", port=12345)
27
- panel._show_results([server])
28
- assert panel.server_list.count() == 1
29
- assert "testbox" in panel.server_list.item(0).text()
30
-
31
-
32
- def test_server_window_is_discoverable(qapp, credentials, tmp_path) -> None:
33
- port = free_udp_port()
34
- window = ServerWindow(
35
- discovery_port=port,
36
- connect_port=0,
37
- paired=PairedClients(db.connect(tmp_path / "server.db")),
38
- credentials=credentials,
39
- )
40
- try:
41
- servers = discover_servers(
42
- timeout=2.0, discovery_port=port, broadcast_hosts=(LOOPBACK,)
43
- )
44
- assert [s.name for s in servers] == [socket.gethostname()]
45
- assert servers[0].port == window.share_server.port
46
- finally:
47
- window.close()
48
-
49
-
50
- def test_viewer_widget_shows_and_clears_frames(qapp) -> None:
51
- from PySide6.QtGui import QImage
52
-
53
- viewer = ViewerWidget()
54
- assert not viewer.has_frame
55
- viewer.show_frame(QImage(8, 8, QImage.Format.Format_RGB32))
56
- assert viewer.has_frame
57
- viewer.clear("gone")
58
- assert not viewer.has_frame
1
+ import socket
2
+
3
+ import remotedesktop
4
+ from remotedesktop import db
5
+ from remotedesktop.client import ClientWindow, DiscoveryPanel
6
+ from remotedesktop.config import PairedClients
7
+ from remotedesktop.discovery import ServerInfo, discover_servers
8
+ from remotedesktop.server import ServerWindow
9
+ from remotedesktop.viewer import ViewerWidget
10
+
11
+ from test_discovery import LOOPBACK, free_udp_port
12
+
13
+
14
+ def test_version() -> None:
15
+ assert remotedesktop.__version__
16
+
17
+
18
+ def test_client_window_hosts_viewer(qapp) -> None:
19
+ window = ClientWindow(auto_scan=False)
20
+ assert isinstance(window.viewer, ViewerWidget)
21
+ assert isinstance(window.discovery_panel, DiscoveryPanel)
22
+
23
+
24
+ def test_discovery_panel_lists_servers(qapp) -> None:
25
+ panel = DiscoveryPanel()
26
+ server = ServerInfo(name="testbox", host="192.168.1.7", port=12345)
27
+ panel._show_results([server])
28
+ assert panel.server_list.count() == 1
29
+ assert "testbox" in panel.server_list.item(0).text()
30
+
31
+
32
+ def test_server_window_is_discoverable(qapp, credentials, tmp_path) -> None:
33
+ port = free_udp_port()
34
+ window = ServerWindow(
35
+ discovery_port=port,
36
+ connect_port=0,
37
+ paired=PairedClients(db.connect(tmp_path / "server.db")),
38
+ credentials=credentials,
39
+ )
40
+ try:
41
+ servers = discover_servers(
42
+ timeout=2.0, discovery_port=port, broadcast_hosts=(LOOPBACK,)
43
+ )
44
+ assert [s.name for s in servers] == [socket.gethostname()]
45
+ assert servers[0].port == window.share_server.port
46
+ finally:
47
+ window.close()
48
+
49
+
50
+ def test_viewer_widget_shows_and_clears_frames(qapp) -> None:
51
+ from PySide6.QtGui import QImage
52
+
53
+ viewer = ViewerWidget()
54
+ assert not viewer.has_frame
55
+ viewer.show_frame(QImage(8, 8, QImage.Format.Format_RGB32))
56
+ assert viewer.has_frame
57
+ viewer.clear("gone")
58
+ assert not viewer.has_frame
File without changes
File without changes
File without changes
File without changes