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.
@@ -0,0 +1,6 @@
1
+ """Browser-based remote desktop server."""
2
+
3
+ from .config import Settings
4
+
5
+ __all__ = ["Settings", "__version__"]
6
+ __version__ = "0.1.1"
@@ -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()