remotedesktop 0.2.0__tar.gz → 0.3.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.2.0 → remotedesktop-0.3.0}/.github/workflows/ci.yml +6 -1
  2. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/PKG-INFO +5 -1
  3. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/readme.md +4 -0
  4. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/__init__.py +1 -1
  5. remotedesktop-0.3.0/src/remotedesktop/autostart.py +63 -0
  6. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/server.py +189 -174
  7. remotedesktop-0.3.0/tests/test_autostart.py +54 -0
  8. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_server_window.py +147 -130
  9. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/.gitignore +0 -0
  10. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/CLAUDE.md +0 -0
  11. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/LICENSE +0 -0
  12. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/pyproject.toml +0 -0
  13. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/run_claude.bat +0 -0
  14. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/run_client.bat +0 -0
  15. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/run_server.bat +0 -0
  16. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/scripts/make_coverage_badge.py +0 -0
  17. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/scripts/make_venv.bat +0 -0
  18. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/client.py +0 -0
  19. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/clipboard.py +0 -0
  20. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/config.py +0 -0
  21. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/db.py +0 -0
  22. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/discovery.py +0 -0
  23. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/input_injection.py +0 -0
  24. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/inventory.py +0 -0
  25. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/protocol.py +0 -0
  26. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/sharing.py +0 -0
  27. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/tls.py +0 -0
  28. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/viewer.py +0 -0
  29. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/window_state.py +0 -0
  30. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/conftest.py +0 -0
  31. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_client_window.py +0 -0
  32. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_clipboard.py +0 -0
  33. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_config.py +0 -0
  34. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_discovery.py +0 -0
  35. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_input.py +0 -0
  36. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_input_injection.py +0 -0
  37. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_inventory.py +0 -0
  38. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_protocol.py +0 -0
  39. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_sharing.py +0 -0
  40. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_smoke.py +0 -0
  41. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_tls.py +0 -0
  42. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_window_state.py +0 -0
  43. {remotedesktop-0.2.0 → remotedesktop-0.3.0}/uv.lock +0 -0
@@ -43,6 +43,11 @@ jobs:
43
43
  git config user.name "github-actions[bot]"
44
44
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
45
45
  $blob = (git hash-object -w badges/coverage.svg).Trim()
46
- $tree = ("100644 blob $blob`tcoverage.svg" | git mktree).Trim()
46
+ # Build the tree via a temp index, not `... | git mktree`: PowerShell
47
+ # pipes CRLF to native stdin, which puts a literal \r in the filename.
48
+ $env:GIT_INDEX_FILE = "$env:RUNNER_TEMP\badge-index"
49
+ git update-index --add --cacheinfo "100644,$blob,coverage.svg"
50
+ $tree = (git write-tree).Trim()
51
+ Remove-Item Env:\GIT_INDEX_FILE
47
52
  $commit = (git commit-tree $tree -m "Update coverage badge").Trim()
48
53
  git push --force origin "${commit}:refs/heads/badges"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remotedesktop
3
- Version: 0.2.0
3
+ Version: 0.3.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).
@@ -9,6 +9,10 @@
9
9
  A Python client/server application that provides remote desktop for Windows
10
10
  computers on the same LAN, with autodiscovery of servers.
11
11
 
12
+ The server can optionally start automatically when you log in to Windows —
13
+ a checkbox on its Status tab registers it under the per-user Run key (no
14
+ administrator rights needed).
15
+
12
16
  Connections are made to the desktop screen, keyboard, mouse, and clipboard.
13
17
  Other connections are not provided, such as shared drives, devices, or
14
18
  multimedia (e.g., audio).
