remotedesktop 0.2.0__py3-none-any.whl → 0.4.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
1
  """Remote desktop client/server for Windows computers on the same LAN."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.4.0"
@@ -0,0 +1,63 @@
1
+ """Start the server automatically when the user logs in to Windows.
2
+
3
+ Uses the per-user Run registry key (HKCU), so no administrator rights are
4
+ needed and the server starts in the interactive session — which it needs,
5
+ because approving a new client is a GUI prompt. On non-Windows platforms
6
+ this is an inert stub, like input injection.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ _IS_WINDOWS = sys.platform == "win32"
13
+ if _IS_WINDOWS:
14
+ import winreg
15
+
16
+ _RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
17
+ _VALUE_NAME = "remotedesktop-server"
18
+
19
+
20
+ def server_command() -> str:
21
+ """The command line that launches this installation's server."""
22
+ exe = Path(sys.executable).with_name("remotedesktop-server.exe")
23
+ if exe.exists():
24
+ return f'"{exe}"'
25
+ # Fallback (e.g. running from source without the entry-point exe).
26
+ return f'"{sys.executable}" -m remotedesktop.server'
27
+
28
+
29
+ class Autostart:
30
+ """Reads and writes the login-autostart registration.
31
+
32
+ Tests pass their own `key_path` so they never touch the real Run key.
33
+ """
34
+
35
+ def __init__(self, *, key_path: str = _RUN_KEY, value_name: str = _VALUE_NAME) -> None:
36
+ self.available = _IS_WINDOWS
37
+ self._key_path = key_path
38
+ self._value_name = value_name
39
+
40
+ def is_enabled(self) -> bool:
41
+ if not self.available:
42
+ return False
43
+ try:
44
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, self._key_path) as key:
45
+ winreg.QueryValueEx(key, self._value_name)
46
+ return True
47
+ except OSError:
48
+ return False
49
+
50
+ def set_enabled(self, enabled: bool) -> None:
51
+ if not self.available:
52
+ return
53
+ if enabled:
54
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, self._key_path) as key:
55
+ winreg.SetValueEx(key, self._value_name, 0, winreg.REG_SZ, server_command())
56
+ else:
57
+ try:
58
+ with winreg.OpenKey(
59
+ winreg.HKEY_CURRENT_USER, self._key_path, 0, winreg.KEY_SET_VALUE
60
+ ) as key:
61
+ winreg.DeleteValue(key, self._value_name)
62
+ except OSError:
63
+ pass # already not registered
remotedesktop/server.py CHANGED
@@ -1,174 +1,189 @@
1
- """Server GUI application: shares this computer's desktop with permitted
2
- clients and prompts the user to approve first-time connections."""
3
-
4
- import socket
5
- import sqlite3
6
- import sys
7
- import time
8
-
9
- from PySide6.QtCore import Qt
10
- from PySide6.QtGui import QCloseEvent
11
- from PySide6.QtWidgets import (
12
- QApplication,
13
- QLabel,
14
- QMainWindow,
15
- QMessageBox,
16
- QPlainTextEdit,
17
- QTabWidget,
18
- QVBoxLayout,
19
- QWidget,
20
- )
21
-
22
- from remotedesktop import db, tls, window_state
23
- from remotedesktop.clipboard import ClipboardSync
24
- from remotedesktop.config import PairedClients, Settings, default_config_dir, default_db_path
25
- from remotedesktop.discovery import (
26
- DEFAULT_CONNECT_PORT,
27
- DISCOVERY_PORT,
28
- DiscoveryResponder,
29
- )
30
- from remotedesktop.inventory import ConnectionInventory, InventoryTab
31
- from remotedesktop.sharing import ShareServer
32
-
33
-
34
- class ServerWindow(QMainWindow):
35
- def __init__(
36
- self,
37
- *,
38
- discovery_port: int = DISCOVERY_PORT,
39
- connect_port: int = DEFAULT_CONNECT_PORT,
40
- paired: PairedClients | None = None,
41
- credentials=None,
42
- connection: sqlite3.Connection | None = None,
43
- ) -> None:
44
- super().__init__()
45
- self.setWindowTitle("Remote Desktop Server")
46
- self._name = socket.gethostname()
47
-
48
- self._summary = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
49
- self.connection_log = QPlainTextEdit()
50
- self.connection_log.setReadOnly(True)
51
- self.connection_log.setMaximumBlockCount(1000)
52
- status_tab = QWidget()
53
- status_layout = QVBoxLayout(status_tab)
54
- status_layout.addWidget(self._summary)
55
- status_layout.addWidget(self.connection_log, stretch=1)
56
-
57
- # Tests inject a connection to a temp database; the app uses the default.
58
- self._db = connection if connection is not None else db.connect(default_db_path())
59
- self.inventory = ConnectionInventory(self._db, "server_peers", self)
60
- tabs = QTabWidget()
61
- tabs.addTab(status_tab, "Status")
62
- tabs.addTab(
63
- InventoryTab(self.inventory, "Revoke access", self._revoke_client),
64
- "Clients on LAN",
65
- )
66
- self.setCentralWidget(tabs)
67
-
68
- if credentials is None:
69
- config_dir = default_config_dir()
70
- credentials = tls.load_or_create_credentials(
71
- config_dir / "server_cert.pem", config_dir / "server_key.pem"
72
- )
73
- if paired is None:
74
- paired = PairedClients(self._db)
75
- self._clipboard = ClipboardSync(parent=self)
76
- self.share_server = ShareServer(
77
- self._ask_approval,
78
- credentials=credentials,
79
- paired=paired,
80
- clipboard=self._clipboard,
81
- parent=self,
82
- )
83
- self.share_server.status.connect(self.log)
84
- self.share_server.clientCountChanged.connect(self._update_summary)
85
- self.share_server.peerEvent.connect(self._record_peer)
86
- self._listening = self.share_server.listen(connect_port)
87
-
88
- self.responder: DiscoveryResponder | None = None
89
- self._discoverable = False
90
- if self._listening:
91
- responder = DiscoveryResponder(
92
- self._name, self.share_server.port, discovery_port=discovery_port
93
- )
94
- try:
95
- responder.start()
96
- except OSError as error:
97
- self.log(
98
- f"Discovery unavailable (UDP port {discovery_port}): {error} — "
99
- "another server may already be running"
100
- )
101
- else:
102
- self.responder = responder
103
- self._discoverable = True
104
- self.log(
105
- f'Discoverable as "{self._name}" '
106
- f"(UDP port {discovery_port}, TCP port {self.share_server.port})"
107
- )
108
- self._update_summary(0)
109
- self._settings = Settings(self._db)
110
- window_state.restore_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
111
-
112
- def log(self, message: str) -> None:
113
- self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
114
-
115
- def _record_peer(self, event: dict) -> None:
116
- self.inventory.record(
117
- event["key"],
118
- event["event"],
119
- name=event.get("name", ""),
120
- address=event.get("address", ""),
121
- detail=event.get("detail", ""),
122
- )
123
-
124
- def _update_summary(self, client_count: int) -> None:
125
- if not self._listening:
126
- self._summary.setText("Cannot share: the connection port is already in use")
127
- return
128
- discoverable = (
129
- f'Discoverable on this LAN as "{self._name}"'
130
- if self._discoverable
131
- else "Not discoverable (discovery port in use)"
132
- )
133
- sharing = (
134
- f"Sharing this desktop with {client_count} viewer(s)"
135
- if client_count
136
- else "Not sharing"
137
- )
138
- self._summary.setText(f"{discoverable}\n{sharing}")
139
-
140
- def _revoke_client(self, client_id: str) -> None:
141
- answer = QMessageBox.question(
142
- self,
143
- "Revoke access",
144
- f"Revoke access for client {client_id}?\n\n"
145
- "It will be disconnected now and must be approved again to reconnect.",
146
- )
147
- if answer == QMessageBox.StandardButton.Yes:
148
- self.share_server.revoke_client(client_id)
149
-
150
- def _ask_approval(self, client_id: str, client_name: str) -> bool:
151
- answer = QMessageBox.question(
152
- self,
153
- "Connection request",
154
- f'Allow "{client_name}" to view this desktop?\n\nClient id: {client_id}',
155
- )
156
- return answer == QMessageBox.StandardButton.Yes
157
-
158
- def closeEvent(self, event: QCloseEvent) -> None:
159
- window_state.save_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
160
- if self.responder is not None:
161
- self.responder.stop()
162
- self.share_server.close()
163
- super().closeEvent(event)
164
-
165
-
166
- def main() -> None: # pragma: no cover - runs the Qt event loop
167
- app = QApplication(sys.argv)
168
- window = ServerWindow()
169
- window.show()
170
- raise SystemExit(app.exec())
171
-
172
-
173
- if __name__ == "__main__": # pragma: no cover
174
- main()
1
+ """Server GUI application: shares this computer's desktop with permitted
2
+ clients and prompts the user to approve first-time connections."""
3
+
4
+ import socket
5
+ import sqlite3
6
+ import sys
7
+ import time
8
+
9
+ from PySide6.QtCore import Qt
10
+ from PySide6.QtGui import QCloseEvent
11
+ from PySide6.QtWidgets import (
12
+ QApplication,
13
+ QCheckBox,
14
+ QLabel,
15
+ QMainWindow,
16
+ QMessageBox,
17
+ QPlainTextEdit,
18
+ QTabWidget,
19
+ QVBoxLayout,
20
+ QWidget,
21
+ )
22
+
23
+ from remotedesktop import db, tls, window_state
24
+ from remotedesktop.autostart import Autostart
25
+ from remotedesktop.clipboard import ClipboardSync
26
+ from remotedesktop.config import PairedClients, Settings, default_config_dir, default_db_path
27
+ from remotedesktop.discovery import (
28
+ DEFAULT_CONNECT_PORT,
29
+ DISCOVERY_PORT,
30
+ DiscoveryResponder,
31
+ )
32
+ from remotedesktop.inventory import ConnectionInventory, InventoryTab
33
+ from remotedesktop.sharing import ShareServer
34
+
35
+
36
+ class ServerWindow(QMainWindow):
37
+ def __init__(
38
+ self,
39
+ *,
40
+ discovery_port: int = DISCOVERY_PORT,
41
+ connect_port: int = DEFAULT_CONNECT_PORT,
42
+ paired: PairedClients | None = None,
43
+ credentials=None,
44
+ connection: sqlite3.Connection | None = None,
45
+ autostart: Autostart | None = None,
46
+ ) -> None:
47
+ super().__init__()
48
+ self.setWindowTitle("Remote Desktop Server")
49
+ self._name = socket.gethostname()
50
+
51
+ self._summary = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
52
+ self._autostart = autostart if autostart is not None else Autostart()
53
+ self.autostart_checkbox = QCheckBox("Start this server when I log in to Windows")
54
+ self.autostart_checkbox.setChecked(self._autostart.is_enabled())
55
+ self.autostart_checkbox.setEnabled(self._autostart.available)
56
+ self.autostart_checkbox.toggled.connect(self._on_autostart_toggled)
57
+ self.connection_log = QPlainTextEdit()
58
+ self.connection_log.setReadOnly(True)
59
+ self.connection_log.setMaximumBlockCount(1000)
60
+ status_tab = QWidget()
61
+ status_layout = QVBoxLayout(status_tab)
62
+ status_layout.addWidget(self._summary)
63
+ status_layout.addWidget(self.autostart_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
64
+ status_layout.addWidget(self.connection_log, stretch=1)
65
+
66
+ # Tests inject a connection to a temp database; the app uses the default.
67
+ self._db = connection if connection is not None else db.connect(default_db_path())
68
+ self.inventory = ConnectionInventory(self._db, "server_peers", self)
69
+ tabs = QTabWidget()
70
+ tabs.addTab(status_tab, "Status")
71
+ tabs.addTab(
72
+ InventoryTab(self.inventory, "Revoke access", self._revoke_client),
73
+ "Clients on LAN",
74
+ )
75
+ self.setCentralWidget(tabs)
76
+
77
+ if credentials is None:
78
+ config_dir = default_config_dir()
79
+ credentials = tls.load_or_create_credentials(
80
+ config_dir / "server_cert.pem", config_dir / "server_key.pem"
81
+ )
82
+ if paired is None:
83
+ paired = PairedClients(self._db)
84
+ self._clipboard = ClipboardSync(parent=self)
85
+ self.share_server = ShareServer(
86
+ self._ask_approval,
87
+ credentials=credentials,
88
+ paired=paired,
89
+ clipboard=self._clipboard,
90
+ parent=self,
91
+ )
92
+ self.share_server.status.connect(self.log)
93
+ self.share_server.clientCountChanged.connect(self._update_summary)
94
+ self.share_server.peerEvent.connect(self._record_peer)
95
+ self._listening = self.share_server.listen(connect_port)
96
+
97
+ self.responder: DiscoveryResponder | None = None
98
+ self._discoverable = False
99
+ if self._listening:
100
+ responder = DiscoveryResponder(
101
+ self._name, self.share_server.port, discovery_port=discovery_port
102
+ )
103
+ try:
104
+ responder.start()
105
+ except OSError as error:
106
+ self.log(
107
+ f"Discovery unavailable (UDP port {discovery_port}): {error} — "
108
+ "another server may already be running"
109
+ )
110
+ else:
111
+ self.responder = responder
112
+ self._discoverable = True
113
+ self.log(
114
+ f'Discoverable as "{self._name}" '
115
+ f"(UDP port {discovery_port}, TCP port {self.share_server.port})"
116
+ )
117
+ self._update_summary(0)
118
+ self._settings = Settings(self._db)
119
+ window_state.restore_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
120
+
121
+ def log(self, message: str) -> None:
122
+ self.connection_log.appendPlainText(f"{time.strftime('%H:%M:%S')} {message}")
123
+
124
+ def _record_peer(self, event: dict) -> None:
125
+ self.inventory.record(
126
+ event["key"],
127
+ event["event"],
128
+ name=event.get("name", ""),
129
+ address=event.get("address", ""),
130
+ detail=event.get("detail", ""),
131
+ )
132
+
133
+ def _update_summary(self, client_count: int) -> None:
134
+ if not self._listening:
135
+ self._summary.setText("Cannot share: the connection port is already in use")
136
+ return
137
+ discoverable = (
138
+ f'Discoverable on this LAN as "{self._name}"'
139
+ if self._discoverable
140
+ else "Not discoverable (discovery port in use)"
141
+ )
142
+ sharing = (
143
+ f"Sharing this desktop with {client_count} viewer(s)"
144
+ if client_count
145
+ else "Not sharing"
146
+ )
147
+ self._summary.setText(f"{discoverable}\n{sharing}")
148
+
149
+ def _on_autostart_toggled(self, checked: bool) -> None:
150
+ self._autostart.set_enabled(checked)
151
+ self.log(
152
+ "Server will start at login" if checked else "Server will no longer start at login"
153
+ )
154
+
155
+ def _revoke_client(self, client_id: str) -> None:
156
+ answer = QMessageBox.question(
157
+ self,
158
+ "Revoke access",
159
+ f"Revoke access for client {client_id}?\n\n"
160
+ "It will be disconnected now and must be approved again to reconnect.",
161
+ )
162
+ if answer == QMessageBox.StandardButton.Yes:
163
+ self.share_server.revoke_client(client_id)
164
+
165
+ def _ask_approval(self, client_id: str, client_name: str) -> bool:
166
+ answer = QMessageBox.question(
167
+ self,
168
+ "Connection request",
169
+ f'Allow "{client_name}" to view this desktop?\n\nClient id: {client_id}',
170
+ )
171
+ return answer == QMessageBox.StandardButton.Yes
172
+
173
+ def closeEvent(self, event: QCloseEvent) -> None:
174
+ window_state.save_geometry(self, self._settings, window_state.SERVER_GEOMETRY_KEY)
175
+ if self.responder is not None:
176
+ self.responder.stop()
177
+ self.share_server.close()
178
+ super().closeEvent(event)
179
+
180
+
181
+ def main() -> None: # pragma: no cover - runs the Qt event loop
182
+ app = QApplication(sys.argv)
183
+ window = ServerWindow()
184
+ window.show()
185
+ raise SystemExit(app.exec())
186
+
187
+
188
+ if __name__ == "__main__": # pragma: no cover
189
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.2.0
3
+ Version: 0.4.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
@@ -28,6 +28,10 @@ Description-Content-Type: text/markdown
28
28
  A Python client/server application that provides remote desktop for Windows
29
29
  computers on the same LAN, with autodiscovery of servers.
30
30
 
31
+ The server can optionally start automatically when you log in to Windows —
32
+ a checkbox on its Status tab registers it under the per-user Run key (no
33
+ administrator rights needed).
34
+
31
35
  Connections are made to the desktop screen, keyboard, mouse, and clipboard.
32
36
  Other connections are not provided, such as shared drives, devices, or
33
37
  multimedia (e.g., audio).
@@ -111,3 +115,13 @@ uv run pytest # run the tests
111
115
  Run the tests from PowerShell or cmd, not Git Bash: Git Bash puts Git's
112
116
  MinGW OpenSSL DLLs on `PATH`, which Qt's TLS backend loads and crashes on.
113
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.
@@ -1,4 +1,5 @@
1
- remotedesktop/__init__.py,sha256=U48HuI2lyUbilZSyrRLP34Te7Zx743rikPVFy5sj2Ro,100
1
+ remotedesktop/__init__.py,sha256=d_RMAscIXOFiew0nH6TDft-NXuqIaPI46IniyCQ20tY,100
2
+ remotedesktop/autostart.py,sha256=MM7qsO6MVcK4dEofjSyRPrKpBinKU5w6xaWptLDC-7g,2251
2
3
  remotedesktop/client.py,sha256=XRGPCCmbLDTjsB6eLi8sM87pR9iXWcNJIFZmh8MlMnE,10209
3
4
  remotedesktop/clipboard.py,sha256=aDVwBVztSh2MxgTFIMaVsoXjZIrjDeVBnSOCmou9L9A,3743
4
5
  remotedesktop/config.py,sha256=pE8hUEnktgthRDALyHz8qdUeUKjIhcSotffgniZLszM,4285
@@ -7,13 +8,13 @@ remotedesktop/discovery.py,sha256=ai_ic2XtSKO_m8y5IFkHQ88PBDgd_N6YOEccaIBtmis,47
7
8
  remotedesktop/input_injection.py,sha256=p-MRpnS7WDt5S8JK4gNC5cgAjM9DMqV-kzqJkLdtKTw,4581
8
9
  remotedesktop/inventory.py,sha256=fJLh2Ni2oCuUTEvHNANzpVf4abJT1eLeDU-YRSHwpjQ,7406
9
10
  remotedesktop/protocol.py,sha256=IeX-SVz2KiZzYZ3ZiAHE8ZNYC8zyVNQi5uTxcGD4Zzc,2532
10
- remotedesktop/server.py,sha256=eV1adhxRK7opl8jNPsdBiHOugKOZLmpTR8ryRtDAALg,6252
11
+ remotedesktop/server.py,sha256=EgfyHEF4LYe7GjK5TZTWbB3Ul4TvH08x8MSkG_Fi3Yk,7261
11
12
  remotedesktop/sharing.py,sha256=XgSCiBcb-r_i2RmPut3ifGS6YvE4AX9O5hr50ob2sXk,24313
12
13
  remotedesktop/tls.py,sha256=AGrBpDq1gdjQm7TZ74y2W5-RxyEcbsFDPlNdRvadaAk,3934
13
14
  remotedesktop/viewer.py,sha256=b0MGSVuhQKdrQM2cjxHstt_2Flgw6dZv1awAenL4Bys,6554
14
15
  remotedesktop/window_state.py,sha256=X47Bl_FrV5WMyVDA-PWShum9pt6TMrR0HhADuz-CqNw,1012
15
- remotedesktop-0.2.0.dist-info/METADATA,sha256=mYg-85BgtdLoWI7FLQMZmF7IMUmXBj_rC2cEHoEe2-M,5024
16
- remotedesktop-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
17
- remotedesktop-0.2.0.dist-info/entry_points.txt,sha256=MmHDIIsykHkOwLf_IbGweGDFA2BuITI8_lUXsMpihcg,112
18
- remotedesktop-0.2.0.dist-info/licenses/LICENSE,sha256=6rWgMfDohGLDuJ0UFOyLphM28Nn0fuN26MczIQldMkc,1067
19
- remotedesktop-0.2.0.dist-info/RECORD,,
16
+ remotedesktop-0.4.0.dist-info/METADATA,sha256=eXN_CFFwXAbg0J9jXzM3TDUnJWVOMAFSRfKtJ8WOaCU,5734
17
+ remotedesktop-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
18
+ remotedesktop-0.4.0.dist-info/entry_points.txt,sha256=MmHDIIsykHkOwLf_IbGweGDFA2BuITI8_lUXsMpihcg,112
19
+ remotedesktop-0.4.0.dist-info/licenses/LICENSE,sha256=6rWgMfDohGLDuJ0UFOyLphM28Nn0fuN26MczIQldMkc,1067
20
+ remotedesktop-0.4.0.dist-info/RECORD,,