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.
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/.github/workflows/ci.yml +6 -1
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/PKG-INFO +5 -1
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/readme.md +4 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/__init__.py +1 -1
- remotedesktop-0.3.0/src/remotedesktop/autostart.py +63 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/server.py +189 -174
- remotedesktop-0.3.0/tests/test_autostart.py +54 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_server_window.py +147 -130
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/.gitignore +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/CLAUDE.md +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/LICENSE +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/pyproject.toml +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/run_claude.bat +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/run_client.bat +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/run_server.bat +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/scripts/make_coverage_badge.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/scripts/make_venv.bat +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/client.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/clipboard.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/config.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/db.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/discovery.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/input_injection.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/inventory.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/protocol.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/sharing.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/tls.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/viewer.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/src/remotedesktop/window_state.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/conftest.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_client_window.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_clipboard.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_config.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_discovery.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_input.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_input_injection.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_inventory.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_protocol.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_sharing.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_smoke.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_tls.py +0 -0
- {remotedesktop-0.2.0 → remotedesktop-0.3.0}/tests/test_window_state.py +0 -0
- {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
|
-
|
|
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.
|
|
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).
|
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
from remotedesktop
|
|
24
|
-
from remotedesktop.
|
|
25
|
-
from remotedesktop.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
self.
|
|
49
|
-
self.
|
|
50
|
-
|
|
51
|
-
self.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
self.
|
|
59
|
-
self.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
self.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
self.
|
|
85
|
-
self.share_server
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"
|
|
144
|
-
|
|
145
|
-
"
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
8
|
-
from remotedesktop.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
from
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
window.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
window.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
port
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
window.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
client
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert "
|
|
102
|
-
finally:
|
|
103
|
-
client.close()
|
|
104
|
-
window.close()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def
|
|
108
|
-
monkeypatch.setattr(
|
|
109
|
-
QMessageBox, "question", staticmethod(lambda *a, **k: QMessageBox.StandardButton.
|
|
110
|
-
)
|
|
111
|
-
window = make_window(credentials, tmp_path)
|
|
112
|
-
client = make_client(tmp_path)
|
|
113
|
-
|
|
114
|
-
client.
|
|
115
|
-
client.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|