remotedesktop 0.0.1__py3-none-any.whl → 0.2.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- """Remote desktop client/server for Windows computers on the same LAN."""
2
-
3
- __version__ = "0.0.1"
1
+ """Remote desktop client/server for Windows computers on the same LAN."""
2
+
3
+ __version__ = "0.2.0"
remotedesktop/client.py CHANGED
@@ -1,27 +1,245 @@
1
- """Client GUI application: discovers servers on the LAN, connects to one,
2
- and shows its desktop in a viewer widget."""
3
-
4
- import sys
5
-
6
- from PySide6.QtWidgets import QApplication, QMainWindow
7
-
8
- from remotedesktop.viewer import ViewerWidget
9
-
10
-
11
- class ClientWindow(QMainWindow):
12
- def __init__(self) -> None:
13
- super().__init__()
14
- self.setWindowTitle("Remote Desktop Client")
15
- self.viewer = ViewerWidget(self)
16
- self.setCentralWidget(self.viewer)
17
-
18
-
19
- def main() -> None:
20
- app = QApplication(sys.argv)
21
- window = ClientWindow()
22
- window.show()
23
- raise SystemExit(app.exec())
24
-
25
-
26
- if __name__ == "__main__":
27
- main()
1
+ """Client GUI application: discovers servers on the LAN, connects to one,
2
+ and shows its desktop in a viewer widget."""
3
+
4
+ import sqlite3
5
+ import sys
6
+ import threading
7
+ import time
8
+
9
+ from PySide6.QtCore import Qt, Signal
10
+ from PySide6.QtGui import QCloseEvent
11
+ from PySide6.QtWidgets import (
12
+ QApplication,
13
+ QDockWidget,
14
+ QListWidget,
15
+ QListWidgetItem,
16
+ QMainWindow,
17
+ QMessageBox,
18
+ QPlainTextEdit,
19
+ QPushButton,
20
+ QTabWidget,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from remotedesktop import db, window_state
26
+ from remotedesktop.clipboard import ClipboardSync
27
+ from remotedesktop.config import KnownServers, Settings, default_db_path, load_client_identity
28
+ from remotedesktop.discovery import DISCOVERY_PORT, ServerInfo, discover_servers
29
+ from remotedesktop.inventory import ConnectionInventory, InventoryTab
30
+ from remotedesktop.sharing import ShareClient
31
+ from remotedesktop.viewer import ViewerWidget
32
+
33
+
34
+ class DiscoveryPanel(QWidget):
35
+ """Scans the LAN for servers and lists them for the user to pick."""
36
+
37
+ serverActivated = Signal(ServerInfo)
38
+ serversFound = Signal(list)
39
+ status = Signal(str)
40
+ _scanFinished = Signal(list)
41
+
42
+ def __init__(self, parent: QWidget | None = None) -> None:
43
+ super().__init__(parent)
44
+ self._refresh_button = QPushButton("Refresh")
45
+ self.server_list = QListWidget()
46
+ layout = QVBoxLayout(self)
47
+ layout.addWidget(self._refresh_button)
48
+ layout.addWidget(self.server_list)
49
+ self._refresh_button.clicked.connect(self.refresh)
50
+ self.server_list.itemActivated.connect(self._on_item_activated)
51
+ self._scanFinished.connect(self._show_results)
52
+
53
+ def refresh(self) -> None:
54
+ self._refresh_button.setEnabled(False)
55
+ self._refresh_button.setText("Scanning…")
56
+ self.status.emit(f"Scanning LAN (UDP broadcast to port {DISCOVERY_PORT}) …")
57
+ threading.Thread(target=self._scan, name="discovery-scan", daemon=True).start()
58
+
59
+ def _scan(self) -> None:
60
+ # Runs on a worker thread; the signal is delivered queued on the GUI
61
+ # thread. Any failure must still emit, or the button stays disabled.
62
+ servers: list[ServerInfo] = []
63
+ try:
64
+ servers = discover_servers()
65
+ except Exception:
66
+ pass
67
+ self._scanFinished.emit(servers)
68
+
69
+ def _show_results(self, servers: list) -> None:
70
+ self._refresh_button.setEnabled(True)
71
+ self._refresh_button.setText("Refresh")
72
+ self.server_list.clear()
73
+ for server in servers:
74
+ item = QListWidgetItem(f"{server.name} ({server.host}:{server.port})")
75
+ item.setData(Qt.ItemDataRole.UserRole, server)
76
+ self.server_list.addItem(item)
77
+ self.serversFound.emit(servers)
78
+ found = ", ".join(f"{s.name} at {s.host}:{s.port}" for s in servers)
79
+ self.status.emit(f"Scan finished — found: {found}" if servers else "Scan finished — no servers found")
80
+
81
+ def _on_item_activated(self, item: QListWidgetItem) -> None:
82
+ self.serverActivated.emit(item.data(Qt.ItemDataRole.UserRole))
83
+
84
+
85
+ class ClientWindow(QMainWindow):
86
+ def __init__(self, *, connection: sqlite3.Connection | None = None) -> None:
87
+ super().__init__()
88
+ self.setWindowTitle("Remote Desktop Client")
89
+ # Tests inject a connection to a temp database; the app uses the default.
90
+ self._db = connection if connection is not None else db.connect(default_db_path())
91
+ self.viewer = ViewerWidget(self)
92
+ self.inventory = ConnectionInventory(self._db, "client_peers", self)
93
+ tabs = QTabWidget()
94
+ tabs.addTab(self.viewer, "Remote Screen")
95
+ tabs.addTab(
96
+ InventoryTab(self.inventory, "Forget server", self._forget_server),
97
+ "Servers on LAN",
98
+ )
99
+ self.setCentralWidget(tabs)
100
+
101
+ self.discovery_panel = DiscoveryPanel(self)
102
+ servers_dock = QDockWidget("Servers", self)
103
+ servers_dock.setWidget(self.discovery_panel)
104
+ self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, servers_dock)
105
+
106
+ self.connection_log = QPlainTextEdit(self)
107
+ self.connection_log.setReadOnly(True)
108
+ self.connection_log.setMaximumBlockCount(1000)
109
+ log_dock = QDockWidget("Connection log", self)
110
+ log_dock.setWidget(self.connection_log)
111
+ self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, log_dock)
112
+
113
+ self.discovery_panel.serverActivated.connect(self._on_server_activated)
114
+ self.discovery_panel.serversFound.connect(self._record_discovered)
115
+ self.discovery_panel.status.connect(self.log)
116
+ self.viewer.inputEvent.connect(self._on_input_event)
117
+
118
+ self._clipboard = ClipboardSync(parent=self)
119
+ self._known_servers = KnownServers(self._db)
120
+ self._identity = load_client_identity(self._db)
121
+ self._client: ShareClient | None = None
122
+ self._connected = False
123
+ self._denied = False
124
+ self._server_name = ""
125
+ self._server_key = ""
126
+ self._frame_count = 0
127
+ self.statusBar().showMessage("Not connected")
128
+ self._settings = Settings(self._db)
129
+ window_state.restore_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
130
+ self.log("Client started")
131
+
132
+ def log(self, message: str) -> None:
133
+ self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
134
+
135
+ def _forget_server(self, key: str) -> None:
136
+ answer = QMessageBox.question(
137
+ self,
138
+ "Forget server",
139
+ f"Forget server {key}?\n\n"
140
+ "If connected it will be disconnected, and the next connection will "
141
+ "need the server user to approve this computer again.",
142
+ )
143
+ if answer != QMessageBox.StandardButton.Yes:
144
+ return
145
+ if self._connected and self._server_key == key and self._client is not None:
146
+ self._client.close()
147
+ self._known_servers.forget(key)
148
+ self.inventory.record(key, "forgotten")
149
+ self.log(f"Forgot server {key}")
150
+
151
+ def _record_discovered(self, servers: list) -> None:
152
+ for server in servers:
153
+ key = f"{server.host}:{server.port}"
154
+ if self._connected and key == self._server_key:
155
+ continue # don't downgrade the connected server to "discovered"
156
+ self.inventory.record(
157
+ key, "discovered", name=server.name, address=key, detail=key
158
+ )
159
+
160
+ def _on_server_activated(self, server: ServerInfo) -> None:
161
+ if self._client is not None:
162
+ self.log("Closing previous connection")
163
+ self._client.close()
164
+ self._client.deleteLater()
165
+ self._server_name = server.name
166
+ self._server_key = f"{server.host}:{server.port}"
167
+ self._frame_count = 0
168
+ self._connected = False
169
+ self._denied = False
170
+ self.inventory.record(
171
+ self._server_key, "attempt", name=server.name,
172
+ address=self._server_key, detail=self._server_key,
173
+ )
174
+ client = ShareClient(
175
+ identity=self._identity,
176
+ known_servers=self._known_servers,
177
+ clipboard=self._clipboard,
178
+ parent=self,
179
+ )
180
+ self._client = client
181
+ client.status.connect(self.log)
182
+ client.connected.connect(self._on_connected)
183
+ client.denied.connect(self._on_denied)
184
+ client.disconnected.connect(self._on_disconnected)
185
+ client.frameReceived.connect(self._on_frame)
186
+ self.viewer.clear(f"Connecting to {server.name} …")
187
+ self.statusBar().showMessage(f"Connecting to {server.name} ({server.host}:{server.port}) …")
188
+ client.connect_to(server.host, server.port)
189
+
190
+ def _on_connected(self, server_name: str) -> None:
191
+ self._server_name = server_name or self._server_name
192
+ self._connected = True
193
+ self.inventory.record(self._server_key, "connected", name=self._server_name)
194
+ self.viewer.setFocus()
195
+ self.statusBar().showMessage(
196
+ f"Connected to {self._server_name} — waiting for first frame "
197
+ "(click the view to control it)"
198
+ )
199
+
200
+ def _on_input_event(self, event: dict) -> None:
201
+ if self._connected and self._client is not None:
202
+ self._client.send_input(event)
203
+
204
+ def _on_denied(self, reason: str) -> None:
205
+ self._connected = False
206
+ self._denied = True
207
+ self.inventory.record(self._server_key, "denied", name=self._server_name)
208
+ self.viewer.clear(f"Connection denied: {reason}")
209
+ self.statusBar().showMessage(f"Denied by {self._server_name}: {reason}")
210
+
211
+ def _on_disconnected(self) -> None:
212
+ self._connected = False
213
+ # After a denial, keep "denied" as the peer's state in the inventory
214
+ # rather than overwriting it with the trailing "disconnected".
215
+ if self._server_key and not self._denied:
216
+ self.inventory.record(self._server_key, "disconnected", name=self._server_name)
217
+ if not self._denied:
218
+ self.viewer.clear("Disconnected")
219
+ self.statusBar().showMessage(f"Disconnected from {self._server_name}")
220
+
221
+ def _on_frame(self, image) -> None:
222
+ self._frame_count += 1
223
+ self.viewer.show_frame(image)
224
+ self.statusBar().showMessage(
225
+ f"Viewing {self._server_name} — {image.width()}x{image.height()} — "
226
+ f"{self._frame_count} frames received"
227
+ )
228
+
229
+ def closeEvent(self, event: QCloseEvent) -> None:
230
+ window_state.save_geometry(self, self._settings, window_state.CLIENT_GEOMETRY_KEY)
231
+ if self._client is not None:
232
+ self._client.close()
233
+ super().closeEvent(event)
234
+
235
+
236
+ def main() -> None: # pragma: no cover - runs the Qt event loop
237
+ app = QApplication(sys.argv)
238
+ window = ClientWindow()
239
+ window.show()
240
+ window.discovery_panel.refresh()
241
+ raise SystemExit(app.exec())
242
+
243
+
244
+ if __name__ == "__main__": # pragma: no cover
245
+ main()
@@ -0,0 +1,93 @@
1
+ """Clipboard synchronization.
2
+
3
+ Wraps the local QClipboard: emits `changed` with a serializable payload when
4
+ the user copies something locally, and `apply()` writes a payload received
5
+ from the peer into the local clipboard.
6
+
7
+ Echo prevention is by content signature, not just a guard flag: applying a
8
+ payload records its signature, and any clipboard-changed notification whose
9
+ content matches the last signature is ignored. The signature hashes the
10
+ canonical pixels of images (not their PNG encoding), so a value that makes a
11
+ round trip through the OS clipboard and back does not loop. This matters
12
+ because on Windows `dataChanged` fires asynchronously, after a simple guard
13
+ flag would already have been cleared.
14
+ """
15
+
16
+ import base64
17
+ import hashlib
18
+
19
+ from PySide6.QtCore import QBuffer, QMimeData, QObject, Signal
20
+ from PySide6.QtGui import QClipboard, QGuiApplication, QImage
21
+
22
+
23
+ def _image_hash(image: QImage | None) -> str | None:
24
+ if image is None or image.isNull():
25
+ return None
26
+ canonical = image.convertToFormat(QImage.Format.Format_RGBA8888)
27
+ return hashlib.sha1(bytes(canonical.constBits())).hexdigest()
28
+
29
+
30
+ class ClipboardSync(QObject):
31
+ """Bridges the local clipboard to the network. See module docstring."""
32
+
33
+ changed = Signal(dict)
34
+
35
+ def __init__(
36
+ self, clipboard: QClipboard | None = None, parent: QObject | None = None
37
+ ) -> None:
38
+ super().__init__(parent)
39
+ self._clipboard = clipboard or QGuiApplication.clipboard()
40
+ self._applying = False
41
+ self._last_signature: tuple[str | None, str | None] | None = None
42
+ self._clipboard.dataChanged.connect(self._on_data_changed)
43
+
44
+ def _on_data_changed(self) -> None:
45
+ if self._applying:
46
+ return
47
+ text = self._clipboard.text() or None
48
+ image = self._clipboard.image()
49
+ image_hash = _image_hash(image)
50
+ signature = (text, image_hash)
51
+ if signature == self._last_signature or (text is None and image_hash is None):
52
+ return
53
+ self._last_signature = signature
54
+ payload: dict = {}
55
+ if text is not None:
56
+ payload["text"] = text
57
+ if image_hash is not None:
58
+ buffer = QBuffer()
59
+ buffer.open(QBuffer.OpenModeFlag.WriteOnly)
60
+ image.save(buffer, "PNG") # ty: ignore[no-matching-overload]
61
+ payload["image_png"] = base64.b64encode(
62
+ bytes(buffer.data()) # ty: ignore[invalid-argument-type]
63
+ ).decode()
64
+ self.changed.emit(payload)
65
+
66
+ def apply(self, payload: dict) -> None:
67
+ text = payload.get("text")
68
+ text = text if isinstance(text, str) else None
69
+ image = None
70
+ encoded = payload.get("image_png")
71
+ if isinstance(encoded, str):
72
+ try:
73
+ image = QImage.fromData(base64.b64decode(encoded), "PNG") # ty: ignore[invalid-argument-type]
74
+ except (ValueError, TypeError):
75
+ image = None
76
+ if image is not None and image.isNull():
77
+ image = None
78
+ signature = (text, _image_hash(image))
79
+ if signature == self._last_signature or signature == (None, None):
80
+ return
81
+ self._last_signature = signature
82
+ # One QMimeData carrying both representations, so a payload with text
83
+ # and an image keeps both halves on the receiving clipboard.
84
+ mime = QMimeData()
85
+ if text is not None:
86
+ mime.setText(text)
87
+ if image is not None:
88
+ mime.setImageData(image)
89
+ self._applying = True
90
+ try:
91
+ self._clipboard.setMimeData(mime)
92
+ finally:
93
+ self._applying = False
@@ -0,0 +1,122 @@
1
+ """Persistent per-machine state, all stored in the shared SQLite database.
2
+
3
+ `Settings` is a generic key/value store (and holds this client's stable id and
4
+ name). `PairedClients` maps each approved client id to the token issued at
5
+ approval (server side). `KnownServers` records the servers this machine has
6
+ paired with — pinned fingerprint and token — keyed by "host:port" (client
7
+ side). All three operate on a `sqlite3.Connection` from `db.connect`.
8
+ """
9
+
10
+ import socket
11
+ import sqlite3
12
+ import uuid
13
+ from pathlib import Path
14
+
15
+ import platformdirs
16
+
17
+
18
+ def default_config_dir() -> Path:
19
+ return Path(platformdirs.user_data_dir("remotedesktop", appauthor=False))
20
+
21
+
22
+ def default_db_path() -> Path:
23
+ return default_config_dir() / "remotedesktop.db"
24
+
25
+
26
+ class Settings:
27
+ """A key/value store over the `settings` table."""
28
+
29
+ def __init__(self, connection: sqlite3.Connection) -> None:
30
+ self._db = connection
31
+
32
+ def get(self, key: str, default: str | None = None) -> str | None:
33
+ row = self._db.execute(
34
+ "SELECT value FROM settings WHERE key = ?", (key,)
35
+ ).fetchone()
36
+ return row[0] if row is not None else default
37
+
38
+ def set(self, key: str, value: str) -> None:
39
+ self._db.execute(
40
+ "INSERT INTO settings (key, value) VALUES (?, ?) "
41
+ "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
42
+ (key, value),
43
+ )
44
+ self._db.commit()
45
+
46
+
47
+ def load_client_identity(connection: sqlite3.Connection) -> tuple[str, str]:
48
+ """Return this client's (stable id, display name), creating it on first use."""
49
+ settings = Settings(connection)
50
+ client_id = settings.get("client_id")
51
+ name = settings.get("client_name")
52
+ if client_id and name:
53
+ return client_id, name
54
+ client_id = str(uuid.uuid4())
55
+ name = socket.gethostname()
56
+ settings.set("client_id", client_id)
57
+ settings.set("client_name", name)
58
+ return client_id, name
59
+
60
+
61
+ class PairedClients:
62
+ """Maps each approved client id to its shared token (server side).
63
+
64
+ A client is "approved" exactly when it has a token here.
65
+ """
66
+
67
+ def __init__(self, connection: sqlite3.Connection) -> None:
68
+ self._db = connection
69
+
70
+ def __contains__(self, client_id: str) -> bool:
71
+ return self.token_for(client_id) is not None
72
+
73
+ def token_for(self, client_id: str) -> str | None:
74
+ row = self._db.execute(
75
+ "SELECT token FROM paired_clients WHERE client_id = ?", (client_id,)
76
+ ).fetchone()
77
+ return row[0] if row is not None else None
78
+
79
+ def pair(self, client_id: str) -> str:
80
+ """Issue and persist a new token for a client, returning it."""
81
+ import secrets
82
+
83
+ token = secrets.token_hex(32)
84
+ self._db.execute(
85
+ "INSERT INTO paired_clients (client_id, token) VALUES (?, ?) "
86
+ "ON CONFLICT(client_id) DO UPDATE SET token = excluded.token",
87
+ (client_id, token),
88
+ )
89
+ self._db.commit()
90
+ return token
91
+
92
+ def revoke(self, client_id: str) -> None:
93
+ """Remove a client's token so it must be approved again to reconnect."""
94
+ self._db.execute("DELETE FROM paired_clients WHERE client_id = ?", (client_id,))
95
+ self._db.commit()
96
+
97
+
98
+ class KnownServers:
99
+ """Client-side record of paired servers, keyed by "host:port"."""
100
+
101
+ def __init__(self, connection: sqlite3.Connection) -> None:
102
+ self._db = connection
103
+
104
+ def get(self, key: str) -> dict | None:
105
+ row = self._db.execute(
106
+ "SELECT fingerprint, token FROM known_servers WHERE key = ?", (key,)
107
+ ).fetchone()
108
+ return {"fingerprint": row[0], "token": row[1]} if row is not None else None
109
+
110
+ def remember(self, key: str, fingerprint: str, token: str) -> None:
111
+ self._db.execute(
112
+ "INSERT INTO known_servers (key, fingerprint, token) VALUES (?, ?, ?) "
113
+ "ON CONFLICT(key) DO UPDATE SET fingerprint = excluded.fingerprint, "
114
+ "token = excluded.token",
115
+ (key, fingerprint, token),
116
+ )
117
+ self._db.commit()
118
+
119
+ def forget(self, key: str) -> None:
120
+ """Drop a server's stored token and pin, so the next connection re-pairs."""
121
+ self._db.execute("DELETE FROM known_servers WHERE key = ?", (key,))
122
+ self._db.commit()
remotedesktop/db.py ADDED
@@ -0,0 +1,49 @@
1
+ """The single SQLite database that backs all persistence.
2
+
3
+ One database file under %LOCALAPPDATA%/remotedesktop holds every persistent table:
4
+ settings (a key/value store, including this client's identity), the server's
5
+ paired clients, the client's known servers, and the connection inventory. Pass
6
+ `None` for an in-memory database (used by tests).
7
+ """
8
+
9
+ import sqlite3
10
+ from pathlib import Path
11
+
12
+ _PEER_COLUMNS = (
13
+ "key TEXT PRIMARY KEY, name TEXT, address TEXT, detail TEXT, "
14
+ "first_seen TEXT, last_seen TEXT, attempts INTEGER, state TEXT, last_event TEXT"
15
+ )
16
+
17
+ # The server's inventory of clients and the client's inventory of servers are
18
+ # kept in separate tables so a machine running both apps doesn't commingle them.
19
+ # `peers` is the generic table used by tests.
20
+ PEER_TABLES = ("peers", "server_peers", "client_peers")
21
+
22
+ _SCHEMA = f"""
23
+ CREATE TABLE IF NOT EXISTS settings (
24
+ key TEXT PRIMARY KEY,
25
+ value TEXT
26
+ );
27
+ CREATE TABLE IF NOT EXISTS paired_clients (
28
+ client_id TEXT PRIMARY KEY,
29
+ token TEXT
30
+ );
31
+ CREATE TABLE IF NOT EXISTS known_servers (
32
+ key TEXT PRIMARY KEY,
33
+ fingerprint TEXT,
34
+ token TEXT
35
+ );
36
+ CREATE TABLE IF NOT EXISTS peers ({_PEER_COLUMNS});
37
+ CREATE TABLE IF NOT EXISTS server_peers ({_PEER_COLUMNS});
38
+ CREATE TABLE IF NOT EXISTS client_peers ({_PEER_COLUMNS});
39
+ """
40
+
41
+
42
+ def connect(path: Path | None) -> sqlite3.Connection:
43
+ """Open (creating if needed) the database and ensure every table exists."""
44
+ if path is not None:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ connection = sqlite3.connect(str(path) if path is not None else ":memory:")
47
+ connection.executescript(_SCHEMA)
48
+ connection.commit()
49
+ return connection
@@ -0,0 +1,146 @@
1
+ """LAN autodiscovery.
2
+
3
+ Clients broadcast a JSON probe datagram to the discovery port; every server
4
+ on the LAN replies with its display name and TCP connection port. Datagrams
5
+ whose magic, protocol version, or type don't match are ignored.
6
+ """
7
+
8
+ import json
9
+ import socket
10
+ import threading
11
+ import time
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+
15
+ DISCOVERY_PORT = 48653
16
+ DEFAULT_CONNECT_PORT = 48654
17
+
18
+ _MAGIC = "remotedesktop"
19
+ _PROTOCOL_VERSION = 1
20
+ _MAX_DATAGRAM = 4096
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ServerInfo:
25
+ name: str
26
+ host: str
27
+ port: int
28
+
29
+
30
+ def _parse(data: bytes, expected_type: str) -> dict | None:
31
+ try:
32
+ message = json.loads(data.decode())
33
+ except (UnicodeDecodeError, json.JSONDecodeError):
34
+ return None
35
+ if not isinstance(message, dict):
36
+ return None
37
+ if message.get("magic") != _MAGIC or message.get("version") != _PROTOCOL_VERSION:
38
+ return None
39
+ if message.get("type") != expected_type:
40
+ return None
41
+ return message
42
+
43
+
44
+ def _encode(message_type: str, **fields: object) -> bytes:
45
+ return json.dumps(
46
+ {"magic": _MAGIC, "version": _PROTOCOL_VERSION, "type": message_type, **fields}
47
+ ).encode()
48
+
49
+
50
+ class DiscoveryResponder:
51
+ """Runs on the server; answers discovery probes with this server's info."""
52
+
53
+ def __init__(
54
+ self,
55
+ name: str,
56
+ connect_port: int,
57
+ *,
58
+ discovery_port: int = DISCOVERY_PORT,
59
+ bind_host: str = "",
60
+ ) -> None:
61
+ self._reply = _encode("reply", name=name, port=connect_port)
62
+ self._discovery_port = discovery_port
63
+ self._bind_host = bind_host
64
+ self._socket: socket.socket | None = None
65
+ self._thread: threading.Thread | None = None
66
+ self._running = threading.Event()
67
+
68
+ def start(self) -> None:
69
+ if self._thread is not None:
70
+ raise RuntimeError("responder already started")
71
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
72
+ try:
73
+ sock.bind((self._bind_host, self._discovery_port))
74
+ except OSError:
75
+ sock.close()
76
+ raise
77
+ sock.settimeout(0.25)
78
+ self._socket = sock
79
+ self._running.set()
80
+ self._thread = threading.Thread(
81
+ target=self._serve, name="discovery-responder", daemon=True
82
+ )
83
+ self._thread.start()
84
+
85
+ def stop(self) -> None:
86
+ self._running.clear()
87
+ if self._thread is not None:
88
+ self._thread.join()
89
+ self._thread = None
90
+ if self._socket is not None:
91
+ self._socket.close()
92
+ self._socket = None
93
+
94
+ def _serve(self) -> None:
95
+ assert self._socket is not None
96
+ while self._running.is_set():
97
+ try:
98
+ data, sender = self._socket.recvfrom(_MAX_DATAGRAM)
99
+ except TimeoutError:
100
+ continue
101
+ except OSError:
102
+ return
103
+ if _parse(data, "probe") is None:
104
+ continue
105
+ try:
106
+ self._socket.sendto(self._reply, sender)
107
+ except OSError:
108
+ pass
109
+
110
+
111
+ def discover_servers(
112
+ timeout: float = 1.0,
113
+ *,
114
+ discovery_port: int = DISCOVERY_PORT,
115
+ broadcast_hosts: Sequence[str] = ("255.255.255.255",),
116
+ ) -> list[ServerInfo]:
117
+ """Broadcast a probe and collect server replies until the timeout expires.
118
+
119
+ Results are deduplicated by (host, port); order is arrival order.
120
+ """
121
+ found: dict[tuple[str, int], ServerInfo] = {}
122
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
123
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
124
+ sock.bind(("", 0))
125
+ probe = _encode("probe")
126
+ for host in broadcast_hosts:
127
+ try:
128
+ sock.sendto(probe, (host, discovery_port))
129
+ except OSError:
130
+ pass
131
+ deadline = time.monotonic() + timeout
132
+ while (remaining := deadline - time.monotonic()) > 0:
133
+ sock.settimeout(remaining)
134
+ try:
135
+ data, (host, _sender_port) = sock.recvfrom(_MAX_DATAGRAM)
136
+ except (TimeoutError, OSError):
137
+ break
138
+ message = _parse(data, "reply")
139
+ if message is None:
140
+ continue
141
+ name = message.get("name")
142
+ port = message.get("port")
143
+ if not isinstance(name, str) or not isinstance(port, int):
144
+ continue
145
+ found.setdefault((host, port), ServerInfo(name=name, host=host, port=port))
146
+ return list(found.values())