├── LICENSE ├── README.md ├── client.py ├── host.py ├── key.py └── start.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 techlm77 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LinuxPlay – A Fast, Fully Open-Source Remote Desktop for Linux 2 | 3 | LinuxPlay is a lightweight, low-latency, fully open-source remote desktop solution designed specifically for Linux. It provides seamless video streaming and full keyboard/mouse control with low latency, making it a superior alternative to VNC and other traditional remote desktop solutions. LinuxPlay is 100% local and private – no accounts, no cloud dependency, just pure performance. 4 | 5 | ## Features 6 | 7 | - **Low-latency** using UDP multicast streaming 8 | - **Full keyboard and mouse support**, including function keys and modifiers 9 | - **Multi-monitor support** – Choose individual displays or view all at once 10 | - **Adaptive bitrate streaming** to optimize quality based on network conditions 11 | - **Hardware-accelerated encoding and decoding** for superior performance 12 | - **Drag-and-drop file transfer** for seamless file sharing 13 | - **Clipboard sharing** between the client and host 14 | 15 | ## Installation 16 | 17 | ```bash 18 | sudo apt-get update && sudo apt-get install -y python3-pip python3-pyqt5 ffmpeg xdotool xclip libgl1-mesa-dev mesa-utils && pip3 install av 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### Start the Host (Server) 24 | 25 | Run the following command on the remote machine (the one you want to control): 26 | 27 | ```bash 28 | python3 host.py --encoder vaapi --resolution 1600x900 --framerate 60 --audio enable --password password123 29 | ``` 30 | 31 | #### Available Options: 32 | | Option | Description | Default | 33 | |-----------------|---------------------------------------------------------|-------------| 34 | | `--encoder` | Video encoder: `nvenc`, `vaapi`, or `none` (CPU) | `none` | 35 | | `--resolution` | Capture resolution (e.g., `1920x1080`) | `1920x1080` | 36 | | `--framerate` | Capture framerate (e.g., `30`, `60`) | `30` | 37 | | `--bitrate` | Initial video bitrate (e.g., `8M`) | `8M` | 38 | | `--audio` | Enable or disable audio streaming (`enable`, `disable`) | `disable` | 39 | | `--adaptive` | Enable adaptive bitrate switching | Off | 40 | | `--password` | Set an optional password for control messages | None | 41 | 42 | --- 43 | 44 | ### Start the Client (Viewer) 45 | 46 | Run this command on the local machine (the one used to control the remote PC): 47 | 48 | ```bash 49 | python3 client.py --decoder nvdec --host_ip 192.168.1.123 --remote_resolution 1600x900 --audio enable --password password123 50 | ``` 51 | 52 | #### Available Options: 53 | | Option | Description | Default | 54 | |-----------------------|---------------------------------------------------------|-------------| 55 | | `--decoder` | Video decoder: `nvdec`, `vaapi`, or `none` (CPU) | `none` | 56 | | `--host_ip` | The IP address of the host machine | Required | 57 | | `--remote_resolution` | Remote screen resolution (e.g., `1600x900`) | `1920x1080` | 58 | | `--audio` | Enable or disable audio playback (`enable`, `disable`) | `disable` | 59 | | `--password` | Optional password for control events and handshake | None | 60 | 61 | ## Why No Wayland Support? 62 | 63 | LinuxPlay does **not** support Wayland due to the following reasons: 64 | 65 | - **FFmpeg does not support Wayland for screen capture.** Even if I wanted to, there is no reliable FFmpeg-based solution. 66 | - **GStreamer relies on PipeWire for screen capture, but PipeWire does not work on any of my systems, making it unusable.** 67 | - **KMS/DRM does not work for screen capture, even with direct display output.** 68 | 69 | I have extensively tested all these alternatives and none of them worked reliably, which is why LinuxPlay remains X11-only. 70 | 71 | ## Contribute 72 | 73 | LinuxPlay is fully open-source and welcomes contributions. Whether you want to improve performance, add features, or report bugs, your input is appreciated. 74 | 75 | ## License 76 | 77 | LinuxPlay is licensed under the MIT License. 78 | 79 | ## Future Plans 80 | 81 | - **Xbox/PlayStation Controller** 82 | 83 | ## Testing 84 | 85 | - **Full Encryption** – Implement TLS encryption for control messages and video streaming. 86 | - **Internet-Ready Security** – Enable secure remote connections with end-to-end encryption. 87 | 88 | ## Security Warning 89 | 90 | LinuxPlay can be used over the internet, but it currently does **not** include built-in encryption. It is recommended to use a **VPN, SSH tunnel, or firewall rules** to secure your connection if accessing remotely. Future versions will include built-in encryption for enhanced security. 91 | -------------------------------------------------------------------------------- /client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import av 4 | import argparse 5 | import logging 6 | import subprocess 7 | import socket 8 | import time 9 | import threading 10 | import os 11 | import numpy as np 12 | import ssl 13 | from PyQt5.QtWidgets import QApplication, QMainWindow, QMessageBox, QOpenGLWidget 14 | from PyQt5.QtCore import QThread, QTimer, pyqtSignal, Qt 15 | from PyQt5.QtGui import QSurfaceFormat 16 | import atexit 17 | from shutil import which 18 | from OpenGL.GL import * 19 | from OpenGL.GLUT import * 20 | from cryptography.fernet import Fernet 21 | 22 | security_key = None 23 | cipher = None 24 | ssl_context = None 25 | 26 | MOUSE_MOVE_THROTTLE = 0.005 27 | DEFAULT_UDP_PORT = 5000 28 | DEFAULT_RESOLUTION = "1920x1080" 29 | MULTICAST_IP = "239.0.0.1" 30 | CONTROL_PORT = 7000 31 | TCP_HANDSHAKE_PORT = 7001 32 | UDP_CLIPBOARD_PORT = 7002 33 | FILE_UPLOAD_PORT = 7003 34 | 35 | audio_proc = None 36 | 37 | def has_nvidia(): 38 | return which("nvidia-smi") is not None 39 | 40 | def has_vaapi(): 41 | return os.path.exists("/dev/dri/renderD128") 42 | 43 | def is_intel_cpu(): 44 | try: 45 | with open("/proc/cpuinfo", "r") as f: 46 | return "GenuineIntel" in f.read() 47 | except Exception: 48 | return False 49 | 50 | def secure_sendto(sock, message, addr): 51 | encrypted = cipher.encrypt(message.encode("utf-8")) 52 | sock.sendto(encrypted, addr) 53 | 54 | def secure_recvfrom(sock, bufsize): 55 | data, addr = sock.recvfrom(bufsize) 56 | try: 57 | decrypted = cipher.decrypt(data).decode("utf-8") 58 | except Exception as e: 59 | decrypted = "" 60 | return decrypted, addr 61 | 62 | def tcp_handshake_client(host_ip): 63 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 64 | try: 65 | logging.info("Connecting to host %s:%s for handshake", host_ip, TCP_HANDSHAKE_PORT) 66 | sock.connect((host_ip, TCP_HANDSHAKE_PORT)) 67 | secure_sock = ssl_context.wrap_socket(sock, server_hostname=host_ip) 68 | except Exception as e: 69 | logging.error("Handshake connection failed: %s", e) 70 | sock.close() 71 | return (False, None) 72 | handshake_msg = "HELLO" 73 | secure_sock.sendall(handshake_msg.encode("utf-8")) 74 | try: 75 | resp = secure_sock.recv(1024).decode("utf-8", errors="replace").strip() 76 | except Exception as e: 77 | logging.error("Failed to receive handshake response: %s", e) 78 | secure_sock.close() 79 | return (False, None) 80 | secure_sock.close() 81 | if resp.startswith("OK:"): 82 | logging.info("Handshake successful.") 83 | parts = resp.split(":", 2) 84 | if len(parts) >= 3: 85 | host_encoder = parts[1].strip() 86 | monitor_info = parts[2].strip() 87 | else: 88 | host_encoder = parts[1].strip() 89 | monitor_info = DEFAULT_RESOLUTION 90 | return (True, (host_encoder, monitor_info)) 91 | else: 92 | logging.error("Handshake failed. Response: %s", resp) 93 | return (False, None) 94 | 95 | class DecoderThread(QThread): 96 | frame_ready = pyqtSignal(object) 97 | def __init__(self, input_url, decoder_opts, window, parent=None): 98 | super().__init__(parent) 99 | self.input_url = input_url 100 | self.decoder_opts = decoder_opts 101 | self.window = window 102 | self.decoder_opts.setdefault("probesize", "32") 103 | self.decoder_opts.setdefault("analyzeduration", "0") 104 | self._running = True 105 | 106 | def run(self): 107 | while self._running: 108 | container = None 109 | try: 110 | container = av.open(self.input_url, options=self.decoder_opts) 111 | for frame in container.decode(video=0): 112 | if not self._running: 113 | break 114 | arr = frame.to_ndarray(format="rgb24") 115 | with self.window.latest_lock: 116 | self.window.latest_frame = (arr, frame.width, frame.height) 117 | except av.error.InvalidDataError as e: 118 | logging.error("InvalidDataError in decoding: %s", e) 119 | except Exception as e: 120 | logging.error("Decoding error: %s", e) 121 | finally: 122 | if container is not None: 123 | try: 124 | container.close() 125 | except Exception as e: 126 | logging.error("Error closing container: %s", e) 127 | time.sleep(0.005) 128 | 129 | def stop(self): 130 | self._running = False 131 | 132 | class VideoWidgetGL(QOpenGLWidget): 133 | def __init__(self, control_callback, rwidth, rheight, offset_x, offset_y, parent=None): 134 | super().__init__(parent) 135 | self.setMouseTracking(True) 136 | self.setFocusPolicy(Qt.StrongFocus) 137 | self.setAutoFillBackground(False) 138 | self.setAttribute(Qt.WA_OpaquePaintEvent, True) 139 | self.setAttribute(Qt.WA_NoSystemBackground, True) 140 | 141 | self.control_callback = control_callback 142 | self.texture_width = rwidth 143 | self.texture_height = rheight 144 | self.offset_x = offset_x 145 | self.offset_y = offset_y 146 | self.last_mouse_move = 0 147 | self.frame_data = None 148 | 149 | self.clipboard = QApplication.clipboard() 150 | self.clipboard.dataChanged.connect(self.on_clipboard_change) 151 | self.last_clipboard = self.clipboard.text() 152 | self.ignore_clipboard = False 153 | 154 | self.texture_id = None 155 | self.pbo_ids = [] 156 | self.current_pbo = 0 157 | 158 | def on_clipboard_change(self): 159 | new_text = self.clipboard.text() 160 | if not self.ignore_clipboard and new_text and new_text != self.last_clipboard: 161 | self.last_clipboard = new_text 162 | msg = f"CLIPBOARD_UPDATE CLIENT {new_text}" 163 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 164 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) 165 | secure_sendto(sock, msg, (MULTICAST_IP, UDP_CLIPBOARD_PORT)) 166 | logging.info("Client clipboard updated and broadcast.") 167 | 168 | def initializeGL(self): 169 | glClearColor(0.0, 0.0, 0.0, 1.0) 170 | self.texture_id = glGenTextures(1) 171 | self._initialize_texture(self.texture_width, self.texture_height) 172 | 173 | def _initialize_texture(self, width, height): 174 | glBindTexture(GL_TEXTURE_2D, self.texture_id) 175 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) 176 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) 177 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, None) 178 | glBindTexture(GL_TEXTURE_2D, 0) 179 | if self.pbo_ids is not None and len(self.pbo_ids) > 0: 180 | glDeleteBuffers(len(self.pbo_ids), self.pbo_ids) 181 | self.pbo_ids = list(glGenBuffers(2)) 182 | buffer_size = width * height * 3 183 | for pbo in self.pbo_ids: 184 | glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pbo) 185 | glBufferData(GL_PIXEL_UNPACK_BUFFER, buffer_size, None, GL_STREAM_DRAW) 186 | glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0) 187 | self.texture_width = width 188 | self.texture_height = height 189 | 190 | def resizeTexture(self, new_width, new_height): 191 | logging.info("Resizing texture from %dx%d to %dx%d", self.texture_width, self.texture_height, new_width, new_height) 192 | self._initialize_texture(new_width, new_height) 193 | 194 | def paintGL(self): 195 | glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) 196 | if self.frame_data: 197 | arr, frame_width, frame_height = self.frame_data 198 | if frame_width != self.texture_width or frame_height != self.texture_height: 199 | self.resizeTexture(frame_width, frame_height) 200 | data = np.ascontiguousarray(arr, dtype=np.uint8) 201 | buffer_size = data.nbytes 202 | current_pbo = self.pbo_ids[self.current_pbo] 203 | glBindBuffer(GL_PIXEL_UNPACK_BUFFER, current_pbo) 204 | ptr = glMapBufferRange(GL_PIXEL_UNPACK_BUFFER, 0, buffer_size, 205 | GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT) 206 | if ptr: 207 | from ctypes import memmove, c_void_p 208 | memmove(c_void_p(ptr), data.ctypes.data, buffer_size) 209 | glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER) 210 | glBindTexture(GL_TEXTURE_2D, self.texture_id) 211 | glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, frame_width, frame_height, GL_RGB, GL_UNSIGNED_BYTE, None) 212 | glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0) 213 | glEnable(GL_TEXTURE_2D) 214 | glBegin(GL_QUADS) 215 | glTexCoord2f(0.0, 1.0); glVertex2f(-1.0, -1.0) 216 | glTexCoord2f(1.0, 1.0); glVertex2f(1.0, -1.0) 217 | glTexCoord2f(1.0, 0.0); glVertex2f(1.0, 1.0) 218 | glTexCoord2f(0.0, 0.0); glVertex2f(-1.0, 1.0) 219 | glEnd() 220 | glDisable(GL_TEXTURE_2D) 221 | glBindTexture(GL_TEXTURE_2D, 0) 222 | self.current_pbo = (self.current_pbo + 1) % 2 223 | 224 | def updateFrame(self, frame_tuple): 225 | self.frame_data = frame_tuple 226 | self.update() 227 | 228 | def mouseMoveEvent(self, e): 229 | now = time.time() 230 | if now - self.last_mouse_move < MOUSE_MOVE_THROTTLE: 231 | return 232 | self.last_mouse_move = now 233 | if self.width() and self.height(): 234 | rx = self.offset_x + int(e.x() / self.width() * self.texture_width) 235 | ry = self.offset_y + int(e.y() / self.height() * self.texture_height) 236 | self.control_callback(f"MOUSE_MOVE {rx} {ry}") 237 | e.accept() 238 | 239 | def mousePressEvent(self, e): 240 | button_map = {Qt.LeftButton: "1", Qt.MiddleButton: "2", Qt.RightButton: "3"} 241 | b = button_map.get(e.button(), "") 242 | if b and self.width() and self.height(): 243 | rx = self.offset_x + int(e.x() / self.width() * self.texture_width) 244 | ry = self.offset_y + int(e.y() / self.height() * self.texture_height) 245 | self.control_callback(f"MOUSE_PRESS {b} {rx} {ry}") 246 | e.accept() 247 | 248 | def mouseReleaseEvent(self, e): 249 | button_map = {Qt.LeftButton: "1", Qt.MiddleButton: "2", Qt.RightButton: "3"} 250 | b = button_map.get(e.button(), "") 251 | if b: 252 | self.control_callback(f"MOUSE_RELEASE {b}") 253 | e.accept() 254 | 255 | def wheelEvent(self, e): 256 | delta = e.angleDelta() 257 | if delta.y() != 0: 258 | b = "4" if delta.y() > 0 else "5" 259 | self.control_callback(f"MOUSE_SCROLL {b}") 260 | e.accept() 261 | elif delta.x() != 0: 262 | b = "6" if delta.x() < 0 else "7" 263 | self.control_callback(f"MOUSE_SCROLL {b}") 264 | e.accept() 265 | 266 | def keyPressEvent(self, e): 267 | if e.isAutoRepeat(): 268 | return 269 | key_name = self._get_key_name(e) 270 | if key_name: 271 | self.control_callback(f"KEY_PRESS {key_name}") 272 | e.accept() 273 | 274 | def keyReleaseEvent(self, e): 275 | if e.isAutoRepeat(): 276 | return 277 | key_name = self._get_key_name(e) 278 | if key_name: 279 | self.control_callback(f"KEY_RELEASE {key_name}") 280 | e.accept() 281 | 282 | def _get_key_name(self, event): 283 | from PyQt5.QtCore import Qt 284 | key = event.key() 285 | text = event.text() 286 | key_map = { 287 | Qt.Key_Escape: "Escape", Qt.Key_Tab: "Tab", Qt.Key_Backtab: "Tab", Qt.Key_Backspace: "BackSpace", 288 | Qt.Key_Return: "Return", Qt.Key_Enter: "Return", Qt.Key_Insert: "Insert", Qt.Key_Delete: "Delete", 289 | Qt.Key_Pause: "Pause", Qt.Key_Print: "Print", Qt.Key_SysReq: "Sys_Req", Qt.Key_Clear: "Clear", 290 | Qt.Key_Home: "Home", Qt.Key_End: "End", Qt.Key_Left: "Left", Qt.Key_Up: "Up", Qt.Key_Right: "Right", 291 | Qt.Key_Down: "Down", Qt.Key_PageUp: "Page_Up", Qt.Key_PageDown: "Page_Down", Qt.Key_Shift: "Shift_L", 292 | Qt.Key_Control: "Control_L", Qt.Key_Meta: "Super_L", Qt.Key_Alt: "Alt_L", Qt.Key_AltGr: "Alt_R", 293 | Qt.Key_CapsLock: "Caps_Lock", Qt.Key_NumLock: "Num_Lock", Qt.Key_ScrollLock: "Scroll_Lock", 294 | Qt.Key_F1: "F1", Qt.Key_F2: "F2", Qt.Key_F3: "F3", Qt.Key_F4: "F4", Qt.Key_F5: "F5", Qt.Key_F6: "F6", 295 | Qt.Key_F7: "F7", Qt.Key_F8: "F8", Qt.Key_F9: "F9", Qt.Key_F10: "F10", Qt.Key_F11: "F11", Qt.Key_F12: "F12", 296 | Qt.Key_Space: "space", Qt.Key_QuoteLeft: "grave", Qt.Key_Minus: "minus", Qt.Key_Equal: "equal", 297 | Qt.Key_BracketLeft: "bracketleft", Qt.Key_BracketRight: "bracketright", Qt.Key_Backslash: "backslash", 298 | Qt.Key_Semicolon: "semicolon", Qt.Key_Apostrophe: "apostrophe", Qt.Key_Comma: "comma", 299 | Qt.Key_Period: "period", Qt.Key_Slash: "slash", Qt.Key_Exclam: "exclam", Qt.Key_QuoteDbl: "quotedbl", 300 | Qt.Key_NumberSign: "numbersign", Qt.Key_Dollar: "dollar", Qt.Key_Percent: "percent", 301 | Qt.Key_Ampersand: "ampersand", Qt.Key_Asterisk: "asterisk", Qt.Key_ParenLeft: "parenleft", 302 | Qt.Key_ParenRight: "parenright", Qt.Key_Underscore: "underscore", Qt.Key_Plus: "plus", 303 | Qt.Key_BraceLeft: "braceleft", Qt.Key_BraceRight: "braceright", Qt.Key_Bar: "bar", 304 | Qt.Key_Colon: "colon", Qt.Key_Less: "less", Qt.Key_Greater: "greater", Qt.Key_Question: "question" 305 | } 306 | if key in key_map: 307 | return key_map[key] 308 | if (Qt.Key_A <= key <= Qt.Key_Z) or (Qt.Key_0 <= key <= Qt.Key_9): 309 | return chr(key).lower() 310 | if text: 311 | if text == "£": 312 | return "sterling" 313 | if text == "¬": 314 | return "notsign" 315 | return text 316 | return None 317 | 318 | class MainWindow(QMainWindow): 319 | def __init__(self, decoder_opts, rwidth, rheight, host_ip, udp_port, offset_x, offset_y, parent=None): 320 | super().__init__(parent) 321 | self.setWindowTitle("Remote Desktop Viewer (LinuxPlay)") 322 | self.texture_width = rwidth 323 | self.texture_height = rheight 324 | self.offset_x = offset_x 325 | self.offset_y = offset_y 326 | 327 | self.latest_frame = None 328 | self.latest_lock = threading.Lock() 329 | 330 | self.control_addr = (host_ip, CONTROL_PORT) 331 | self.control_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 332 | self.control_sock.setblocking(False) 333 | self.video_widget = VideoWidgetGL(self.send_control, rwidth, rheight, offset_x, offset_y) 334 | self.setCentralWidget(self.video_widget) 335 | self.video_widget.setFocus() 336 | self.setAcceptDrops(True) 337 | logging.debug("Using decoder options: %s", decoder_opts) 338 | logging.debug("Connecting to host %s, resolution %sx%s", host_ip, rwidth, rheight) 339 | video_url = f"udp://0.0.0.0:{udp_port}?fifo_size=1024&max_delay=0&overrun_nonfatal=1" 340 | self.decoder_thread = DecoderThread(video_url, decoder_opts, self) 341 | self.decoder_thread.start() 342 | self.timer = QTimer(self) 343 | self.timer.timeout.connect(self.poll_frame) 344 | self.timer.start(16) 345 | 346 | def poll_frame(self): 347 | with self.latest_lock: 348 | frame = self.latest_frame 349 | if frame: 350 | self.video_widget.updateFrame(frame) 351 | 352 | def dragEnterEvent(self, event): 353 | if event.mimeData().hasUrls(): 354 | event.acceptProposedAction() 355 | else: 356 | event.ignore() 357 | 358 | def dropEvent(self, event): 359 | urls = event.mimeData().urls() 360 | if urls: 361 | for url in urls: 362 | file_path = url.toLocalFile() 363 | if os.path.isdir(file_path): 364 | for root, dirs, files in os.walk(file_path): 365 | for f in files: 366 | full_path = os.path.join(root, f) 367 | threading.Thread(target=self.upload_file, args=(full_path,), daemon=True).start() 368 | elif os.path.isfile(file_path): 369 | threading.Thread(target=self.upload_file, args=(file_path,), daemon=True).start() 370 | event.acceptProposedAction() 371 | else: 372 | event.ignore() 373 | 374 | def upload_file(self, file_path): 375 | try: 376 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 377 | sock.connect((self.control_addr[0], FILE_UPLOAD_PORT)) 378 | secure_sock = ssl_context.wrap_socket(sock) 379 | filename = os.path.basename(file_path) 380 | filename_bytes = filename.encode('utf-8') 381 | filename_length = len(filename_bytes) 382 | header = filename_length.to_bytes(4, byteorder='big') + filename_bytes 383 | file_size = os.path.getsize(file_path) 384 | header += file_size.to_bytes(8, byteorder='big') 385 | secure_sock.sendall(header) 386 | with open(file_path, 'rb') as f: 387 | while True: 388 | data = f.read(4096) 389 | if not data: 390 | break 391 | secure_sock.sendall(data) 392 | secure_sock.close() 393 | logging.info("File %s uploaded successfully.", filename) 394 | except Exception as e: 395 | logging.error("Error uploading file: %s", e) 396 | 397 | def update_image(self, frame_tuple): 398 | self.video_widget.updateFrame(frame_tuple) 399 | 400 | def send_control(self, msg): 401 | try: 402 | secure_sendto(self.control_sock, msg, self.control_addr) 403 | except Exception as e: 404 | logging.error("Error sending control message: %s", e) 405 | 406 | def closeEvent(self, event): 407 | self.timer.stop() 408 | self.decoder_thread.stop() 409 | self.decoder_thread.wait(2000) 410 | global audio_proc 411 | if audio_proc is not None: 412 | try: 413 | audio_proc.terminate() 414 | except Exception as e: 415 | logging.error("Error terminating audio process: %s", e) 416 | event.accept() 417 | 418 | def clipboard_listener_client(): 419 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 420 | try: 421 | sock.bind(("", UDP_CLIPBOARD_PORT)) 422 | mreq = socket.inet_aton(MULTICAST_IP) + socket.inet_aton("0.0.0.0") 423 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 424 | except Exception as e: 425 | logging.error("Clipboard listener bind failed: %s", e) 426 | return 427 | while True: 428 | try: 429 | msg, addr = secure_recvfrom(sock, 65535) 430 | tokens = msg.split(maxsplit=2) 431 | if len(tokens) >= 3 and tokens[0] == "CLIPBOARD_UPDATE" and tokens[1] == "HOST": 432 | new_content = tokens[2] 433 | clipboard = QApplication.clipboard() 434 | clipboard.blockSignals(True) 435 | if new_content != clipboard.text(): 436 | clipboard.setText(new_content) 437 | logging.info("Client clipboard updated from host.") 438 | clipboard.blockSignals(False) 439 | except Exception as e: 440 | logging.error("Client clipboard listener error: %s", e) 441 | 442 | def control_listener_client(): 443 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 444 | try: 445 | sock.bind(("", CONTROL_PORT)) 446 | except Exception as e: 447 | logging.error("Client control listener bind failed: %s", e) 448 | return 449 | logging.info("Client control listener active on UDP port %s", CONTROL_PORT) 450 | while True: 451 | try: 452 | msg, addr = secure_recvfrom(sock, 2048) 453 | if msg: 454 | logging.info("Client received control message: %s", msg) 455 | except Exception as e: 456 | logging.error("Client control listener error: %s", e) 457 | 458 | def cleanup(): 459 | pass 460 | 461 | atexit.register(cleanup) 462 | 463 | def main(): 464 | parser = argparse.ArgumentParser(description="Remote Desktop Client (Optimized for Ultra-Low Latency) with Security") 465 | parser.add_argument("--decoder", choices=["none", "h.264", "h.265", "av1"], default="none") 466 | parser.add_argument("--host_ip", required=True) 467 | parser.add_argument("--audio", choices=["enable", "disable"], default="disable") 468 | parser.add_argument("--monitor", default="0", help="Monitor index to view or 'all' for all monitors") 469 | parser.add_argument("--debug", action="store_true", help="Enable debug mode with more logging.") 470 | parser.add_argument("--security-key", required=True, help="Base64-encoded 32-byte key for encryption (Fernet)") 471 | args = parser.parse_args() 472 | 473 | global security_key, cipher, ssl_context 474 | security_key = args.security_key.encode("utf-8") 475 | cipher = Fernet(security_key) 476 | ssl_context = ssl.create_default_context() 477 | ssl_context.check_hostname = False 478 | ssl_context.verify_mode = ssl.CERT_NONE 479 | 480 | fmt = QSurfaceFormat() 481 | fmt.setSwapInterval(0) 482 | QSurfaceFormat.setDefaultFormat(fmt) 483 | 484 | app = QApplication(sys.argv) 485 | 486 | if args.debug: 487 | logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S") 488 | else: 489 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S") 490 | 491 | handshake_ok, host_info = tcp_handshake_client(args.host_ip) 492 | if not handshake_ok: 493 | sys.exit("Handshake failed. Exiting.") 494 | host_encoder, monitor_info_str = host_info 495 | try: 496 | monitors = [] 497 | if ";" in monitor_info_str: 498 | parts = monitor_info_str.split(";") 499 | for part in parts: 500 | try: 501 | if '+' in part: 502 | res_part, ox, oy = part.split('+') 503 | w_str, h_str = res_part.split('x') 504 | w = int(w_str) 505 | h = int(h_str) 506 | ox = int(ox) 507 | oy = int(oy) 508 | else: 509 | w, h = map(int, part.split('x')) 510 | ox = 0 511 | oy = 0 512 | monitors.append((w, h, ox, oy)) 513 | except Exception: 514 | pass 515 | else: 516 | if '+' in monitor_info_str: 517 | res_part, ox, oy = monitor_info_str.split('+') 518 | w_str, h_str = res_part.split('x') 519 | w = int(w_str) 520 | h = int(h_str) 521 | ox = int(ox) 522 | oy = int(oy) 523 | monitors.append((w, h, ox, oy)) 524 | else: 525 | w, h = map(int, monitor_info_str.lower().split("x")) 526 | monitors.append((w, h, 0, 0)) 527 | except Exception: 528 | logging.error("Error parsing monitor info; using default.") 529 | w, h = map(int, DEFAULT_RESOLUTION.lower().split("x")) 530 | monitors = [(w, h, 0, 0)] 531 | 532 | if args.decoder == "none": 533 | logging.warning("You selected 'none' decoder, but host is using '%s'. Attempting raw decode fallback...", host_encoder) 534 | else: 535 | if args.decoder.replace(".", "") != host_encoder.replace(".", ""): 536 | logging.error("Encoder/decoder mismatch: Host uses '%s', client selected '%s'.", host_encoder, args.decoder) 537 | QMessageBox.critical(None, "Decoder Mismatch", 538 | f"ERROR: The host is currently using '{host_encoder}' encoder, but your decoder is '{args.decoder}'.\n" 539 | f"Please switch to '{host_encoder}' instead.") 540 | sys.exit(1) 541 | 542 | decoder_opts = {} 543 | if args.decoder == "h.264": 544 | if has_nvidia(): 545 | decoder_opts["hwaccel"] = "h264_nvdec" 546 | elif is_intel_cpu(): 547 | decoder_opts["hwaccel"] = "h264_qsv" 548 | elif has_vaapi(): 549 | decoder_opts["hwaccel"] = "h264_vaapi" 550 | elif args.decoder == "h.265": 551 | if has_nvidia(): 552 | decoder_opts["hwaccel"] = "hevc_nvdec" 553 | elif is_intel_cpu(): 554 | decoder_opts["hwaccel"] = "hevc_qsv" 555 | elif has_vaapi(): 556 | decoder_opts["hwaccel"] = "hevc_vaapi" 557 | elif args.decoder == "av1": 558 | if has_nvidia(): 559 | decoder_opts["hwaccel"] = "av1_nvdec" 560 | elif is_intel_cpu(): 561 | decoder_opts["hwaccel"] = "av1_qsv" 562 | elif has_vaapi(): 563 | decoder_opts["hwaccel"] = "av1_vaapi" 564 | 565 | threading.Thread(target=clipboard_listener_client, daemon=True).start() 566 | threading.Thread(target=control_listener_client, daemon=True).start() 567 | 568 | global audio_proc 569 | if args.audio == "enable": 570 | audio_cmd = [ 571 | "ffplay", 572 | "-hide_banner", 573 | "-loglevel", "error", 574 | "-fflags", "nobuffer", 575 | "-flags", "low_delay", 576 | "-autoexit", 577 | "-nodisp", 578 | "-i", f"udp://@{MULTICAST_IP}:6001?fifo_size=512&max_delay=0&pkt_size=1316&overrun_nonfatal=1" 579 | ] 580 | logging.info("Starting audio playback with ffplay...") 581 | audio_proc = subprocess.Popen(audio_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 582 | 583 | if args.monitor.lower() == "all": 584 | windows = [] 585 | base_port = DEFAULT_UDP_PORT 586 | for i, mon in enumerate(monitors): 587 | w, h, ox, oy = mon 588 | window = MainWindow(decoder_opts, w, h, args.host_ip, base_port + i, ox, oy) 589 | window.setWindowTitle(f"Remote Desktop Viewer - Monitor {i}") 590 | window.show() 591 | windows.append(window) 592 | ret = app.exec_() 593 | else: 594 | try: 595 | mon_index = int(args.monitor) 596 | except Exception: 597 | mon_index = 0 598 | if mon_index < 0 or mon_index >= len(monitors): 599 | logging.error("Invalid monitor index %d, defaulting to 0", mon_index) 600 | mon_index = 0 601 | w, h, ox, oy = monitors[mon_index] 602 | window = MainWindow(decoder_opts, w, h, args.host_ip, DEFAULT_UDP_PORT + mon_index, ox, oy) 603 | window.setWindowTitle(f"Remote Desktop Viewer - Monitor {mon_index}") 604 | window.show() 605 | ret = app.exec_() 606 | 607 | if audio_proc: 608 | try: 609 | audio_proc.terminate() 610 | except Exception as e: 611 | logging.error("Error terminating audio process: %s", e) 612 | sys.exit(ret) 613 | 614 | if __name__ == "__main__": 615 | main() 616 | -------------------------------------------------------------------------------- /host.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import subprocess 4 | import argparse 5 | import sys 6 | import logging 7 | import time 8 | import threading 9 | import socket 10 | import atexit 11 | import ssl 12 | from shutil import which 13 | from cryptography.fernet import Fernet 14 | from cryptography import x509 15 | from cryptography.x509.oid import NameOID 16 | from cryptography.hazmat.primitives import hashes, serialization 17 | from cryptography.hazmat.primitives.asymmetric import rsa 18 | from datetime import datetime, timedelta, timezone 19 | 20 | UDP_VIDEO_PORT = 5000 21 | UDP_AUDIO_PORT = 6001 22 | UDP_CONTROL_PORT = 7000 23 | UDP_CLIPBOARD_PORT = 7002 24 | TCP_HANDSHAKE_PORT = 7001 25 | FILE_UPLOAD_PORT = 7003 26 | MULTICAST_IP = "239.0.0.1" 27 | DEFAULT_FPS = "30" 28 | DEFAULT_BITRATE = "8M" 29 | DEFAULT_RES = "1920x1080" 30 | 31 | security_key = None 32 | cipher = None 33 | ssl_context = None 34 | CERT_FILE = "cert.pem" 35 | KEY_FILE = "key.pem" 36 | 37 | class HostState: 38 | def __init__(self): 39 | self.video_threads = [] 40 | self.audio_thread = None 41 | self.current_bitrate = DEFAULT_BITRATE 42 | self.last_clipboard_content = "" 43 | self.ignore_clipboard_update = False 44 | self.should_terminate = False 45 | self.video_thread_lock = threading.Lock() 46 | self.clipboard_lock = threading.Lock() 47 | self.handshake_sock = None 48 | self.control_sock = None 49 | self.clipboard_listener_sock = None 50 | self.client_ip = None 51 | self.monitors = [] 52 | 53 | host_state = HostState() 54 | 55 | def has_nvidia(): 56 | return which("nvidia-smi") is not None 57 | 58 | def has_vaapi(): 59 | return os.path.exists("/dev/dri/renderD128") 60 | 61 | def is_intel_cpu(): 62 | try: 63 | with open("/proc/cpuinfo", "r") as f: 64 | return "GenuineIntel" in f.read() 65 | except Exception: 66 | return False 67 | 68 | def stop_all(): 69 | host_state.should_terminate = True 70 | with host_state.video_thread_lock: 71 | for thread in host_state.video_threads: 72 | thread.stop() 73 | thread.join(timeout=2) 74 | if host_state.audio_thread: 75 | host_state.audio_thread.stop() 76 | host_state.audio_thread.join(timeout=2) 77 | if host_state.handshake_sock: 78 | try: 79 | host_state.handshake_sock.close() 80 | except: 81 | pass 82 | if host_state.control_sock: 83 | try: 84 | host_state.control_sock.close() 85 | except: 86 | pass 87 | if host_state.clipboard_listener_sock: 88 | try: 89 | host_state.clipboard_listener_sock.close() 90 | except: 91 | pass 92 | 93 | def cleanup(): 94 | stop_all() 95 | 96 | atexit.register(cleanup) 97 | 98 | class StreamThread(threading.Thread): 99 | def __init__(self, cmd, name): 100 | super().__init__(daemon=True) 101 | self.cmd = cmd 102 | self.name = name 103 | self.process = None 104 | self._running = True 105 | 106 | def run(self): 107 | logging.info("Starting %s stream: %s", self.name, " ".join(self.cmd)) 108 | self.process = subprocess.Popen( 109 | self.cmd, 110 | stdout=subprocess.DEVNULL, 111 | stderr=subprocess.PIPE, 112 | universal_newlines=True 113 | ) 114 | while self._running: 115 | if host_state.should_terminate: 116 | break 117 | ret = self.process.poll() 118 | if ret is not None: 119 | out, err = self.process.communicate() 120 | logging.error("%s process ended unexpectedly. Return code: %s. Error output:\n%s", 121 | self.name, ret, err) 122 | break 123 | time.sleep(0.5) 124 | 125 | def stop(self): 126 | self._running = False 127 | if self.process: 128 | self.process.terminate() 129 | 130 | def shutil_which(cmd): 131 | import shutil 132 | return shutil.which(cmd) 133 | 134 | def get_display(default=":0"): 135 | return os.environ.get("DISPLAY", default) 136 | 137 | def detect_pulse_monitor(): 138 | monitor = os.environ.get("PULSE_MONITOR") 139 | if monitor: 140 | return monitor 141 | if not shutil_which("pactl"): 142 | logging.warning("pactl not found, using default monitor") 143 | return "default.monitor" 144 | try: 145 | output = subprocess.check_output(["pactl", "list", "short", "sources"], universal_newlines=True) 146 | for line in output.splitlines(): 147 | parts = line.split() 148 | if len(parts) >= 2 and ".monitor" in parts[1]: 149 | return parts[1] 150 | except Exception as e: 151 | logging.error("Error detecting PulseAudio monitor: %s", e) 152 | return "default.monitor" 153 | 154 | def detect_monitors(): 155 | try: 156 | output = subprocess.check_output(["xrandr", "--listmonitors"], universal_newlines=True) 157 | except Exception as e: 158 | logging.error("Failed to detect monitors: %s", e) 159 | return [] 160 | lines = output.strip().splitlines() 161 | monitors = [] 162 | for line in lines[1:]: 163 | parts = line.split() 164 | for part in parts: 165 | if 'x' in part and '+' in part: 166 | try: 167 | res_part, ox, oy = part.split('+') 168 | w_str, h_str = res_part.split('x') 169 | w = int(w_str.split('/')[0]) 170 | h = int(h_str.split('/')[0]) 171 | ox = int(ox) 172 | oy = int(oy) 173 | monitors.append((w, h, ox, oy)) 174 | break 175 | except Exception as e: 176 | logging.error("Error parsing monitor info: %s", e) 177 | continue 178 | return monitors 179 | 180 | def build_video_cmd(args, bitrate, monitor_info, video_port): 181 | w, h, ox, oy = monitor_info 182 | video_size = f"{w}x{h}" 183 | disp = args.display 184 | if "." not in disp: 185 | disp = f"{disp}.0" 186 | input_arg = f"{disp}+{ox},{oy}" 187 | cmd = [ 188 | "ffmpeg", 189 | "-hide_banner", 190 | "-loglevel", "error", 191 | "-fflags", "nobuffer", 192 | "-max_delay", "0", 193 | "-flags", "low_delay", 194 | "-threads", "0", 195 | "-f", "x11grab", 196 | "-draw_mouse", "0", 197 | "-framerate", args.framerate, 198 | "-video_size", video_size, 199 | "-i", input_arg 200 | ] 201 | preset = args.preset if args.preset else ( 202 | "llhq" if (args.encoder in ["h.264", "h.265", "av1"] and has_nvidia()) else "ultrafast") 203 | gop = args.gop 204 | qp = args.qp 205 | tune = args.tune 206 | pix_fmt = args.pix_fmt 207 | 208 | if args.encoder == "h.264": 209 | if has_nvidia(): 210 | encode = [ 211 | "-c:v", "h264_nvenc", 212 | "-preset", preset, 213 | "-g", gop, 214 | "-bf", "0", 215 | "-b:v", bitrate, 216 | "-pix_fmt", pix_fmt 217 | ] 218 | if qp: 219 | encode.extend(["-qp", qp]) 220 | elif is_intel_cpu(): 221 | encode = [ 222 | "-c:v", "h264_qsv", 223 | "-preset", preset, 224 | "-g", gop, 225 | "-bf", "0", 226 | "-b:v", bitrate, 227 | "-pix_fmt", pix_fmt 228 | ] 229 | if qp: 230 | encode.extend(["-qp", qp]) 231 | elif has_vaapi(): 232 | encode = [ 233 | "-vf", "format=nv12,hwupload", 234 | "-vaapi_device", "/dev/dri/renderD128", 235 | "-c:v", "h264_vaapi", 236 | "-g", gop, 237 | "-bf", "0", 238 | "-b:v", bitrate 239 | ] 240 | if qp: 241 | encode.extend(["-qp", qp]) 242 | else: 243 | encode.extend(["-qp", "20"]) 244 | else: 245 | encode = [ 246 | "-c:v", "libx264", 247 | "-preset", preset, 248 | ] 249 | if tune: 250 | encode.extend(["-tune", tune]) 251 | else: 252 | encode.extend(["-tune", "zerolatency"]) 253 | encode.extend([ 254 | "-g", gop, 255 | "-bf", "0", 256 | "-b:v", bitrate, 257 | "-pix_fmt", pix_fmt 258 | ]) 259 | if qp: 260 | encode.extend(["-qp", qp]) 261 | elif args.encoder == "h.265": 262 | if has_nvidia(): 263 | encode = [ 264 | "-c:v", "hevc_nvenc", 265 | "-preset", preset, 266 | "-g", gop, 267 | "-bf", "0", 268 | "-b:v", bitrate, 269 | "-pix_fmt", pix_fmt 270 | ] 271 | if qp: 272 | encode.extend(["-qp", qp]) 273 | elif is_intel_cpu(): 274 | encode = [ 275 | "-c:v", "hevc_qsv", 276 | "-preset", preset, 277 | "-g", gop, 278 | "-bf", "0", 279 | "-b:v", bitrate, 280 | "-pix_fmt", pix_fmt 281 | ] 282 | if qp: 283 | encode.extend(["-qp", qp]) 284 | elif has_vaapi(): 285 | encode = [ 286 | "-vf", "format=nv12,hwupload", 287 | "-vaapi_device", "/dev/dri/renderD128", 288 | "-c:v", "hevc_vaapi", 289 | "-g", gop, 290 | "-bf", "0", 291 | "-b:v", bitrate 292 | ] 293 | if qp: 294 | encode.extend(["-qp", qp]) 295 | else: 296 | encode.extend(["-qp", "20"]) 297 | else: 298 | encode = [ 299 | "-c:v", "libx265", 300 | "-preset", preset, 301 | ] 302 | if tune: 303 | encode.extend(["-tune", tune]) 304 | else: 305 | encode.extend(["-tune", "zerolatency"]) 306 | encode.extend([ 307 | "-g", gop, 308 | "-bf", "0", 309 | "-b:v", bitrate 310 | ]) 311 | if qp: 312 | encode.extend(["-qp", qp]) 313 | elif args.encoder == "av1": 314 | if has_nvidia(): 315 | encode = [ 316 | "-c:v", "av1_nvenc", 317 | "-preset", preset, 318 | "-g", gop, 319 | "-bf", "0", 320 | "-b:v", bitrate, 321 | "-pix_fmt", pix_fmt 322 | ] 323 | if qp: 324 | encode.extend(["-qp", qp]) 325 | elif is_intel_cpu(): 326 | encode = [ 327 | "-c:v", "av1_qsv", 328 | "-preset", preset, 329 | "-g", gop, 330 | "-bf", "0", 331 | "-b:v", bitrate, 332 | "-pix_fmt", pix_fmt 333 | ] 334 | if qp: 335 | encode.extend(["-qp", qp]) 336 | elif has_vaapi(): 337 | encode = [ 338 | "-vf", "format=nv12,hwupload", 339 | "-vaapi_device", "/dev/dri/renderD128", 340 | "-c:v", "av1_vaapi", 341 | "-g", gop, 342 | "-bf", "0", 343 | "-b:v", bitrate 344 | ] 345 | if qp: 346 | encode.extend(["-qp", qp]) 347 | else: 348 | encode.extend(["-qp", "20"]) 349 | else: 350 | encode = [ 351 | "-c:v", "libaom-av1", 352 | "-strict", "experimental", 353 | "-cpu-used", "4", 354 | "-g", gop, 355 | "-b:v", bitrate 356 | ] 357 | if qp: 358 | encode.extend(["-qp", qp]) 359 | else: 360 | encode = [ 361 | "-c:v", "libx264", 362 | "-preset", preset, 363 | ] 364 | if tune: 365 | encode.extend(["-tune", tune]) 366 | else: 367 | encode.extend(["-tune", "zerolatency"]) 368 | encode.extend([ 369 | "-g", gop, 370 | "-bf", "0", 371 | "-b:v", bitrate, 372 | "-pix_fmt", pix_fmt 373 | ]) 374 | if qp: 375 | encode.extend(["-qp", qp]) 376 | out = [ 377 | "-f", "mpegts", 378 | f"udp://{host_state.client_ip}:{video_port}?pkt_size=1316&buffer_size=2048" 379 | ] 380 | return cmd + encode + out 381 | 382 | def build_audio_cmd(): 383 | monitor_source = detect_pulse_monitor() 384 | return [ 385 | "ffmpeg", 386 | "-hide_banner", 387 | "-loglevel", "error", 388 | "-fflags", "nobuffer", 389 | "-max_delay", "0", 390 | "-flags", "low_delay", 391 | "-f", "pulse", 392 | "-i", monitor_source, 393 | "-c:a", "libopus", 394 | "-b:a", "128k", 395 | "-f", "mpegts", 396 | f"udp://{MULTICAST_IP}:{UDP_AUDIO_PORT}?pkt_size=1316&buffer_size=512" 397 | ] 398 | 399 | def adaptive_bitrate_manager(args): 400 | while not host_state.should_terminate: 401 | time.sleep(30) 402 | if host_state.should_terminate: 403 | break 404 | with host_state.video_thread_lock: 405 | if host_state.current_bitrate == DEFAULT_BITRATE: 406 | try: 407 | base = int("".join(filter(str.isdigit, DEFAULT_BITRATE))) 408 | new_bitrate = f"{int(base*0.6)}M" 409 | except: 410 | new_bitrate = DEFAULT_BITRATE 411 | else: 412 | new_bitrate = DEFAULT_BITRATE 413 | if new_bitrate != host_state.current_bitrate: 414 | logging.info("Adaptive ABR: Switching bitrate from %s to %s", 415 | host_state.current_bitrate, new_bitrate) 416 | new_threads = [] 417 | for i, mon in enumerate(host_state.monitors): 418 | video_port = UDP_VIDEO_PORT + i 419 | new_cmd = build_video_cmd(args, new_bitrate, mon, video_port) 420 | new_thread = StreamThread(new_cmd, f"Video Monitor {i} (Adaptive)") 421 | new_thread.start() 422 | new_threads.append(new_thread) 423 | for thread in host_state.video_threads: 424 | thread.stop() 425 | thread.join() 426 | host_state.video_threads = new_threads 427 | host_state.current_bitrate = new_bitrate 428 | 429 | def generate_self_signed_cert(cert_file, key_file): 430 | key = rsa.generate_private_key(public_exponent=65537, key_size=2048) 431 | subject = issuer = x509.Name([ 432 | x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"), 433 | x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"State"), 434 | x509.NameAttribute(NameOID.LOCALITY_NAME, u"Locality"), 435 | x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"MyOrganization"), 436 | x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"), 437 | ]) 438 | now = datetime.now(timezone.utc) 439 | cert = x509.CertificateBuilder().subject_name(subject)\ 440 | .issuer_name(issuer)\ 441 | .public_key(key.public_key())\ 442 | .serial_number(x509.random_serial_number())\ 443 | .not_valid_before(now)\ 444 | .not_valid_after(now + timedelta(days=365))\ 445 | .add_extension(x509.SubjectAlternativeName([x509.DNSName(u"localhost")]), critical=False)\ 446 | .sign(key, hashes.SHA256()) 447 | with open(cert_file, "wb") as f: 448 | f.write(cert.public_bytes(serialization.Encoding.PEM)) 449 | with open(key_file, "wb") as f: 450 | f.write(key.private_bytes( 451 | encoding=serialization.Encoding.PEM, 452 | format=serialization.PrivateFormat.TraditionalOpenSSL, 453 | encryption_algorithm=serialization.NoEncryption() 454 | )) 455 | 456 | def secure_sendto(sock, message, addr): 457 | encrypted = cipher.encrypt(message.encode("utf-8")) 458 | sock.sendto(encrypted, addr) 459 | 460 | def secure_recvfrom(sock, bufsize): 461 | data, addr = sock.recvfrom(bufsize) 462 | try: 463 | decrypted = cipher.decrypt(data).decode("utf-8") 464 | except Exception as e: 465 | decrypted = "" 466 | return decrypted, addr 467 | 468 | def tcp_handshake_server(sock, encoder_str, args): 469 | logging.info("TCP Handshake server listening on port %s", TCP_HANDSHAKE_PORT) 470 | while not host_state.should_terminate: 471 | try: 472 | conn, addr = sock.accept() 473 | ssl_conn = ssl_context.wrap_socket(conn, server_side=True) 474 | logging.info("Handshake connection from %s", addr) 475 | host_state.client_ip = addr[0] 476 | data = ssl_conn.recv(1024).decode("utf-8", errors="replace").strip() 477 | logging.info("Received handshake: '%s'", data) 478 | if data == "HELLO": 479 | if host_state.monitors: 480 | monitors_str = ";".join(f"{w}x{h}+{ox}+{oy}" for (w, h, ox, oy) in host_state.monitors) 481 | else: 482 | monitors_str = DEFAULT_RES 483 | resp = f"OK:{encoder_str}:{monitors_str}" 484 | ssl_conn.sendall(resp.encode("utf-8")) 485 | logging.info("Handshake successful. Sent: %s", resp) 486 | else: 487 | ssl_conn.sendall("FAIL".encode("utf-8")) 488 | logging.error("Unexpected handshake message: %s", data) 489 | ssl_conn.close() 490 | except OSError: 491 | break 492 | except Exception as e: 493 | logging.error("TCP handshake server error: %s", e) 494 | break 495 | 496 | def control_listener(sock): 497 | logging.info("Control listener active on UDP port %s", UDP_CONTROL_PORT) 498 | while not host_state.should_terminate: 499 | try: 500 | msg, addr = secure_recvfrom(sock, 2048) 501 | tokens = msg.split() 502 | if not tokens: 503 | continue 504 | cmd = tokens[0] 505 | if cmd == "MOUSE_MOVE" and len(tokens) == 3: 506 | subprocess.Popen(["xdotool", "mousemove", tokens[1], tokens[2]], 507 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 508 | elif cmd == "MOUSE_PRESS" and len(tokens) == 4: 509 | subprocess.Popen(["xdotool", "mousemove", tokens[2], tokens[3]], 510 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 511 | subprocess.Popen(["xdotool", "mousedown", tokens[1]], 512 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 513 | elif cmd == "MOUSE_RELEASE" and len(tokens) == 2: 514 | subprocess.Popen(["xdotool", "mouseup", tokens[1]], 515 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 516 | elif cmd == "MOUSE_SCROLL" and len(tokens) == 2: 517 | subprocess.Popen(["xdotool", "click", tokens[1]], 518 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 519 | elif cmd == "KEY_PRESS" and len(tokens) == 2: 520 | subprocess.Popen(["xdotool", "keydown", tokens[1]], 521 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 522 | elif cmd == "KEY_RELEASE" and len(tokens) == 2: 523 | subprocess.Popen(["xdotool", "keyup", tokens[1]], 524 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 525 | else: 526 | logging.warning("Ignored unsupported control message: %s", msg) 527 | except OSError: 528 | break 529 | except Exception as e: 530 | logging.error("Control listener error: %s", e) 531 | break 532 | 533 | def clipboard_monitor_host(): 534 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 535 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) 536 | while not host_state.should_terminate: 537 | try: 538 | proc = subprocess.run( 539 | ["xclip", "-o", "-selection", "clipboard"], 540 | stdout=subprocess.PIPE, 541 | stderr=subprocess.PIPE, 542 | universal_newlines=True 543 | ) 544 | current = proc.stdout.strip() 545 | except: 546 | current = "" 547 | with host_state.clipboard_lock: 548 | if (not host_state.ignore_clipboard_update and current and current != host_state.last_clipboard_content): 549 | host_state.last_clipboard_content = current 550 | msg = f"CLIPBOARD_UPDATE HOST {current}" 551 | secure_sendto(sock, msg, (MULTICAST_IP, UDP_CLIPBOARD_PORT)) 552 | logging.info("Host clipboard updated and broadcast.") 553 | time.sleep(1) 554 | sock.close() 555 | 556 | def clipboard_listener_host(sock): 557 | while not host_state.should_terminate: 558 | try: 559 | msg, addr = secure_recvfrom(sock, 65535) 560 | tokens = msg.split(maxsplit=2) 561 | if len(tokens) >= 3 and tokens[0] == "CLIPBOARD_UPDATE" and tokens[1] == "CLIENT": 562 | new_content = tokens[2] 563 | with host_state.clipboard_lock: 564 | host_state.ignore_clipboard_update = True 565 | proc = subprocess.run( 566 | ["xclip", "-o", "-selection", "clipboard"], 567 | stdout=subprocess.PIPE, 568 | stderr=subprocess.PIPE, 569 | universal_newlines=True 570 | ) 571 | current = proc.stdout.strip() 572 | if new_content != current: 573 | p = subprocess.Popen(["xclip", "-selection", "clipboard", "-in"], stdin=subprocess.PIPE) 574 | p.communicate(new_content.encode("utf-8")) 575 | logging.info("Host clipboard updated from client.") 576 | host_state.ignore_clipboard_update = False 577 | except OSError: 578 | break 579 | except Exception as e: 580 | logging.error("Host clipboard listener error: %s", e) 581 | break 582 | 583 | def recvall(sock, n): 584 | data = b"" 585 | while len(data) < n: 586 | packet = sock.recv(n - len(data)) 587 | if not packet: 588 | return None 589 | data += packet 590 | return data 591 | 592 | def file_upload_listener(): 593 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 594 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 595 | s.bind(("", FILE_UPLOAD_PORT)) 596 | s.listen(5) 597 | logging.info("File upload listener active on TCP port %s", FILE_UPLOAD_PORT) 598 | while not host_state.should_terminate: 599 | try: 600 | conn, addr = s.accept() 601 | ssl_conn = ssl_context.wrap_socket(conn, server_side=True) 602 | logging.info("File upload connection from %s", addr) 603 | header = recvall(ssl_conn, 4) 604 | if not header: 605 | ssl_conn.close() 606 | continue 607 | filename_length = int.from_bytes(header, byteorder='big') 608 | filename_bytes = recvall(ssl_conn, filename_length) 609 | filename = filename_bytes.decode('utf-8') 610 | filesize_bytes = recvall(ssl_conn, 8) 611 | file_size = int.from_bytes(filesize_bytes, byteorder='big') 612 | dest_dir = os.path.expanduser("~/LinuxPlayDrop") 613 | if not os.path.exists(dest_dir): 614 | os.makedirs(dest_dir) 615 | dest_path = os.path.join(dest_dir, filename) 616 | with open(dest_path, 'wb') as f: 617 | remaining = file_size 618 | while remaining > 0: 619 | chunk_size = 4096 if remaining >= 4096 else remaining 620 | chunk = ssl_conn.recv(chunk_size) 621 | if not chunk: 622 | break 623 | f.write(chunk) 624 | remaining -= len(chunk) 625 | logging.info("Received file %s (%d bytes)", dest_path, file_size) 626 | ssl_conn.close() 627 | except Exception as e: 628 | logging.error("File upload error: %s", e) 629 | s.close() 630 | 631 | def main(): 632 | parser = argparse.ArgumentParser(description="Remote Desktop Host (Optimized for Low Latency) with Security") 633 | parser.add_argument("--encoder", choices=["none", "h.264", "h.265", "av1"], default="none") 634 | parser.add_argument("--framerate", default=DEFAULT_FPS) 635 | parser.add_argument("--bitrate", default=DEFAULT_BITRATE) 636 | parser.add_argument("--audio", choices=["enable", "disable"], default="disable") 637 | parser.add_argument("--adaptive", action="store_true") 638 | parser.add_argument("--display", default=":0") 639 | parser.add_argument("--preset", default="", help="Encoder preset (if empty, built-in default is used)") 640 | parser.add_argument("--gop", default="30", help="Group of Pictures size (keyframe interval)") 641 | parser.add_argument("--qp", default="", help="Quantization Parameter (leave empty for none)") 642 | parser.add_argument("--tune", default="", help="Tune option (e.g., zerolatency)") 643 | parser.add_argument("--pix_fmt", default="yuv420p", help="Pixel format (default: yuv420p)") 644 | parser.add_argument("--debug", action="store_true", help="Enable debug logging") 645 | parser.add_argument("--security-key", required=True, help="Base64-encoded 32-byte key for encryption (Fernet)") 646 | args = parser.parse_args() 647 | 648 | global security_key, cipher, ssl_context 649 | security_key = args.security_key.encode("utf-8") 650 | cipher = Fernet(security_key) 651 | if not os.path.exists(CERT_FILE) or not os.path.exists(KEY_FILE): 652 | generate_self_signed_cert(CERT_FILE, KEY_FILE) 653 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 654 | ssl_context.load_cert_chain(certfile=CERT_FILE, keyfile=KEY_FILE) 655 | 656 | if args.debug: 657 | logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S") 658 | else: 659 | logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S") 660 | 661 | host_state.current_bitrate = args.bitrate 662 | 663 | host_state.monitors = detect_monitors() 664 | if not host_state.monitors: 665 | try: 666 | w, h = map(int, DEFAULT_RES.lower().split("x")) 667 | except: 668 | w, h = map(int, "1920x1080".split("x")) 669 | host_state.monitors = [(w, h, 0, 0)] 670 | 671 | encoder_str = args.encoder 672 | 673 | host_state.handshake_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 674 | host_state.handshake_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 675 | try: 676 | host_state.handshake_sock.bind(("", TCP_HANDSHAKE_PORT)) 677 | host_state.handshake_sock.listen(5) 678 | except Exception as e: 679 | logging.error("Failed to bind TCP handshake port %s: %s", TCP_HANDSHAKE_PORT, e) 680 | stop_all() 681 | sys.exit(1) 682 | 683 | host_state.control_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 684 | host_state.control_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 685 | try: 686 | host_state.control_sock.bind(("", UDP_CONTROL_PORT)) 687 | except Exception as e: 688 | logging.error("Failed to bind control port %s: %s", UDP_CONTROL_PORT, e) 689 | stop_all() 690 | sys.exit(1) 691 | 692 | host_state.clipboard_listener_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 693 | host_state.clipboard_listener_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 694 | try: 695 | host_state.clipboard_listener_sock.bind(("", UDP_CLIPBOARD_PORT)) 696 | mreq = socket.inet_aton(MULTICAST_IP) + socket.inet_aton("0.0.0.0") 697 | host_state.clipboard_listener_sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 698 | except Exception as e: 699 | logging.error("Failed to bind clipboard port %s: %s", UDP_CLIPBOARD_PORT, e) 700 | stop_all() 701 | sys.exit(1) 702 | 703 | handshake_thread = threading.Thread(target=tcp_handshake_server, args=(host_state.handshake_sock, encoder_str, args), daemon=True) 704 | handshake_thread.start() 705 | 706 | clipboard_monitor_thread = threading.Thread(target=clipboard_monitor_host, daemon=True) 707 | clipboard_monitor_thread.start() 708 | 709 | clipboard_listener_thread = threading.Thread(target=clipboard_listener_host, args=(host_state.clipboard_listener_sock,), daemon=True) 710 | clipboard_listener_thread.start() 711 | 712 | file_thread = threading.Thread(target=file_upload_listener, daemon=True) 713 | file_thread.start() 714 | 715 | logging.info("Waiting for client connection for video streaming...") 716 | while host_state.client_ip is None and not host_state.should_terminate: 717 | time.sleep(0.1) 718 | logging.info("Client connected from %s, starting video streams.", host_state.client_ip) 719 | 720 | with host_state.video_thread_lock: 721 | host_state.video_threads = [] 722 | for i, mon in enumerate(host_state.monitors): 723 | video_port = UDP_VIDEO_PORT + i 724 | video_cmd = build_video_cmd(args, host_state.current_bitrate, mon, video_port) 725 | logging.debug("Video command for monitor %d: %s", i, " ".join(video_cmd)) 726 | stream_thread = StreamThread(video_cmd, f"Video Monitor {i}") 727 | stream_thread.start() 728 | host_state.video_threads.append(stream_thread) 729 | 730 | if args.audio == "enable": 731 | audio_cmd = build_audio_cmd() 732 | logging.debug("Audio command: %s", " ".join(audio_cmd)) 733 | host_state.audio_thread = StreamThread(audio_cmd, "Audio") 734 | host_state.audio_thread.start() 735 | 736 | if args.adaptive: 737 | abr_thread = threading.Thread(target=adaptive_bitrate_manager, args=(args,), daemon=True) 738 | abr_thread.start() 739 | 740 | ctrl_thread = threading.Thread(target=control_listener, args=(host_state.control_sock,), daemon=True) 741 | ctrl_thread.start() 742 | 743 | logging.info("Host running. Press Ctrl+C to exit.") 744 | try: 745 | while True: 746 | time.sleep(1) 747 | except KeyboardInterrupt: 748 | logging.info("Shutting down host...") 749 | stop_all() 750 | sys.exit(0) 751 | 752 | if __name__ == "__main__": 753 | main() 754 | -------------------------------------------------------------------------------- /key.py: -------------------------------------------------------------------------------- 1 | from cryptography.fernet import Fernet 2 | print(Fernet.generate_key().decode()) 3 | -------------------------------------------------------------------------------- /start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import subprocess 4 | import os 5 | import signal 6 | from PyQt5.QtWidgets import ( 7 | QApplication, QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, QFormLayout, 8 | QComboBox, QCheckBox, QPushButton, QGroupBox, QLineEdit, QTextEdit, QLabel 9 | ) 10 | from PyQt5.QtGui import QPalette, QColor 11 | from PyQt5.QtCore import Qt 12 | from shutil import which 13 | 14 | def has_nvidia(): 15 | from shutil import which as shwhich 16 | return shwhich("nvidia-smi") is not None 17 | 18 | def has_vaapi(): 19 | return os.path.exists("/dev/dri/renderD128") 20 | 21 | def check_encoder_support(codec): 22 | try: 23 | output = subprocess.check_output(["ffmpeg", "-encoders"], stderr=subprocess.DEVNULL).decode() 24 | except Exception: 25 | return False 26 | if codec == "h265": 27 | return any(x in output for x in ["hevc_nvenc", "hevc_vaapi", "hevc_qsv"]) 28 | elif codec == "av1": 29 | return any(x in output for x in ["av1_nvenc", "av1_vaapi", "av1_qsv"]) 30 | elif codec == "h.264": 31 | return any(x in output for x in ["h264_nvenc", "h264_vaapi", "h264_qsv"]) 32 | return False 33 | 34 | def check_decoder_support(codec): 35 | try: 36 | output = subprocess.check_output(["ffmpeg", "-decoders"], stderr=subprocess.DEVNULL).decode() 37 | except Exception: 38 | return False 39 | if codec == "h265": 40 | return any(x in output for x in ["hevc_nvdec", "hevc_vaapi", "hevc_qsv", "hevc_cuvid"]) 41 | elif codec == "av1": 42 | return any(x in output for x in ["av1_nvdec", "av1_vaapi", "av1_qsv"]) 43 | elif codec == "h.264": 44 | return any(x in output for x in ["h264_nvdec", "h264_vaapi", "h264_qsv", "h264_cuvid"]) 45 | return False 46 | 47 | class HostTab(QWidget): 48 | def __init__(self, parent=None): 49 | super().__init__(parent) 50 | 51 | main_layout = QVBoxLayout() 52 | 53 | self.profileCombo = QComboBox() 54 | self.profileCombo.setEditable(False) 55 | self.profileCombo.addItems(["Default", "Lowest Latency", "Balanced", "High Quality"]) 56 | self.profileCombo.currentIndexChanged.connect(self.profileChanged) 57 | 58 | form_group = QGroupBox("Host Configuration") 59 | form_layout = QFormLayout() 60 | 61 | form_layout.addRow("Profile:", self.profileCombo) 62 | 63 | self.encoderCombo = QComboBox() 64 | self.encoderCombo.setEditable(False) 65 | self.encoderCombo.addItem("none") 66 | if check_encoder_support("h.264"): 67 | self.encoderCombo.addItem("h.264") 68 | if check_encoder_support("h265"): 69 | self.encoderCombo.addItem("h.265") 70 | if check_encoder_support("av1"): 71 | self.encoderCombo.addItem("av1") 72 | 73 | self.framerateCombo = QComboBox() 74 | self.framerateCombo.setEditable(False) 75 | self.framerateCombo.addItems(["24", "30", "45", "60", "75", "90", "120", "144", "240"]) 76 | 77 | self.bitrateCombo = QComboBox() 78 | self.bitrateCombo.setEditable(False) 79 | self.bitrateCombo.addItems(["250k", "500k", "1M", "2M", "4M", "8M", "16M", "20M", "32M"]) 80 | 81 | self.audioCombo = QComboBox() 82 | self.audioCombo.setEditable(False) 83 | self.audioCombo.addItems(["enable", "disable", "loopback"]) 84 | 85 | self.adaptiveCheck = QCheckBox("Enable Adaptive Bitrate") 86 | 87 | self.displayCombo = QComboBox() 88 | self.displayCombo.setEditable(False) 89 | self.displayCombo.addItems([":0", ":1", ":2"]) 90 | 91 | self.presetCombo = QComboBox() 92 | self.presetCombo.setEditable(False) 93 | self.presetCombo.addItems(["Default", "ultrafast", "superfast", "veryfast", "fast", "medium", "slow", "veryslow", "llhq"]) 94 | 95 | self.gopCombo = QComboBox() 96 | self.gopCombo.setEditable(False) 97 | self.gopCombo.addItems(["5", "10", "15", "20", "30", "45", "60", "90", "120"]) 98 | 99 | self.qpCombo = QComboBox() 100 | self.qpCombo.setEditable(False) 101 | self.qpCombo.addItems(["None", "10", "20", "30", "40", "50"]) 102 | 103 | self.tuneCombo = QComboBox() 104 | self.tuneCombo.setEditable(False) 105 | self.tuneCombo.addItems(["None", "zerolatency", "film", "animation", "grain", "psnr", "ssim", "fastdecode"]) 106 | 107 | self.pixFmtCombo = QComboBox() 108 | self.pixFmtCombo.setEditable(False) 109 | self.pixFmtCombo.addItems(["yuv420p", "yuv422p", "yuv444p", "nv12"]) 110 | 111 | self.debugCheck = QCheckBox("Enable Debug") 112 | 113 | self.secKeyEdit = QLineEdit() 114 | self.secKeyEdit.setPlaceholderText("Enter base64-encoded 32-byte key") 115 | 116 | form_layout.addRow("Encoder:", self.encoderCombo) 117 | form_layout.addRow("Framerate:", self.framerateCombo) 118 | form_layout.addRow("Bitrate:", self.bitrateCombo) 119 | form_layout.addRow("Audio:", self.audioCombo) 120 | form_layout.addRow("Adaptive:", self.adaptiveCheck) 121 | form_layout.addRow("X Display:", self.displayCombo) 122 | form_layout.addRow("Preset:", self.presetCombo) 123 | form_layout.addRow("GOP:", self.gopCombo) 124 | form_layout.addRow("QP:", self.qpCombo) 125 | form_layout.addRow("Tune:", self.tuneCombo) 126 | form_layout.addRow("Pixel Format:", self.pixFmtCombo) 127 | form_layout.addRow("Debug:", self.debugCheck) 128 | form_layout.addRow("Security Key:", self.secKeyEdit) 129 | form_group.setLayout(form_layout) 130 | 131 | button_layout = QHBoxLayout() 132 | self.startButton = QPushButton("Start Host") 133 | self.stopButton = QPushButton("Stop Host") 134 | button_layout.addWidget(self.startButton) 135 | button_layout.addWidget(self.stopButton) 136 | 137 | main_layout.addWidget(form_group) 138 | main_layout.addLayout(button_layout) 139 | main_layout.addStretch() 140 | self.setLayout(main_layout) 141 | 142 | self.startButton.clicked.connect(self.start_host) 143 | self.stopButton.clicked.connect(self.stop_host) 144 | self.host_process = None 145 | 146 | def profileChanged(self, index): 147 | profile = self.profileCombo.currentText() 148 | if profile == "Lowest Latency": 149 | self.encoderCombo.setCurrentText("h.264") 150 | self.framerateCombo.setCurrentText("60") 151 | self.bitrateCombo.setCurrentText("500k") 152 | self.audioCombo.setCurrentText("disable") 153 | self.adaptiveCheck.setChecked(False) 154 | self.displayCombo.setCurrentText(":0") 155 | self.presetCombo.setCurrentText("ultrafast") 156 | self.gopCombo.setCurrentText("10") 157 | self.qpCombo.setCurrentText("None") 158 | self.tuneCombo.setCurrentText("zerolatency") 159 | self.pixFmtCombo.setCurrentText("yuv420p") 160 | elif profile == "Balanced": 161 | self.encoderCombo.setCurrentText("h.264") 162 | self.framerateCombo.setCurrentText("45") 163 | self.bitrateCombo.setCurrentText("4M") 164 | self.audioCombo.setCurrentText("enable") 165 | self.adaptiveCheck.setChecked(True) 166 | self.displayCombo.setCurrentText(":0") 167 | self.presetCombo.setCurrentText("fast") 168 | self.gopCombo.setCurrentText("15") 169 | self.qpCombo.setCurrentText("None") 170 | self.tuneCombo.setCurrentText("film") 171 | self.pixFmtCombo.setCurrentText("yuv420p") 172 | elif profile == "High Quality": 173 | self.encoderCombo.setCurrentText("h.265" if self.encoderCombo.findText("h.265") != -1 else "h.264") 174 | self.framerateCombo.setCurrentText("30") 175 | self.bitrateCombo.setCurrentText("16M") 176 | self.audioCombo.setCurrentText("enable") 177 | self.adaptiveCheck.setChecked(False) 178 | self.displayCombo.setCurrentText(":0") 179 | self.presetCombo.setCurrentText("slow") 180 | self.gopCombo.setCurrentText("30") 181 | self.qpCombo.setCurrentText("None") 182 | self.tuneCombo.setCurrentText("None") 183 | self.pixFmtCombo.setCurrentText("yuv444p") 184 | else: 185 | self.encoderCombo.setCurrentText("none") 186 | self.framerateCombo.setCurrentText("30") 187 | self.bitrateCombo.setCurrentText("8M") 188 | self.audioCombo.setCurrentText("enable") 189 | self.adaptiveCheck.setChecked(False) 190 | self.displayCombo.setCurrentText(":0") 191 | self.presetCombo.setCurrentText("Default") 192 | self.gopCombo.setCurrentText("30") 193 | self.qpCombo.setCurrentText("None") 194 | self.tuneCombo.setCurrentText("None") 195 | self.pixFmtCombo.setCurrentText("yuv420p") 196 | 197 | def start_host(self): 198 | encoder = self.encoderCombo.currentText() 199 | framerate = self.framerateCombo.currentText() 200 | bitrate = self.bitrateCombo.currentText() 201 | audio = self.audioCombo.currentText() 202 | adaptive = self.adaptiveCheck.isChecked() 203 | display = self.displayCombo.currentText() 204 | preset = "" if self.presetCombo.currentText() == "Default" else self.presetCombo.currentText() 205 | gop = self.gopCombo.currentText() 206 | qp = "" if self.qpCombo.currentText() == "None" else self.qpCombo.currentText() 207 | tune = "" if self.tuneCombo.currentText() == "None" else self.tuneCombo.currentText() 208 | pix_fmt = self.pixFmtCombo.currentText() 209 | debug = self.debugCheck.isChecked() 210 | sec_key = self.secKeyEdit.text().strip() 211 | if not sec_key: 212 | self.secKeyEdit.setText("Please enter a valid security key") 213 | return 214 | cmd = [ 215 | sys.executable, "host.py", 216 | "--encoder", encoder, 217 | "--framerate", framerate, 218 | "--bitrate", bitrate, 219 | "--audio", audio, 220 | "--display", display, 221 | "--gop", gop, 222 | "--pix_fmt", pix_fmt, 223 | "--security-key", sec_key 224 | ] 225 | if adaptive: 226 | cmd.append("--adaptive") 227 | if preset: 228 | cmd.extend(["--preset", preset]) 229 | if qp: 230 | cmd.extend(["--qp", qp]) 231 | if tune: 232 | cmd.extend(["--tune", tune]) 233 | if debug: 234 | cmd.append("--debug") 235 | self.stop_host() 236 | self.host_process = subprocess.Popen(cmd, preexec_fn=os.setsid) 237 | 238 | def stop_host(self): 239 | if self.host_process: 240 | try: 241 | os.killpg(self.host_process.pid, signal.SIGTERM) 242 | self.host_process.wait(3) 243 | except Exception: 244 | if self.host_process: 245 | self.host_process.kill() 246 | self.host_process = None 247 | 248 | class ClientTab(QWidget): 249 | def __init__(self, parent=None): 250 | super().__init__(parent) 251 | main_layout = QVBoxLayout() 252 | form_group = QGroupBox("Client Configuration") 253 | form_layout = QFormLayout() 254 | self.decoderCombo = QComboBox() 255 | self.decoderCombo.setEditable(False) 256 | self.decoderCombo.addItem("none") 257 | if check_decoder_support("h.264"): 258 | self.decoderCombo.addItem("h.264") 259 | if check_decoder_support("h265"): 260 | self.decoderCombo.addItem("h.265") 261 | if check_decoder_support("av1"): 262 | self.decoderCombo.addItem("av1") 263 | self.hostIPEdit = QComboBox() 264 | self.hostIPEdit.setEditable(True) 265 | self.audioCombo = QComboBox() 266 | self.audioCombo.setEditable(False) 267 | self.audioCombo.addItems(["enable", "disable"]) 268 | self.monitorField = QLineEdit() 269 | self.monitorField.setText("0") 270 | self.debugCheck = QCheckBox("Enable Debug") 271 | self.secKeyEdit = QLineEdit() 272 | self.secKeyEdit.setPlaceholderText("Enter base64-encoded 32-byte key") 273 | form_layout.addRow("Decoder:", self.decoderCombo) 274 | form_layout.addRow("Host IP:", self.hostIPEdit) 275 | form_layout.addRow("Audio:", self.audioCombo) 276 | form_layout.addRow("Monitor (index or 'all'):", self.monitorField) 277 | form_layout.addRow("Debug:", self.debugCheck) 278 | form_layout.addRow("Security Key:", self.secKeyEdit) 279 | form_group.setLayout(form_layout) 280 | button_layout = QHBoxLayout() 281 | self.startButton = QPushButton("Start Client") 282 | button_layout.addWidget(self.startButton) 283 | main_layout.addWidget(form_group) 284 | main_layout.addLayout(button_layout) 285 | main_layout.addStretch() 286 | self.setLayout(main_layout) 287 | self.startButton.clicked.connect(self.start_client) 288 | 289 | def start_client(self): 290 | decoder = self.decoderCombo.currentText() 291 | host_ip = self.hostIPEdit.currentText() 292 | audio = self.audioCombo.currentText() 293 | monitor = self.monitorField.text() 294 | debug = self.debugCheck.isChecked() 295 | sec_key = self.secKeyEdit.text().strip() 296 | if not sec_key: 297 | self.secKeyEdit.setText("Please enter a valid security key") 298 | return 299 | cmd = [ 300 | sys.executable, "client.py", 301 | "--decoder", decoder, 302 | "--host_ip", host_ip, 303 | "--audio", audio, 304 | "--monitor", monitor, 305 | "--security-key", sec_key 306 | ] 307 | if debug: 308 | cmd.append("--debug") 309 | subprocess.Popen(cmd) 310 | 311 | class HelpTab(QWidget): 312 | def __init__(self, parent=None): 313 | super().__init__(parent) 314 | layout = QVBoxLayout() 315 | help_text = """ 316 |
This application streams your desktop from a host to a client.
318 |Start the Host with your chosen settings and shared security key, then launch the Client to view the stream. The TCP handshake and file transfers are secured via TLS, and control/clipboard UDP messages are encrypted.
353 | """ 354 | from PyQt5.QtWidgets import QTextEdit 355 | help_view = QTextEdit() 356 | help_view.setReadOnly(True) 357 | help_view.setHtml(help_text) 358 | layout.addWidget(help_view) 359 | self.setLayout(layout) 360 | 361 | class StartWindow(QWidget): 362 | def __init__(self): 363 | super().__init__() 364 | self.setWindowTitle("Remote Desktop Viewer (LinuxPlay)") 365 | self.tabs = QTabWidget() 366 | self.hostTab = HostTab() 367 | self.clientTab = ClientTab() 368 | self.helpTab = HelpTab() 369 | self.tabs.addTab(self.hostTab, "Host") 370 | self.tabs.addTab(self.clientTab, "Client") 371 | self.tabs.addTab(self.helpTab, "Help") 372 | main_layout = QVBoxLayout() 373 | main_layout.addWidget(self.tabs) 374 | self.setLayout(main_layout) 375 | 376 | def closeEvent(self, event): 377 | if self.hostTab.host_process: 378 | self.hostTab.stop_host() 379 | event.accept() 380 | 381 | def main(): 382 | application = QApplication(sys.argv) 383 | application.setStyle("Fusion") 384 | from PyQt5.QtGui import QPalette, QColor 385 | palette = application.palette() 386 | palette.setColor(QPalette.Window, QColor(53, 53, 53)) 387 | palette.setColor(QPalette.WindowText, Qt.white) 388 | palette.setColor(QPalette.Base, QColor(35, 35, 35)) 389 | palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) 390 | palette.setColor(QPalette.ToolTipBase, Qt.white) 391 | palette.setColor(QPalette.ToolTipText, Qt.white) 392 | palette.setColor(QPalette.Text, Qt.white) 393 | palette.setColor(QPalette.Button, QColor(53, 53, 53)) 394 | palette.setColor(QPalette.ButtonText, Qt.white) 395 | palette.setColor(QPalette.BrightText, Qt.red) 396 | palette.setColor(QPalette.Link, QColor(42, 130, 218)) 397 | palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) 398 | palette.setColor(QPalette.HighlightedText, Qt.black) 399 | application.setPalette(palette) 400 | window = StartWindow() 401 | window.resize(600, 500) 402 | window.show() 403 | sys.exit(application.exec_()) 404 | 405 | if __name__ == "__main__": 406 | main() 407 | --------------------------------------------------------------------------------