Alvin3y1 commited on
Commit
fed14f7
·
verified ·
1 Parent(s): b83393e

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +342 -0
app.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --- START OF FILE app.py.txt ---
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import time
9
+ import logging
10
+ import concurrent.futures
11
+ import threading
12
+ import numpy as np
13
+ import uuid
14
+ from aiohttp import web
15
+ from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack, RTCIceServer, RTCConfiguration
16
+ from av import VideoFrame
17
+ import mss
18
+
19
+ # --- Configuration ---
20
+ HOST = "0.0.0.0"
21
+ PORT = 7860
22
+ DISPLAY_NUM = ":99"
23
+
24
+ # Xvfb buffer limits
25
+ MAX_WIDTH = 3840
26
+ MAX_HEIGHT = 2160
27
+
28
+ # Initial Resolution
29
+ DEFAULT_WIDTH = 1280
30
+ DEFAULT_HEIGHT = 720
31
+
32
+ # Cloudflare TURN Credentials
33
+ TURN_USER = "g08abe68c81a07f098bb5f0914549bb32440e5aad0b216c7fba2b61e76fd62c6"
34
+ TURN_PASS = "aed1a10dd10eba9401ad9d99e5c66036d8a970eab5ba8e6dc9845ab57c771a7d"
35
+
36
+ logging.basicConfig(level=logging.WARNING)
37
+ logger = logging.getLogger("WebRTC-Plasma")
38
+
39
+ # Increase worker threads for faster I/O
40
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=16)
41
+ thread_local_storage = threading.local()
42
+
43
+ config = {
44
+ "width": DEFAULT_WIDTH,
45
+ "height": DEFAULT_HEIGHT
46
+ }
47
+
48
+ # --- High Performance Input Manager ---
49
+ class InputManager:
50
+ def __init__(self):
51
+ self.process = None
52
+ self.lock = threading.Lock()
53
+ self.scroll_accum = 0
54
+
55
+ def start_process(self):
56
+ # Only start if DISPLAY var is set
57
+ if not os.environ.get("DISPLAY"):
58
+ return
59
+
60
+ try:
61
+ self.process = subprocess.Popen(
62
+ ['xdotool', '-'],
63
+ stdin=subprocess.PIPE,
64
+ encoding='utf-8',
65
+ bufsize=0
66
+ )
67
+ except Exception as e:
68
+ logger.error(f"Failed to start xdotool: {e}")
69
+
70
+ def _send_raw(self, command):
71
+ # Lazy initialization / Restart if dead
72
+ if self.process is None or self.process.poll() is not None:
73
+ self.start_process()
74
+
75
+ if self.process:
76
+ try:
77
+ self.process.stdin.write(command + "\n")
78
+ self.process.stdin.flush()
79
+ except Exception:
80
+ # If write fails, force restart next time
81
+ try: self.process.kill()
82
+ except: pass
83
+ self.process = None
84
+
85
+ def send(self, command):
86
+ with self.lock:
87
+ self._send_raw(command)
88
+
89
+ def scroll(self, dy):
90
+ with self.lock:
91
+ self.scroll_accum += dy
92
+ THRESHOLD = 40
93
+ while self.scroll_accum >= THRESHOLD:
94
+ self._send_raw("click 5")
95
+ self.scroll_accum -= THRESHOLD
96
+ while self.scroll_accum <= -THRESHOLD:
97
+ self._send_raw("click 4")
98
+ self.scroll_accum += THRESHOLD
99
+
100
+ def mouse_move(self, x, y): self.send(f"mousemove {x} {y}")
101
+ def mouse_down(self, btn): self.send(f"mousedown {btn}")
102
+ def mouse_up(self, btn): self.send(f"mouseup {btn}")
103
+ def click(self, btn, repeat=1): self.send(f"click --repeat {repeat} {btn}")
104
+ def key_down(self, key): self.send(f"keydown {key}")
105
+ def key_up(self, key): self.send(f"keyup {key}")
106
+
107
+ input_manager = InputManager()
108
+
109
+ # --- System Management ---
110
+
111
+ def start_system():
112
+ # 1. Setup Environment FIRST
113
+ os.environ["DISPLAY"] = DISPLAY_NUM
114
+ os.environ["KDE_FULL_SESSION"] = "true"
115
+
116
+ if not shutil.which("Xvfb"): raise FileNotFoundError("Xvfb missing")
117
+
118
+ logger.warning(f"Starting Xvfb on {DISPLAY_NUM}...")
119
+ subprocess.Popen([
120
+ "Xvfb", DISPLAY_NUM,
121
+ "-screen", "0", f"{MAX_WIDTH}x{MAX_HEIGHT}x24",
122
+ "-ac", "-noreset"
123
+ ])
124
+
125
+ # 2. Wait for Xvfb to initialize
126
+ time.sleep(3)
127
+
128
+ # 3. Now start xdotool (InputManager)
129
+ input_manager.start_process()
130
+
131
+ # 4. Initialize Resolution
132
+ set_resolution(DEFAULT_WIDTH, DEFAULT_HEIGHT)
133
+
134
+ # 5. Start KDE Plasma Desktop
135
+ logger.warning("Starting KDE Plasma...")
136
+
137
+ # KDE requires a dbus session. We use dbus-launch to wrap the start command.
138
+ plasma_cmd = "dbus-launch --exit-with-session startplasma-x11"
139
+
140
+ # Run in a separate thread/process so it doesn't block the loop,
141
+ # but we don't need a keep-alive loop like a browser; the desktop manages itself.
142
+ subprocess.Popen(plasma_cmd, shell=True)
143
+
144
+ def get_xrandr_output_name():
145
+ try:
146
+ out = subprocess.check_output(["xrandr"]).decode()
147
+ for line in out.splitlines():
148
+ if " connected" in line:
149
+ return line.split()[0]
150
+ except: pass
151
+ return "screen"
152
+
153
+ def get_cvt_modeline(width, height, rate=60):
154
+ H_BLANK = 160
155
+ H_SYNC = 32
156
+ H_FRONT_PORCH = 48
157
+ V_FRONT_PORCH = 3
158
+ V_SYNC = 5
159
+ MIN_V_BLANK = 460
160
+
161
+ frame_time_us = 1000000.0 / rate
162
+ active_time_us = frame_time_us - MIN_V_BLANK
163
+ if active_time_us <= 0: return None
164
+
165
+ h_period_us = active_time_us / height
166
+ v_blank_lines = int(MIN_V_BLANK / h_period_us) + 1
167
+ v_total = height + v_blank_lines
168
+ h_total = width + H_BLANK
169
+ pclk = (h_total * v_total * rate) / 1000000.0
170
+
171
+ h_sync_start = width + H_FRONT_PORCH
172
+ h_sync_end = h_sync_start + H_SYNC
173
+ v_sync_start = height + V_FRONT_PORCH
174
+ v_sync_end = v_sync_start + V_SYNC
175
+
176
+ return f'"{width}x{height}_60.00" {pclk:.2f} {width} {h_sync_start} {h_sync_end} {h_total} {height} {v_sync_start} {v_sync_end} {v_total} +hsync -vsync'
177
+
178
+ def set_resolution(w, h):
179
+ try:
180
+ if w % 2 != 0: w += 1
181
+ if h % 2 != 0: h += 1
182
+
183
+ output = get_xrandr_output_name()
184
+ mode_name = f"WEB_{w}x{h}_{str(uuid.uuid4())[:4]}"
185
+ modeline_str = get_cvt_modeline(w, h)
186
+ if not modeline_str: return
187
+
188
+ parts = modeline_str.split()
189
+ mode_params = parts[1:]
190
+
191
+ subprocess.run(["xrandr", "--newmode", mode_name] + mode_params, check=True)
192
+ subprocess.run(["xrandr", "--addmode", output, mode_name], check=True)
193
+ subprocess.run(["xrandr", "--output", output, "--mode", mode_name], check=True)
194
+
195
+ config["width"] = w
196
+ config["height"] = h
197
+ except Exception as e:
198
+ logger.error(f"Resolution setup failed: {e}")
199
+
200
+ # --- Video Capture ---
201
+ class VirtualScreenTrack(VideoStreamTrack):
202
+ kind = "video"
203
+ def __init__(self):
204
+ super().__init__()
205
+ self.last_frame_time = 0
206
+ self.frame_count = 0
207
+
208
+ def _capture(self):
209
+ try:
210
+ if not hasattr(thread_local_storage, "sct"):
211
+ thread_local_storage.sct = mss.mss()
212
+
213
+ monitor = {"top": 0, "left": 0, "width": config["width"], "height": config["height"]}
214
+ sct_img = thread_local_storage.sct.grab(monitor)
215
+ img = np.array(sct_img)
216
+ return img[..., :3]
217
+ except: return None
218
+
219
+ async def recv(self):
220
+ FPS = 30
221
+ FRAME_TIME = 1.0 / FPS
222
+
223
+ pts, time_base = await self.next_timestamp()
224
+
225
+ current_time = time.time()
226
+ wait = FRAME_TIME - (current_time - self.last_frame_time)
227
+ if wait > 0:
228
+ await asyncio.sleep(wait)
229
+
230
+ self.last_frame_time = time.time()
231
+
232
+ frame = await asyncio.get_event_loop().run_in_executor(executor, self._capture)
233
+
234
+ if frame is None:
235
+ blank = np.zeros((config["height"], config["width"], 3), dtype=np.uint8)
236
+ av_frame = VideoFrame.from_ndarray(blank, format="bgr24")
237
+ else:
238
+ av_frame = VideoFrame.from_ndarray(frame, format="bgr24")
239
+
240
+ av_frame.pts = pts
241
+ av_frame.time_base = time_base
242
+ return av_frame
243
+
244
+ # --- Input Mapping ---
245
+ def map_key(key):
246
+ if key == " ": return "space"
247
+ k = key.lower()
248
+ charmap = {
249
+ "control": "ctrl", "shift": "shift", "alt": "alt", "meta": "super", "cmd": "super",
250
+ "enter": "Return", "backspace": "BackSpace", "tab": "Tab", "escape": "Escape",
251
+ "arrowup": "Up", "arrowdown": "Down", "arrowleft": "Left", "arrowright": "Right",
252
+ "home": "Home", "end": "End", "pageup": "Page_Up", "pagedown": "Page_Down",
253
+ "delete": "Delete", "insert": "Insert",
254
+ "f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6",
255
+ "f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12",
256
+ "!": "exclam", "@": "at", "#": "numbersign", "$": "dollar", "%": "percent",
257
+ "^": "asciicircum", "&": "ampersand", "*": "asterisk", "(": "parenleft",
258
+ ")": "parenright", "-": "minus", "_": "underscore", "=": "equal", "+": "plus",
259
+ "[": "bracketleft", "{": "braceleft", "]": "bracketright", "}": "braceright",
260
+ ";": "semicolon", ":": "colon", "'": "apostrophe", "\"": "quotedbl",
261
+ ",": "comma", "<": "less", ".": "period", ">": "greater", "/": "slash",
262
+ "?": "question", "\\": "backslash", "|": "bar", "`": "grave", "~": "asciitilde",
263
+ " ": "space"
264
+ }
265
+ return charmap.get(k, k)
266
+
267
+ def process_input(data):
268
+ try:
269
+ msg = json.loads(data)
270
+ t = msg.get("type")
271
+
272
+ current_w = config["width"]
273
+ current_h = config["height"]
274
+
275
+ if t == "resize":
276
+ target_w = int(msg.get("width"))
277
+ target_h = int(msg.get("height"))
278
+ set_resolution(target_w, target_h)
279
+
280
+ elif t == "mousemove":
281
+ input_manager.mouse_move(int(msg["x"] * current_w), int(msg["y"] * current_h))
282
+ elif t == "mousedown":
283
+ input_manager.mouse_down({0:1, 1:2, 2:3}.get(msg.get("button"), 1))
284
+ elif t == "mouseup":
285
+ input_manager.mouse_up({0:1, 1:2, 2:3}.get(msg.get("button"), 1))
286
+ elif t == "wheel":
287
+ input_manager.scroll(msg.get("deltaY", 0))
288
+ elif t == "keydown":
289
+ k = map_key(msg.get("key"))
290
+ if k: input_manager.key_down(k)
291
+ elif t == "keyup":
292
+ k = map_key(msg.get("key"))
293
+ if k: input_manager.key_up(k)
294
+
295
+ except Exception:
296
+ pass
297
+
298
+ # --- Routes ---
299
+ async def offer(request):
300
+ try:
301
+ params = await request.json()
302
+ offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
303
+ except: return web.Response(status=400)
304
+
305
+ pc = RTCPeerConnection(RTCConfiguration(iceServers=[
306
+ RTCIceServer(urls=["turns:turn.cloudflare.com:443?transport=tcp", "turn:turn.cloudflare.com:3478?transport=udp"], username=TURN_USER, credential=TURN_PASS),
307
+ RTCIceServer(urls=["stun:stun.l.google.com:19302"])
308
+ ]))
309
+ pcs.add(pc)
310
+
311
+ @pc.on("connectionstatechange")
312
+ async def on_state():
313
+ if pc.connectionState in ["failed", "closed"]:
314
+ await pc.close()
315
+ pcs.discard(pc)
316
+
317
+ @pc.on("datachannel")
318
+ def on_dc(channel):
319
+ channel.on("message", lambda m: asyncio.get_event_loop().run_in_executor(executor, process_input, m))
320
+
321
+ pc.addTrack(VirtualScreenTrack())
322
+ await pc.setRemoteDescription(offer)
323
+ answer = await pc.createAnswer()
324
+ await pc.setLocalDescription(answer)
325
+
326
+ sdp = "\r\n".join([l for l in pc.localDescription.sdp.splitlines() if "a=candidate" not in l or "typ relay" in l]) + "\r\n"
327
+ return web.Response(content_type="application/json", text=json.dumps({"sdp": sdp, "type": pc.localDescription.type}), headers={"Access-Control-Allow-Origin": "*"})
328
+
329
+ async def index(r): return web.Response(text="helloworld")
330
+ async def options(r): return web.Response(headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type"})
331
+
332
+ pcs = set()
333
+ async def on_shutdown(app): await asyncio.gather(*[pc.close() for pc in pcs])
334
+
335
+ if __name__ == "__main__":
336
+ start_system()
337
+ app = web.Application()
338
+ app.on_shutdown.append(on_shutdown)
339
+ app.router.add_get("/", index)
340
+ app.router.add_post("/offer", offer)
341
+ app.router.add_options("/offer", options)
342
+ web.run_app(app, host=HOST, port=PORT)