@@ -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.3.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
@@ -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()
@@ -0,0 +1,54 @@
1
+ """Autostart tests run against an isolated registry key, never the real
2
+ Run key, so they cannot change what actually starts at login."""
3
+
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ from remotedesktop.autostart import Autostart, server_command
9
+
10
+ pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows registry")
11
+
12
+ _TEST_KEY = r"Software\remotedesktop-tests\Run"
13
+
14
+
15
+ @pytest.fixture
16
+ def autostart():
17
+ instance = Autostart(key_path=_TEST_KEY, value_name="test-server")
18
+ yield instance
19
+ instance.set_enabled(False)
20
+
21
+
22
+ def test_enable_disable_round_trip(autostart):
23
+ assert not autostart.is_enabled()
24
+ autostart.set_enabled(True)
25
+ assert autostart.is_enabled()
26
+ autostart.set_enabled(False)
27
+ assert not autostart.is_enabled()
28
+
29
+
30
+ def test_disable_when_not_registered_is_a_noop(autostart):
31
+ autostart.set_enabled(False)
32
+ assert not autostart.is_enabled()
33
+
34
+
35
+ def test_registered_command_is_stored(autostart):
36
+ import winreg
37
+
38
+ autostart.set_enabled(True)
39
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _TEST_KEY) as key:
40
+ value, kind = winreg.QueryValueEx(key, "test-server")
41
+ assert kind == winreg.REG_SZ
42
+ assert value == server_command()
43
+
44
+
45
+ def test_server_command_is_quoted_and_launchable():
46
+ command = server_command()
47
+ assert command.startswith('"')
48
+ assert "remotedesktop" in command
49
+
50
+
51
+ def test_unavailable_autostart_is_inert(autostart):
52
+ autostart.available = False
53
+ autostart.set_enabled(True)
54
+ assert not autostart.is_enabled()
@@ -1,130 +1,147 @@
1
- import socket
2
-
3
- from PySide6.QtNetwork import QHostAddress, QTcpServer
4
- from PySide6.QtWidgets import QMessageBox
5
-
6
- from remotedesktop import db
7
- from remotedesktop.config import PairedClients
8
- from remotedesktop.server import ServerWindow
9
-
10
- from test_discovery import free_udp_port
11
- from test_sharing import CLIENT_ID, make_client, pump
12
-
13
-
14
- def make_window(credentials, tmp_path, *, discovery_port=None, connect_port=0):
15
- connection = db.connect(tmp_path / "server.db")
16
- return ServerWindow(
17
- discovery_port=discovery_port if discovery_port is not None else free_udp_port(),
18
- connect_port=connect_port,
19
- paired=PairedClients(connection),
20
- credentials=credentials,
21
- connection=connection,
22
- )
23
-
24
-
25
- def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
26
- window = make_window(credentials, tmp_path)
27
- try:
28
- assert window._listening
29
- assert window._discoverable
30
- assert "Discoverable on this LAN" in window._summary.text()
31
- assert "Not sharing" in window._summary.text()
32
- finally:
33
- window.close()
34
-
35
-
36
- def test_window_reports_discovery_port_conflict(qapp, credentials, tmp_path):
37
- blocker = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
38
- blocker.bind(("", 0))
39
- port = blocker.getsockname()[1]
40
- try:
41
- window = make_window(credentials, tmp_path, discovery_port=port)
42
- try:
43
- assert window._listening
44
- assert not window._discoverable
45
- assert "Not discoverable" in window._summary.text()
46
- assert "Discovery unavailable" in window.connection_log.toPlainText()
47
- finally:
48
- window.close()
49
- finally:
50
- blocker.close()
51
-
52
-
53
- def test_window_reports_connect_port_conflict(qapp, credentials, tmp_path):
54
- # Block the port the same way the app binds it (dual-stack Any via Qt);
55
- # a raw IPv4-only socket would not conflict with Qt's IPv6 Any binding.
56
- blocker = QTcpServer()
57
- assert blocker.listen(QHostAddress.SpecialAddress.Any, 0)
58
- port = blocker.serverPort()
59
- try:
60
- window = make_window(credentials, tmp_path, connect_port=port)
61
- try:
62
- assert not window._listening
63
- assert window.responder is None
64
- assert "Cannot share" in window._summary.text()
65
- finally:
66
- window.close()
67
- finally:
68
- blocker.close()
69
-
70
-
71
- def test_approval_prompt_pairs_client_and_updates_summary(qapp, credentials, tmp_path, monkeypatch):
72
- monkeypatch.setattr(
73
- QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
74
- )
75
- window = make_window(credentials, tmp_path)
76
- client = make_client(tmp_path)
77
- names = []
78
- client.connected.connect(names.append)
79
- client.connect_to("127.0.0.1", window.share_server.port)
80
- try:
81
- pump(qapp, lambda: names)
82
- pump(qapp, lambda: "Sharing this desktop with 1 viewer(s)" in window._summary.text())
83
- # The inventory tab tracked the pairing via peer events.
84
- assert window.inventory._peers[CLIENT_ID].state == "connected (paired)"
85
- finally:
86
- client.close()
87
- window.close()
88
-
89
-
90
- def test_refused_approval_denies_client(qapp, credentials, tmp_path, monkeypatch):
91
- monkeypatch.setattr(
92
- QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)
93
- )
94
- window = make_window(credentials, tmp_path)
95
- client = make_client(tmp_path)
96
- denials = []
97
- client.denied.connect(denials.append)
98
- client.connect_to("127.0.0.1", window.share_server.port)
99
- try:
100
- pump(qapp, lambda: denials)
101
- assert "refused" in denials[0]
102
- finally:
103
- client.close()
104
- window.close()
105
-
106
-
107
- def test_revoke_via_window_disconnects_client(qapp, credentials, tmp_path, monkeypatch):
108
- monkeypatch.setattr(
109
- QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
110
- )
111
- window = make_window(credentials, tmp_path)
112
- client = make_client(tmp_path)
113
- names, disconnected = [], []
114
- client.connected.connect(names.append)
115
- client.disconnected.connect(lambda: disconnected.append(True))
116
- client.connect_to("127.0.0.1", window.share_server.port)
117
- try:
118
- pump(qapp, lambda: names)
119
- window._revoke_client(CLIENT_ID)
120
- pump(qapp, lambda: disconnected)
121
- assert window.inventory._peers[CLIENT_ID].state == "revoked"
122
-
123
- # Answering "No" must not revoke anything.
124
- monkeypatch.setattr(
125
- QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)
126
- )
127
- window._revoke_client(CLIENT_ID)
128
- finally:
129
- client.close()
130
- window.close()
1
+ import socket
2
+
3
+ from PySide6.QtNetwork import QHostAddress, QTcpServer
4
+ from PySide6.QtWidgets import QMessageBox
5
+
6
+ from remotedesktop import db
7
+ from remotedesktop.autostart import Autostart
8
+ from remotedesktop.config import PairedClients
9
+ from remotedesktop.server import ServerWindow
10
+
11
+ from test_discovery import free_udp_port
12
+ from test_sharing import CLIENT_ID, make_client, pump
13
+
14
+ _TEST_AUTOSTART_KEY = r"Software\remotedesktop-tests\WindowRun"
15
+
16
+
17
+ def make_window(credentials, tmp_path, *, discovery_port=None, connect_port=0):
18
+ connection = db.connect(tmp_path / "server.db")
19
+ return ServerWindow(
20
+ discovery_port=discovery_port if discovery_port is not None else free_udp_port(),
21
+ connect_port=connect_port,
22
+ paired=PairedClients(connection),
23
+ credentials=credentials,
24
+ connection=connection,
25
+ autostart=Autostart(key_path=_TEST_AUTOSTART_KEY, value_name="window-test"),
26
+ )
27
+
28
+
29
+ def test_window_listens_and_is_discoverable(qapp, credentials, tmp_path):
30
+ window = make_window(credentials, tmp_path)
31
+ try:
32
+ assert window._listening
33
+ assert window._discoverable
34
+ assert "Discoverable on this LAN" in window._summary.text()
35
+ assert "Not sharing" in window._summary.text()
36
+ finally:
37
+ window.close()
38
+
39
+
40
+ def test_window_reports_discovery_port_conflict(qapp, credentials, tmp_path):
41
+ blocker = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
42
+ blocker.bind(("", 0))
43
+ port = blocker.getsockname()[1]
44
+ try:
45
+ window = make_window(credentials, tmp_path, discovery_port=port)
46
+ try:
47
+ assert window._listening
48
+ assert not window._discoverable
49
+ assert "Not discoverable" in window._summary.text()
50
+ assert "Discovery unavailable" in window.connection_log.toPlainText()
51
+ finally:
52
+ window.close()
53
+ finally:
54
+ blocker.close()
55
+
56
+
57
+ def test_window_reports_connect_port_conflict(qapp, credentials, tmp_path):
58
+ # Block the port the same way the app binds it (dual-stack Any via Qt);
59
+ # a raw IPv4-only socket would not conflict with Qt's IPv6 Any binding.
60
+ blocker = QTcpServer()
61
+ assert blocker.listen(QHostAddress.SpecialAddress.Any, 0)
62
+ port = blocker.serverPort()
63
+ try:
64
+ window = make_window(credentials, tmp_path, connect_port=port)
65
+ try:
66
+ assert not window._listening
67
+ assert window.responder is None
68
+ assert "Cannot share" in window._summary.text()
69
+ finally:
70
+ window.close()
71
+ finally:
72
+ blocker.close()
73
+
74
+
75
+ def test_autostart_checkbox_toggles_registration(qapp, credentials, tmp_path):
76
+ window = make_window(credentials, tmp_path)
77
+ try:
78
+ assert not window._autostart.is_enabled()
79
+ window.autostart_checkbox.setChecked(True)
80
+ assert window._autostart.is_enabled()
81
+ assert "start at login" in window.connection_log.toPlainText()
82
+ window.autostart_checkbox.setChecked(False)
83
+ assert not window._autostart.is_enabled()
84
+ finally:
85
+ window.close()
86
+
87
+
88
+ 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
+ )
92
+ window = make_window(credentials, tmp_path)
93
+ client = make_client(tmp_path)
94
+ names = []
95
+ client.connected.connect(names.append)
96
+ client.connect_to("127.0.0.1", window.share_server.port)
97
+ try:
98
+ pump(qapp, lambda: names)
99
+ pump(qapp, lambda: "Sharing this desktop with 1 viewer(s)" in window._summary.text())
100
+ # The inventory tab tracked the pairing via peer events.
101
+ assert window.inventory._peers[CLIENT_ID].state == "connected (paired)"
102
+ finally:
103
+ client.close()
104
+ window.close()
105
+
106
+
107
+ 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
+ )
111
+ window = make_window(credentials, tmp_path)
112
+ client = make_client(tmp_path)
113
+ denials = []
114
+ client.denied.connect(denials.append)
115
+ client.connect_to("127.0.0.1", window.share_server.port)
116
+ try:
117
+ pump(qapp, lambda: denials)
118
+ assert "refused" in denials[0]
119
+ finally:
120
+ client.close()
121
+ window.close()
122
+
123
+
124
+ def test_revoke_via_window_disconnects_client(qapp, credentials, tmp_path, monkeypatch):
125
+ monkeypatch.setattr(
126
+ QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.Yes)
127
+ )
128
+ window = make_window(credentials, tmp_path)
129
+ client = make_client(tmp_path)
130
+ names, disconnected = [], []
131
+ client.connected.connect(names.append)
132
+ client.disconnected.connect(lambda: disconnected.append(True))
133
+ client.connect_to("127.0.0.1", window.share_server.port)
134
+ try:
135
+ pump(qapp, lambda: names)
136
+ window._revoke_client(CLIENT_ID)
137
+ pump(qapp, lambda: disconnected)
138
+ assert window.inventory._peers[CLIENT_ID].state == "revoked"
139
+
140
+ # Answering "No" must not revoke anything.
141
+ monkeypatch.setattr(
142
+ QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.No)
143
+ )
144
+ window._revoke_client(CLIENT_ID)
145
+ finally:
146
+ client.close()
147
+ window.close()
File without changes
File without changes
File without changes
File without changes