gui-remote-controll 0.1.1__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.
- gui_remote_controll/__init__.py +6 -0
- gui_remote_controll/__main__.py +191 -0
- gui_remote_controll/app.py +439 -0
- gui_remote_controll/auth.py +95 -0
- gui_remote_controll/config.py +49 -0
- gui_remote_controll/desktop.py +310 -0
- gui_remote_controll/protocol.py +113 -0
- gui_remote_controll/static/app.css +492 -0
- gui_remote_controll/static/app.js +701 -0
- gui_remote_controll/static/auth.css +120 -0
- gui_remote_controll/static/auth.html +26 -0
- gui_remote_controll/static/auth.js +15 -0
- gui_remote_controll/static/index.html +79 -0
- gui_remote_controll-0.1.1.dist-info/METADATA +104 -0
- gui_remote_controll-0.1.1.dist-info/RECORD +18 -0
- gui_remote_controll-0.1.1.dist-info/WHEEL +4 -0
- gui_remote_controll-0.1.1.dist-info/entry_points.txt +3 -0
- gui_remote_controll-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import uvicorn
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .app import create_app
|
|
15
|
+
from .config import Settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="gui-remote-controll",
|
|
21
|
+
description="Share and control this desktop from a web browser.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--host", default="0.0.0.0", help="Server bind host.")
|
|
24
|
+
parser.add_argument("--port", type=int, default=8000, help="Server port.")
|
|
25
|
+
parser.add_argument("--pin", help="Require this PIN before clients can use the server.")
|
|
26
|
+
parser.add_argument("--fps", type=int, default=10, help="Maximum frame rate from 1 to 30.")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--jpeg-quality",
|
|
29
|
+
type=int,
|
|
30
|
+
default=80,
|
|
31
|
+
help="JPEG quality from 20 to 95.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--monitor",
|
|
35
|
+
type=int,
|
|
36
|
+
default=1,
|
|
37
|
+
help="Initial display index. Use 0 for the combined virtual desktop.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--view-only",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Stream the desktop without accepting input events.",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--no-clipboard",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="Disable remote clipboard synchronization.",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--no-cursor",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Do not include the system cursor in Linux/X11 frames.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--max-clients",
|
|
56
|
+
type=int,
|
|
57
|
+
default=4,
|
|
58
|
+
help="Maximum simultaneous WebSocket clients from 1 to 32.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--trusted-origin",
|
|
62
|
+
action="append",
|
|
63
|
+
default=[],
|
|
64
|
+
metavar="ORIGIN",
|
|
65
|
+
help="Additional allowed browser origin. May be repeated.",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument("--tls-certfile", type=Path, help="TLS certificate file.")
|
|
68
|
+
parser.add_argument("--tls-keyfile", type=Path, help="TLS private key file.")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--open-browser",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="Open the local control URL after the server starts.",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--no-elevate",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Do not request administrator/root privileges before startup.",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--log-level",
|
|
81
|
+
default="info",
|
|
82
|
+
choices=("critical", "error", "warning", "info", "debug", "trace"),
|
|
83
|
+
help="Uvicorn log level.",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
86
|
+
parser.add_argument("--elevation-attempted", action="store_true", help=argparse.SUPPRESS)
|
|
87
|
+
return parser
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def settings_from_args(args: argparse.Namespace) -> Settings:
|
|
91
|
+
settings = Settings(
|
|
92
|
+
host=args.host,
|
|
93
|
+
port=args.port,
|
|
94
|
+
pin=args.pin,
|
|
95
|
+
fps=args.fps,
|
|
96
|
+
jpeg_quality=args.jpeg_quality,
|
|
97
|
+
monitor=args.monitor,
|
|
98
|
+
view_only=args.view_only,
|
|
99
|
+
clipboard_enabled=not args.no_clipboard,
|
|
100
|
+
capture_cursor=not args.no_cursor,
|
|
101
|
+
max_clients=args.max_clients,
|
|
102
|
+
trusted_origins=tuple(args.trusted_origin),
|
|
103
|
+
tls_enabled=bool(args.tls_certfile),
|
|
104
|
+
)
|
|
105
|
+
settings.validate()
|
|
106
|
+
return settings
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _launch_elevated(raw_args: Sequence[str]) -> int:
|
|
110
|
+
from py_admin_launch import AdminLaunchError, launch
|
|
111
|
+
|
|
112
|
+
command = [
|
|
113
|
+
sys.executable,
|
|
114
|
+
"-m",
|
|
115
|
+
"gui_remote_controll",
|
|
116
|
+
"--elevation-attempted",
|
|
117
|
+
*raw_args,
|
|
118
|
+
]
|
|
119
|
+
try:
|
|
120
|
+
result = launch(command, cwd=os.getcwd(), wait=True)
|
|
121
|
+
except AdminLaunchError as exc:
|
|
122
|
+
raise RuntimeError(f"Administrator launch failed: {exc}") from exc
|
|
123
|
+
return result.returncode if result.returncode is not None else 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _validate_tls(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
127
|
+
if bool(args.tls_certfile) != bool(args.tls_keyfile):
|
|
128
|
+
parser.error("--tls-certfile and --tls-keyfile must be provided together")
|
|
129
|
+
for value in (args.tls_certfile, args.tls_keyfile):
|
|
130
|
+
if value is not None and not value.is_file():
|
|
131
|
+
parser.error(f"file does not exist: {value}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _local_url(settings: Settings) -> str:
|
|
135
|
+
scheme = "https" if settings.tls_enabled else "http"
|
|
136
|
+
host = settings.host
|
|
137
|
+
if host in {"0.0.0.0", "::"}:
|
|
138
|
+
host = "127.0.0.1"
|
|
139
|
+
if ":" in host and not host.startswith("["):
|
|
140
|
+
host = f"[{host}]"
|
|
141
|
+
return f"{scheme}://{host}:{settings.port}"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
145
|
+
raw_args = list(sys.argv[1:] if argv is None else argv)
|
|
146
|
+
parser = build_parser()
|
|
147
|
+
args = parser.parse_args(raw_args)
|
|
148
|
+
_validate_tls(parser, args)
|
|
149
|
+
|
|
150
|
+
if not args.no_elevate and not args.elevation_attempted:
|
|
151
|
+
try:
|
|
152
|
+
raise SystemExit(_launch_elevated(raw_args))
|
|
153
|
+
except RuntimeError as exc:
|
|
154
|
+
parser.exit(1, f"{exc}\n")
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
settings = settings_from_args(args)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
parser.error(str(exc))
|
|
160
|
+
|
|
161
|
+
if settings.host in {"0.0.0.0", "::"} and settings.pin is None:
|
|
162
|
+
print(
|
|
163
|
+
"WARNING: remote control is exposed without a PIN. "
|
|
164
|
+
"Use --pin or bind to --host 127.0.0.1.",
|
|
165
|
+
file=sys.stderr,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
local_url = _local_url(settings)
|
|
169
|
+
if args.open_browser:
|
|
170
|
+
timer = threading.Timer(0.8, webbrowser.open, args=(local_url,))
|
|
171
|
+
timer.daemon = True
|
|
172
|
+
timer.start()
|
|
173
|
+
|
|
174
|
+
print(f"GUI Remote Controll {__version__}: {local_url}")
|
|
175
|
+
uvicorn.run(
|
|
176
|
+
create_app(settings),
|
|
177
|
+
host=settings.host,
|
|
178
|
+
port=settings.port,
|
|
179
|
+
log_level=args.log_level,
|
|
180
|
+
ws="auto",
|
|
181
|
+
ws_max_size=settings.max_message_bytes,
|
|
182
|
+
ws_ping_interval=20.0,
|
|
183
|
+
ws_ping_timeout=20.0,
|
|
184
|
+
timeout_graceful_shutdown=3,
|
|
185
|
+
ssl_certfile=str(args.tls_certfile) if args.tls_certfile else None,
|
|
186
|
+
ssl_keyfile=str(args.tls_keyfile) if args.tls_keyfile else None,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
main()
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import platform
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, Request, WebSocket
|
|
15
|
+
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
16
|
+
from starlette.websockets import WebSocketDisconnect
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .auth import LoginLimiter, PinAuth
|
|
20
|
+
from .config import Settings
|
|
21
|
+
from .desktop import DesktopBackend, DesktopUnavailableError, Frame, Screen
|
|
22
|
+
from .protocol import ProtocolError, validate_client_message
|
|
23
|
+
|
|
24
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
25
|
+
STATIC_FILES = {
|
|
26
|
+
"app.css",
|
|
27
|
+
"app.js",
|
|
28
|
+
"auth.css",
|
|
29
|
+
"auth.js",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Backend(Protocol):
|
|
34
|
+
def initialize(self) -> None: ...
|
|
35
|
+
|
|
36
|
+
def list_screens(self) -> tuple[Screen, ...]: ...
|
|
37
|
+
|
|
38
|
+
def capture(self, monitor_index: int) -> Frame: ...
|
|
39
|
+
|
|
40
|
+
def execute(self, message: dict[str, Any], screen: Screen) -> None: ...
|
|
41
|
+
|
|
42
|
+
def release_inputs(self, keys: set[str], buttons: set[str]) -> None: ...
|
|
43
|
+
|
|
44
|
+
def clipboard_get(self) -> str: ...
|
|
45
|
+
|
|
46
|
+
def clipboard_set(self, text: str) -> None: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BackendManager:
|
|
50
|
+
def __init__(self, settings: Settings, factory: Callable[[Settings], Backend]) -> None:
|
|
51
|
+
self.settings = settings
|
|
52
|
+
self.factory = factory
|
|
53
|
+
self._backend: Backend | None = None
|
|
54
|
+
self._lock = asyncio.Lock()
|
|
55
|
+
|
|
56
|
+
async def get(self) -> Backend:
|
|
57
|
+
async with self._lock:
|
|
58
|
+
if self._backend is None:
|
|
59
|
+
backend = self.factory(self.settings)
|
|
60
|
+
await asyncio.to_thread(backend.initialize)
|
|
61
|
+
self._backend = backend
|
|
62
|
+
return self._backend
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConnectionGate:
|
|
66
|
+
def __init__(self, maximum: int) -> None:
|
|
67
|
+
self.maximum = maximum
|
|
68
|
+
self.active = 0
|
|
69
|
+
self._lock = asyncio.Lock()
|
|
70
|
+
|
|
71
|
+
async def enter(self) -> bool:
|
|
72
|
+
async with self._lock:
|
|
73
|
+
if self.active >= self.maximum:
|
|
74
|
+
return False
|
|
75
|
+
self.active += 1
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
async def leave(self) -> None:
|
|
79
|
+
async with self._lock:
|
|
80
|
+
self.active = max(0, self.active - 1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True)
|
|
84
|
+
class ClientState:
|
|
85
|
+
screens: tuple[Screen, ...]
|
|
86
|
+
screen: Screen
|
|
87
|
+
keys: set[str] = field(default_factory=set)
|
|
88
|
+
buttons: set[str] = field(default_factory=set)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_app(
|
|
92
|
+
settings: Settings | None = None,
|
|
93
|
+
*,
|
|
94
|
+
backend_factory: Callable[[Settings], Backend] = DesktopBackend,
|
|
95
|
+
) -> FastAPI:
|
|
96
|
+
runtime = settings or Settings()
|
|
97
|
+
runtime.validate()
|
|
98
|
+
auth = PinAuth(runtime)
|
|
99
|
+
limiter = LoginLimiter(runtime.login_attempts, runtime.login_window_seconds)
|
|
100
|
+
backend_manager = BackendManager(runtime, backend_factory)
|
|
101
|
+
gate = ConnectionGate(runtime.max_clients)
|
|
102
|
+
|
|
103
|
+
app = FastAPI(
|
|
104
|
+
title="GUI Remote Controll",
|
|
105
|
+
version=__version__,
|
|
106
|
+
docs_url=None,
|
|
107
|
+
redoc_url=None,
|
|
108
|
+
openapi_url=None,
|
|
109
|
+
)
|
|
110
|
+
app.state.settings = runtime
|
|
111
|
+
app.state.auth = auth
|
|
112
|
+
app.state.backend_manager = backend_manager
|
|
113
|
+
app.state.connection_gate = gate
|
|
114
|
+
|
|
115
|
+
@app.middleware("http")
|
|
116
|
+
async def security_headers(request: Request, call_next: Callable[..., Any]) -> Response:
|
|
117
|
+
response = await call_next(request)
|
|
118
|
+
response.headers["Content-Security-Policy"] = (
|
|
119
|
+
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
|
120
|
+
"form-action 'self'; img-src 'self' blob: data:; "
|
|
121
|
+
"connect-src 'self' ws: wss:; script-src 'self'; style-src 'self'"
|
|
122
|
+
)
|
|
123
|
+
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
|
124
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
125
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
126
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
127
|
+
if request.url.path in {"/", "/auth"}:
|
|
128
|
+
response.headers["Cache-Control"] = "no-store"
|
|
129
|
+
return response
|
|
130
|
+
|
|
131
|
+
@app.get("/healthz")
|
|
132
|
+
async def health() -> JSONResponse:
|
|
133
|
+
return JSONResponse({"status": "ok", "version": __version__})
|
|
134
|
+
|
|
135
|
+
@app.get("/")
|
|
136
|
+
async def index(request: Request) -> Response:
|
|
137
|
+
if not auth.is_allowed(request):
|
|
138
|
+
return RedirectResponse("/auth?next=%2F", status_code=303)
|
|
139
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
140
|
+
|
|
141
|
+
@app.get("/auth")
|
|
142
|
+
async def auth_page(request: Request) -> Response:
|
|
143
|
+
if not auth.enabled or auth.is_allowed(request):
|
|
144
|
+
return RedirectResponse("/", status_code=303)
|
|
145
|
+
return FileResponse(STATIC_DIR / "auth.html")
|
|
146
|
+
|
|
147
|
+
@app.post("/auth")
|
|
148
|
+
async def authenticate(request: Request) -> Response:
|
|
149
|
+
if not auth.enabled:
|
|
150
|
+
return RedirectResponse("/", status_code=303)
|
|
151
|
+
client_key = request.client.host if request.client else "unknown"
|
|
152
|
+
next_url = _safe_next_url(request.query_params.get("next", "/"))
|
|
153
|
+
if not limiter.is_allowed(client_key):
|
|
154
|
+
query = urlencode({"error": "rate", "next": next_url})
|
|
155
|
+
return RedirectResponse(f"/auth?{query}", status_code=303)
|
|
156
|
+
body = await request.body()
|
|
157
|
+
if len(body) > 4096:
|
|
158
|
+
return Response(status_code=413)
|
|
159
|
+
fields = parse_qs(body.decode("utf-8", errors="replace"), keep_blank_values=True)
|
|
160
|
+
pin = fields.get("pin", [""])[0]
|
|
161
|
+
if not auth.verify_pin(pin):
|
|
162
|
+
limiter.record_failure(client_key)
|
|
163
|
+
query = urlencode({"error": "invalid", "next": next_url})
|
|
164
|
+
return RedirectResponse(f"/auth?{query}", status_code=303)
|
|
165
|
+
limiter.clear(client_key)
|
|
166
|
+
response = RedirectResponse(next_url, status_code=303)
|
|
167
|
+
auth.set_cookie(response)
|
|
168
|
+
return response
|
|
169
|
+
|
|
170
|
+
@app.post("/logout")
|
|
171
|
+
async def logout() -> Response:
|
|
172
|
+
response = RedirectResponse("/auth", status_code=303)
|
|
173
|
+
auth.clear_cookie(response)
|
|
174
|
+
return response
|
|
175
|
+
|
|
176
|
+
@app.get("/api/status")
|
|
177
|
+
async def status(request: Request) -> Response:
|
|
178
|
+
if not auth.is_allowed(request):
|
|
179
|
+
return JSONResponse({"detail": "PIN required."}, status_code=401)
|
|
180
|
+
return JSONResponse(
|
|
181
|
+
{
|
|
182
|
+
"version": __version__,
|
|
183
|
+
"platform": platform.system(),
|
|
184
|
+
"viewOnly": runtime.view_only,
|
|
185
|
+
"clipboard": runtime.clipboard_enabled,
|
|
186
|
+
"clients": gate.active,
|
|
187
|
+
"maxClients": runtime.max_clients,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@app.get("/static/{filename}")
|
|
192
|
+
async def static_file(filename: str) -> Response:
|
|
193
|
+
if filename not in STATIC_FILES:
|
|
194
|
+
return Response(status_code=404)
|
|
195
|
+
return FileResponse(
|
|
196
|
+
STATIC_DIR / filename,
|
|
197
|
+
headers={"Cache-Control": "public, max-age=3600"},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
@app.websocket("/ws")
|
|
201
|
+
async def remote_socket(websocket: WebSocket) -> None:
|
|
202
|
+
if not auth.is_allowed(websocket):
|
|
203
|
+
await websocket.close(code=4401, reason="PIN required")
|
|
204
|
+
return
|
|
205
|
+
if not _origin_allowed(websocket, runtime.trusted_origins):
|
|
206
|
+
await websocket.close(code=4403, reason="Origin not allowed")
|
|
207
|
+
return
|
|
208
|
+
if not await gate.enter():
|
|
209
|
+
await websocket.close(code=4429, reason="Too many clients")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
await websocket.accept()
|
|
213
|
+
backend: Backend | None = None
|
|
214
|
+
state: ClientState | None = None
|
|
215
|
+
try:
|
|
216
|
+
try:
|
|
217
|
+
backend = await backend_manager.get()
|
|
218
|
+
screens = await asyncio.to_thread(backend.list_screens)
|
|
219
|
+
screen = _select_screen(screens, runtime.monitor)
|
|
220
|
+
state = ClientState(screens=screens, screen=screen)
|
|
221
|
+
except DesktopUnavailableError as exc:
|
|
222
|
+
await websocket.send_json({"type": "fatal", "message": str(exc)})
|
|
223
|
+
await websocket.close(code=4500, reason="Desktop unavailable")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
send_lock = asyncio.Lock()
|
|
227
|
+
await websocket.send_json(
|
|
228
|
+
{
|
|
229
|
+
"type": "hello",
|
|
230
|
+
"protocol": 1,
|
|
231
|
+
"platform": platform.system(),
|
|
232
|
+
"viewOnly": runtime.view_only,
|
|
233
|
+
"clipboard": runtime.clipboard_enabled,
|
|
234
|
+
"screens": [item.as_message() for item in screens],
|
|
235
|
+
"monitor": screen.index,
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
stream_task = asyncio.create_task(
|
|
239
|
+
_stream_frames(websocket, backend, state, runtime, send_lock)
|
|
240
|
+
)
|
|
241
|
+
receive_task = asyncio.create_task(
|
|
242
|
+
_receive_messages(websocket, backend, state, runtime, send_lock)
|
|
243
|
+
)
|
|
244
|
+
done, pending = await asyncio.wait(
|
|
245
|
+
{stream_task, receive_task}, return_when=asyncio.FIRST_COMPLETED
|
|
246
|
+
)
|
|
247
|
+
for task in pending:
|
|
248
|
+
task.cancel()
|
|
249
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
250
|
+
for task in done:
|
|
251
|
+
with contextlib.suppress(WebSocketDisconnect, asyncio.CancelledError):
|
|
252
|
+
task.result()
|
|
253
|
+
except WebSocketDisconnect:
|
|
254
|
+
pass
|
|
255
|
+
finally:
|
|
256
|
+
if backend is not None and state is not None:
|
|
257
|
+
await asyncio.to_thread(backend.release_inputs, state.keys, state.buttons)
|
|
258
|
+
await gate.leave()
|
|
259
|
+
with contextlib.suppress(RuntimeError):
|
|
260
|
+
await websocket.close()
|
|
261
|
+
|
|
262
|
+
return app
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def _stream_frames(
|
|
266
|
+
websocket: WebSocket,
|
|
267
|
+
backend: Backend,
|
|
268
|
+
state: ClientState,
|
|
269
|
+
settings: Settings,
|
|
270
|
+
send_lock: asyncio.Lock,
|
|
271
|
+
) -> None:
|
|
272
|
+
last_digest = b""
|
|
273
|
+
last_monitor = -1
|
|
274
|
+
while True:
|
|
275
|
+
started = asyncio.get_running_loop().time()
|
|
276
|
+
try:
|
|
277
|
+
frame = await asyncio.to_thread(backend.capture, state.screen.index)
|
|
278
|
+
except DesktopUnavailableError as exc:
|
|
279
|
+
async with send_lock:
|
|
280
|
+
await websocket.send_json({"type": "fatal", "message": str(exc)})
|
|
281
|
+
return
|
|
282
|
+
digest = hashlib.blake2b(frame.data, digest_size=8).digest()
|
|
283
|
+
async with send_lock:
|
|
284
|
+
if frame.screen.index != last_monitor:
|
|
285
|
+
await websocket.send_json({"type": "screen", **frame.screen.as_message()})
|
|
286
|
+
last_monitor = frame.screen.index
|
|
287
|
+
if digest != last_digest:
|
|
288
|
+
await websocket.send_bytes(frame.data)
|
|
289
|
+
last_digest = digest
|
|
290
|
+
elapsed = asyncio.get_running_loop().time() - started
|
|
291
|
+
await asyncio.sleep(max(0.001, settings.frame_interval - elapsed))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
async def _receive_messages(
|
|
295
|
+
websocket: WebSocket,
|
|
296
|
+
backend: Backend,
|
|
297
|
+
state: ClientState,
|
|
298
|
+
settings: Settings,
|
|
299
|
+
send_lock: asyncio.Lock,
|
|
300
|
+
) -> None:
|
|
301
|
+
while True:
|
|
302
|
+
raw = await websocket.receive_text()
|
|
303
|
+
if len(raw.encode("utf-8")) > settings.max_message_bytes:
|
|
304
|
+
await _send_error(websocket, send_lock, "Message is too large.")
|
|
305
|
+
continue
|
|
306
|
+
try:
|
|
307
|
+
message = validate_client_message(
|
|
308
|
+
json.loads(raw), max_text_chars=settings.max_clipboard_chars
|
|
309
|
+
)
|
|
310
|
+
except (json.JSONDecodeError, ProtocolError) as exc:
|
|
311
|
+
await _send_error(websocket, send_lock, str(exc))
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
message_type = message["type"]
|
|
315
|
+
if message_type == "ping":
|
|
316
|
+
async with send_lock:
|
|
317
|
+
await websocket.send_json({"type": "pong"})
|
|
318
|
+
continue
|
|
319
|
+
if message_type == "monitor":
|
|
320
|
+
selected = next(
|
|
321
|
+
(item for item in state.screens if item.index == message["index"]),
|
|
322
|
+
None,
|
|
323
|
+
)
|
|
324
|
+
if selected is None:
|
|
325
|
+
await _send_error(websocket, send_lock, "The selected monitor is unavailable.")
|
|
326
|
+
else:
|
|
327
|
+
state.screen = selected
|
|
328
|
+
continue
|
|
329
|
+
if message_type == "clipboard_get":
|
|
330
|
+
if not settings.clipboard_enabled:
|
|
331
|
+
await _send_error(websocket, send_lock, "Clipboard synchronization is disabled.")
|
|
332
|
+
continue
|
|
333
|
+
try:
|
|
334
|
+
text = await asyncio.to_thread(backend.clipboard_get)
|
|
335
|
+
digest = _clipboard_digest(text)
|
|
336
|
+
async with send_lock:
|
|
337
|
+
if message["knownDigest"] == digest:
|
|
338
|
+
await websocket.send_json(
|
|
339
|
+
{"type": "clipboard_unchanged", "digest": digest}
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
await websocket.send_json(
|
|
343
|
+
{"type": "clipboard", "text": text, "digest": digest}
|
|
344
|
+
)
|
|
345
|
+
except DesktopUnavailableError as exc:
|
|
346
|
+
await _send_error(websocket, send_lock, str(exc))
|
|
347
|
+
continue
|
|
348
|
+
if message_type == "clipboard_set":
|
|
349
|
+
if not settings.clipboard_enabled:
|
|
350
|
+
await _send_error(websocket, send_lock, "Clipboard synchronization is disabled.")
|
|
351
|
+
continue
|
|
352
|
+
try:
|
|
353
|
+
await asyncio.to_thread(backend.clipboard_set, message["text"])
|
|
354
|
+
async with send_lock:
|
|
355
|
+
await websocket.send_json(
|
|
356
|
+
{
|
|
357
|
+
"type": "clipboard_saved",
|
|
358
|
+
"digest": _clipboard_digest(message["text"]),
|
|
359
|
+
"requestId": message["requestId"],
|
|
360
|
+
}
|
|
361
|
+
)
|
|
362
|
+
except DesktopUnavailableError as exc:
|
|
363
|
+
await _send_error(
|
|
364
|
+
websocket,
|
|
365
|
+
send_lock,
|
|
366
|
+
str(exc),
|
|
367
|
+
request_id=message["requestId"],
|
|
368
|
+
)
|
|
369
|
+
continue
|
|
370
|
+
if settings.view_only:
|
|
371
|
+
continue
|
|
372
|
+
if message_type == "key":
|
|
373
|
+
if message["event"] == "down":
|
|
374
|
+
state.keys.add(message["key"])
|
|
375
|
+
if message["repeat"]:
|
|
376
|
+
continue
|
|
377
|
+
else:
|
|
378
|
+
state.keys.discard(message["key"])
|
|
379
|
+
elif message_type == "pointer" and message["event"] != "move":
|
|
380
|
+
if message["event"] == "down":
|
|
381
|
+
state.buttons.add(message["button"])
|
|
382
|
+
else:
|
|
383
|
+
state.buttons.discard(message["button"])
|
|
384
|
+
try:
|
|
385
|
+
await asyncio.to_thread(backend.execute, message, state.screen)
|
|
386
|
+
except DesktopUnavailableError as exc:
|
|
387
|
+
await _send_error(websocket, send_lock, str(exc))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
async def _send_error(
|
|
391
|
+
websocket: WebSocket,
|
|
392
|
+
lock: asyncio.Lock,
|
|
393
|
+
message: str,
|
|
394
|
+
*,
|
|
395
|
+
request_id: str = "",
|
|
396
|
+
) -> None:
|
|
397
|
+
payload = {"type": "error", "message": message}
|
|
398
|
+
if request_id:
|
|
399
|
+
payload["requestId"] = request_id
|
|
400
|
+
async with lock:
|
|
401
|
+
await websocket.send_json(payload)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _clipboard_digest(text: str) -> str:
|
|
405
|
+
return hashlib.blake2b(text.encode("utf-8"), digest_size=8).hexdigest()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _select_screen(screens: tuple[Screen, ...], preferred: int) -> Screen:
|
|
409
|
+
selected = next((item for item in screens if item.index == preferred), None)
|
|
410
|
+
if selected is not None:
|
|
411
|
+
return selected
|
|
412
|
+
physical = next((item for item in screens if item.index > 0), None)
|
|
413
|
+
if physical is not None:
|
|
414
|
+
return physical
|
|
415
|
+
if screens:
|
|
416
|
+
return screens[0]
|
|
417
|
+
raise DesktopUnavailableError("No display is available.")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _safe_next_url(value: str) -> str:
|
|
421
|
+
if not value.startswith("/") or value.startswith("//"):
|
|
422
|
+
return "/"
|
|
423
|
+
return value[:2048]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _origin_allowed(websocket: WebSocket, trusted_origins: tuple[str, ...]) -> bool:
|
|
427
|
+
origin = websocket.headers.get("origin")
|
|
428
|
+
if not origin:
|
|
429
|
+
return True
|
|
430
|
+
parsed = urlsplit(origin)
|
|
431
|
+
if parsed.scheme not in {"http", "https"}:
|
|
432
|
+
return False
|
|
433
|
+
if parsed.netloc.lower() == websocket.headers.get("host", "").lower():
|
|
434
|
+
return True
|
|
435
|
+
normalized = origin.rstrip("/").lower()
|
|
436
|
+
return normalized in {item.rstrip("/").lower() for item in trusted_origins}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
app = create_app()
|