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 +3 -3
- remotedesktop/client.py +245 -27
- remotedesktop/clipboard.py +93 -0
- remotedesktop/config.py +122 -0
- remotedesktop/db.py +49 -0
- remotedesktop/discovery.py +146 -0
- remotedesktop/input_injection.py +123 -0
- remotedesktop/inventory.py +218 -0
- remotedesktop/protocol.py +75 -0
- remotedesktop/server.py +153 -6
- remotedesktop/sharing.py +544 -0
- remotedesktop/tls.py +90 -0
- remotedesktop/viewer.py +170 -15
- remotedesktop/window_state.py +26 -0
- remotedesktop-0.2.0.dist-info/METADATA +113 -0
- remotedesktop-0.2.0.dist-info/RECORD +19 -0
- remotedesktop-0.0.1.dist-info/METADATA +0 -35
- remotedesktop-0.0.1.dist-info/RECORD +0 -9
- {remotedesktop-0.0.1.dist-info → remotedesktop-0.2.0.dist-info}/WHEEL +0 -0
- {remotedesktop-0.0.1.dist-info → remotedesktop-0.2.0.dist-info}/entry_points.txt +0 -0
- {remotedesktop-0.0.1.dist-info → remotedesktop-0.2.0.dist-info}/licenses/LICENSE +0 -0
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
|
+
"""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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
remotedesktop/config.py
ADDED
|
@@ -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())
|