Alvin3y1 commited on
Commit
9811009
·
verified ·
1 Parent(s): 83fadff

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +236 -154
app.py CHANGED
@@ -5,36 +5,176 @@ import shutil
5
  import subprocess
6
  import time
7
  import logging
 
8
  import threading
 
9
  import uuid
10
- import uvloop
11
-
12
- # Enable high-performance event loop
13
- asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
14
-
15
  from aiohttp import web
16
- from aiortc import RTCPeerConnection, RTCSessionDescription, RTCIceServer, RTCConfiguration
17
- from aiortc.contrib.media import MediaPlayer
 
18
 
19
- # --- Configuration ---
20
  HOST = "0.0.0.0"
21
  PORT = 7860
22
  DISPLAY_NUM = ":99"
23
 
24
- # Resolution Config
25
- # Note: Changing resolution dynamically with x11grab is complex.
26
- # We lock capture to these dimensions for stability.
27
- WIDTH = 1280
28
- HEIGHT = 720
29
 
30
- # Cloudflare TURN Credentials
31
  TURN_USER = "g08abe68c81a07f098bb5f0914549bb32440e5aad0b216c7fba2b61e76fd62c6"
32
  TURN_PASS = "aed1a10dd10eba9401ad9d99e5c66036d8a970eab5ba8e6dc9845ab57c771a7d"
33
 
34
- logging.basicConfig(level=logging.WARNING)
35
- logger = logging.getLogger("WebRTC-Fast")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- # --- High Performance Input Manager ---
38
  class InputManager:
39
  def __init__(self):
40
  self.process = None
@@ -44,33 +184,28 @@ class InputManager:
44
  def start_process(self):
45
  if not os.environ.get("DISPLAY"): return
46
  try:
47
- # - is stdin
48
- self.process = subprocess.Popen(
49
- ['xdotool', '-'],
50
- stdin=subprocess.PIPE,
51
- encoding='utf-8',
52
- bufsize=0
53
- )
54
- except Exception as e:
55
- logger.error(f"Failed to start xdotool: {e}")
56
 
57
  def _send_raw(self, command):
58
- if self.process is None or self.process.poll() is not None:
59
- self.start_process()
60
-
61
  if self.process:
62
  try:
63
  self.process.stdin.write(command + "\n")
64
  self.process.stdin.flush()
65
- except Exception:
66
  try: self.process.kill()
67
  except: pass
68
  self.process = None
69
 
70
  def send(self, command):
71
- with self.lock:
72
- self._send_raw(command)
73
 
 
 
 
 
 
74
  def scroll(self, dy):
75
  with self.lock:
76
  self.scroll_accum += dy
@@ -82,103 +217,85 @@ class InputManager:
82
  self._send_raw("click 4")
83
  self.scroll_accum += THRESHOLD
84
 
85
- def mouse_move(self, x, y): self.send(f"mousemove {x} {y}")
86
- def mouse_down(self, btn): self.send(f"mousedown {btn}")
87
- def mouse_up(self, btn): self.send(f"mouseup {btn}")
88
- def key_down(self, key): self.send(f"keydown {key}")
89
- def key_up(self, key): self.send(f"keyup {key}")
90
-
91
  input_manager = InputManager()
92
 
93
- # --- System Management ---
94
  def start_system():
95
  os.environ["DISPLAY"] = DISPLAY_NUM
96
-
97
  if not shutil.which("Xvfb"): raise FileNotFoundError("Xvfb missing")
98
 
99
  logger.warning(f"Starting Xvfb on {DISPLAY_NUM}...")
100
- # Start Xvfb
101
- subprocess.Popen([
102
- "Xvfb", DISPLAY_NUM,
103
- "-screen", "0", f"{WIDTH}x{HEIGHT}x24",
104
- "-ac", "-noreset"
105
- ])
106
 
107
- time.sleep(2) # Wait for X11
108
-
109
- # Start Window Manager
110
  if shutil.which("matchbox-window-manager"):
111
  subprocess.Popen("matchbox-window-manager -use_titlebar no", shell=True)
112
 
113
- # Initialize Input
114
- input_manager.start_process()
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- # Start Browser
117
- threading.Thread(target=keep_chrome_alive, daemon=True).start()
118
-
119
- def keep_chrome_alive():
120
- chrome_cmd = (
121
- "google-chrome "
122
- "--no-sandbox "
123
- "--start-maximized "
124
- "--user-data-dir=/home/user/chrome-data "
125
- "--disable-infobars "
126
- "--disable-dev-shm-usage "
127
- "--disable-gpu " # Software rendering is often more stable for pure Xvfb capture
128
- "--no-first-run "
129
- "--no-default-browser-check "
130
- "--window-position=0,0 "
131
- f"--window-size={WIDTH},{HEIGHT}"
132
- )
133
- while True:
134
  try:
135
- subprocess.run(chrome_cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
136
- time.sleep(1)
137
- except: time.sleep(2)
138
-
139
- # --- Input Mapping ---
140
- def map_key(key):
141
- if key == " ": return "space"
142
- k = key.lower()
143
- charmap = {
144
- "control": "ctrl", "shift": "shift", "alt": "alt", "meta": "super", "cmd": "super",
145
- "enter": "Return", "backspace": "BackSpace", "tab": "Tab", "escape": "Escape",
146
- "arrowup": "Up", "arrowdown": "Down", "arrowleft": "Left", "arrowright": "Right",
147
- "delete": "Delete", "insert": "Insert",
148
- "!": "exclam", "@": "at", "#": "numbersign", "$": "dollar", "%": "percent",
149
- "^": "asciicircum", "&": "ampersand", "*": "asterisk", "(": "parenleft",
150
- ")": "parenright", "-": "minus", "_": "underscore", "=": "equal", "+": "plus",
151
- "[": "bracketleft", "{": "braceleft", "]": "bracketright", "}": "braceright",
152
- ";": "semicolon", ":": "colon", "'": "apostrophe", "\"": "quotedbl",
153
- ",": "comma", "<": "less", ".": "period", ">": "greater", "/": "slash",
154
- "?": "question", "\\": "backslash", "|": "bar", "`": "grave", "~": "asciitilde",
155
- " ": "space"
156
- }
157
- return charmap.get(k, k)
 
 
 
158
 
159
  def process_input(data):
160
  try:
161
  msg = json.loads(data)
162
  t = msg.get("type")
 
163
 
164
- # Mapping normalized coordinates (0.0 - 1.0) back to pixels
165
- if t == "mousemove":
166
- input_manager.mouse_move(int(msg["x"] * WIDTH), int(msg["y"] * HEIGHT))
167
- elif t == "mousedown":
 
168
  input_manager.mouse_down({0:1, 1:2, 2:3}.get(msg.get("button"), 1))
169
- elif t == "mouseup":
170
  input_manager.mouse_up({0:1, 1:2, 2:3}.get(msg.get("button"), 1))
 
 
 
 
171
  elif t == "wheel":
172
  input_manager.scroll(msg.get("deltaY", 0))
173
- elif t == "keydown":
174
- k = map_key(msg.get("key"))
175
- if k: input_manager.key_down(k)
176
- elif t == "keyup":
177
- k = map_key(msg.get("key"))
178
- if k: input_manager.key_up(k)
179
- except Exception: pass
180
-
181
- # --- WebRTC Routes ---
182
 
183
  async def offer(request):
184
  try:
@@ -186,80 +303,45 @@ async def offer(request):
186
  offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
187
  except: return web.Response(status=400)
188
 
189
- # 1. Create Peer Connection
190
  pc = RTCPeerConnection(RTCConfiguration(iceServers=[
191
  RTCIceServer(urls=["turns:turn.cloudflare.com:443?transport=tcp", "turn:turn.cloudflare.com:3478?transport=udp"], username=TURN_USER, credential=TURN_PASS),
192
- RTCIceServer(urls=["stun:stun.l.google.com:19302"])
193
  ]))
194
-
195
- # 2. Configure FFmpeg Options for Low Latency
196
- # These flags tell FFmpeg to capture X11 directly and encode immediately
197
- options = {
198
- "framerate": "30",
199
- "video_size": f"{WIDTH}x{HEIGHT}",
200
- "probesize": "32", # Fast startup
201
- "analyzeduration": "0", # No delay for analysis
202
- "fflags": "nobuffer", # Reduce buffer latency
203
- "preset": "ultrafast", # Fastest encoding (trades CPU for bitrate, but low lag)
204
- "tune": "zerolatency", # Critical for realtime
205
- "threads": "4" # Use multi-threading
206
- }
207
-
208
- # 3. Create MediaPlayer (Replaces manual VideoTrack)
209
- # This runs in a separate C-thread, bypassing Python GIL issues
210
- player = MediaPlayer(DISPLAY_NUM, format="x11grab", options=options)
211
-
212
- # Add track to PC
213
- pc.addTrack(player.video)
214
  pcs.add(pc)
215
 
216
- # Lifecycle management
217
  @pc.on("connectionstatechange")
218
  async def on_state():
219
  if pc.connectionState in ["failed", "closed"]:
220
  await pc.close()
221
- # Ensure ffmpeg process is killed
222
- if player:
223
- player.video.stop()
224
  pcs.discard(pc)
225
 
226
  @pc.on("datachannel")
227
  def on_dc(channel):
228
- # Offload input processing to default thread executor to avoid blocking async loop
229
- channel.on("message", lambda m: asyncio.get_event_loop().run_in_executor(None, process_input, m))
230
-
231
- # SDP Negotiation
 
 
 
 
 
232
  await pc.setRemoteDescription(offer)
233
  answer = await pc.createAnswer()
234
  await pc.setLocalDescription(answer)
 
235
 
236
- # Clean SDP for faster connection
237
- 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"
238
-
239
- return web.Response(
240
- content_type="application/json",
241
- text=json.dumps({"sdp": sdp, "type": pc.localDescription.type}),
242
- headers={"Access-Control-Allow-Origin": "*"}
243
- )
244
-
245
- async def index(r): return web.Response(text="WebRTC X11 Streamer Ready")
246
  async def options(r): return web.Response(headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type"})
247
 
248
- # --- Cleanup ---
249
  pcs = set()
250
- async def on_shutdown(app):
251
- coros = [pc.close() for pc in pcs]
252
- await asyncio.gather(*coros)
253
 
254
- # --- Entry Point ---
255
  if __name__ == "__main__":
256
  start_system()
257
-
258
  app = web.Application()
259
  app.on_shutdown.append(on_shutdown)
260
  app.router.add_get("/", index)
261
  app.router.add_post("/offer", offer)
262
  app.router.add_options("/offer", options)
263
-
264
- logger.warning(f"Server starting on {HOST}:{PORT}")
265
  web.run_app(app, host=HOST, port=PORT)
 
5
  import subprocess
6
  import time
7
  import logging
8
+ import concurrent.futures
9
  import threading
10
+ import numpy as np
11
  import uuid
12
+ import pty
13
+ import fcntl
14
+ import termios
15
+ import struct
 
16
  from aiohttp import web
17
+ from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack, RTCIceServer, RTCConfiguration
18
+ from av import VideoFrame
19
+ import mss
20
 
 
21
  HOST = "0.0.0.0"
22
  PORT = 7860
23
  DISPLAY_NUM = ":99"
24
 
25
+ MAX_WIDTH = 3840
26
+ MAX_HEIGHT = 2160
27
+
28
+ DEFAULT_WIDTH = 1280
29
+ DEFAULT_HEIGHT = 720
30
 
 
31
  TURN_USER = "g08abe68c81a07f098bb5f0914549bb32440e5aad0b216c7fba2b61e76fd62c6"
32
  TURN_PASS = "aed1a10dd10eba9401ad9d99e5c66036d8a970eab5ba8e6dc9845ab57c771a7d"
33
 
34
+ logging.basicConfig(level=logging.WARNING)
35
+ logger = logging.getLogger("WebRTC-System")
36
+
37
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=16)
38
+ thread_local_storage = threading.local()
39
+
40
+ config = {
41
+ "width": DEFAULT_WIDTH,
42
+ "height": DEFAULT_HEIGHT
43
+ }
44
+
45
+ class ClipboardManager:
46
+ def __init__(self):
47
+ self.last_content = ""
48
+ self.channels = set()
49
+ self.lock = threading.Lock()
50
+ self.running = True
51
+ self.thread = threading.Thread(target=self.monitor_loop, daemon=True)
52
+ self.thread.start()
53
+
54
+ def add_channel(self, channel):
55
+ with self.lock:
56
+ self.channels.add(channel)
57
+
58
+ def remove_channel(self, channel):
59
+ with self.lock:
60
+ self.channels.discard(channel)
61
+
62
+ def update_x11(self, content):
63
+ if content == self.last_content:
64
+ return
65
+
66
+ self.last_content = content
67
+ try:
68
+ env = os.environ.copy()
69
+ env["DISPLAY"] = DISPLAY_NUM
70
+
71
+ p = subprocess.Popen(
72
+ ['xclip', '-i', '-selection', 'clipboard'],
73
+ stdin=subprocess.PIPE,
74
+ env=env
75
+ )
76
+ p.communicate(input=content.encode('utf-8'))
77
+ except Exception as e:
78
+ logger.error(f"Clipboard Write Error: {e}")
79
+
80
+ def monitor_loop(self):
81
+ while self.running:
82
+ try:
83
+ if not os.environ.get("DISPLAY"):
84
+ os.environ["DISPLAY"] = DISPLAY_NUM
85
+
86
+ try:
87
+ curr = subprocess.check_output(
88
+ ['xclip', '-o', '-selection', 'clipboard'],
89
+ stderr=subprocess.DEVNULL,
90
+ timeout=0.5
91
+ ).decode('utf-8')
92
+ except:
93
+ curr = ""
94
+
95
+ if curr and curr != self.last_content:
96
+ self.last_content = curr
97
+ self.broadcast(curr)
98
+
99
+ except Exception:
100
+ pass
101
+ time.sleep(1.0)
102
+
103
+ def broadcast(self, content):
104
+ msg = json.dumps({"type": "clipboard", "content": content})
105
+ with self.lock:
106
+ for ch in list(self.channels):
107
+ try:
108
+ if ch.readyState == "open":
109
+ ch.send(msg)
110
+ else:
111
+ self.channels.discard(ch)
112
+ except:
113
+ self.channels.discard(ch)
114
+
115
+ clipboard_manager = ClipboardManager()
116
+
117
+ class Terminal:
118
+ def __init__(self, channel):
119
+ self.channel = channel
120
+ self.master_fd, self.slave_fd = pty.openpty()
121
+ self.pid = os.fork()
122
+
123
+ if self.pid == 0:
124
+ os.close(self.master_fd)
125
+ os.setsid()
126
+ os.dup2(self.slave_fd, 0)
127
+ os.dup2(self.slave_fd, 1)
128
+ os.dup2(self.slave_fd, 2)
129
+ if self.slave_fd > 2:
130
+ os.close(self.slave_fd)
131
+
132
+ os.environ["TERM"] = "xterm-256color"
133
+ os.environ["DISPLAY"] = DISPLAY_NUM
134
+ os.environ["HOME"] = "/home/user"
135
+
136
+ shell = "/bin/bash"
137
+ os.execl(shell, shell)
138
+ else:
139
+ os.close(self.slave_fd)
140
+ self.loop = asyncio.get_running_loop()
141
+ self.loop.add_reader(self.master_fd, self.read_output)
142
+ self.channel.on("close", self.close)
143
+
144
+ def read_output(self):
145
+ try:
146
+ data = os.read(self.master_fd, 4096)
147
+ if data:
148
+ self.channel.send(data.decode('utf-8', 'ignore'))
149
+ except OSError:
150
+ self.close()
151
+
152
+ def write_input(self, data):
153
+ if self.master_fd:
154
+ try:
155
+ if data.startswith('{"type":"resize"'):
156
+ try:
157
+ cmd = json.loads(data)
158
+ self.resize(cmd.get("rows", 24), cmd.get("cols", 80))
159
+ except: pass
160
+ else:
161
+ os.write(self.master_fd, data.encode('utf-8'))
162
+ except Exception as e:
163
+ logger.error(f"PTY Write Error: {e}")
164
+
165
+ def resize(self, rows, cols):
166
+ try:
167
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
168
+ fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
169
+ except: pass
170
+
171
+ def close(self):
172
+ try:
173
+ self.loop.remove_reader(self.master_fd)
174
+ os.close(self.master_fd)
175
+ os.kill(self.pid, 9)
176
+ except: pass
177
 
 
178
  class InputManager:
179
  def __init__(self):
180
  self.process = None
 
184
  def start_process(self):
185
  if not os.environ.get("DISPLAY"): return
186
  try:
187
+ self.process = subprocess.Popen(['xdotool', '-'], stdin=subprocess.PIPE, encoding='utf-8', bufsize=0)
188
+ except Exception as e: logger.error(f"Failed to start xdotool: {e}")
 
 
 
 
 
 
 
189
 
190
  def _send_raw(self, command):
191
+ if self.process is None or self.process.poll() is not None: self.start_process()
 
 
192
  if self.process:
193
  try:
194
  self.process.stdin.write(command + "\n")
195
  self.process.stdin.flush()
196
+ except:
197
  try: self.process.kill()
198
  except: pass
199
  self.process = None
200
 
201
  def send(self, command):
202
+ with self.lock: self._send_raw(command)
 
203
 
204
+ def mouse_move(self, x, y): self.send(f"mousemove {x} {y}")
205
+ def mouse_down(self, btn): self.send(f"mousedown {btn}")
206
+ def mouse_up(self, btn): self.send(f"mouseup {btn}")
207
+ def key_down(self, key): self.send(f"keydown {key}")
208
+ def key_up(self, key): self.send(f"keyup {key}")
209
  def scroll(self, dy):
210
  with self.lock:
211
  self.scroll_accum += dy
 
217
  self._send_raw("click 4")
218
  self.scroll_accum += THRESHOLD
219
 
 
 
 
 
 
 
220
  input_manager = InputManager()
221
 
 
222
  def start_system():
223
  os.environ["DISPLAY"] = DISPLAY_NUM
 
224
  if not shutil.which("Xvfb"): raise FileNotFoundError("Xvfb missing")
225
 
226
  logger.warning(f"Starting Xvfb on {DISPLAY_NUM}...")
227
+ subprocess.Popen(["Xvfb", DISPLAY_NUM, "-screen", "0", f"{MAX_WIDTH}x{MAX_HEIGHT}x24", "-ac", "-noreset"])
228
+ time.sleep(2)
229
+ input_manager.start_process()
230
+ set_resolution(DEFAULT_WIDTH, DEFAULT_HEIGHT)
 
 
231
 
 
 
 
232
  if shutil.which("matchbox-window-manager"):
233
  subprocess.Popen("matchbox-window-manager -use_titlebar no", shell=True)
234
 
235
+ def set_resolution(w, h):
236
+ try:
237
+ if w % 2 != 0: w += 1
238
+ if h % 2 != 0: h += 1
239
+ config["width"] = w
240
+ config["height"] = h
241
+ except: pass
242
+
243
+ class VirtualScreenTrack(VideoStreamTrack):
244
+ kind = "video"
245
+ def __init__(self):
246
+ super().__init__()
247
+ self.last_frame_time = 0
248
 
249
+ def _capture(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  try:
251
+ if not hasattr(thread_local_storage, "sct"):
252
+ thread_local_storage.sct = mss.mss()
253
+ monitor = {"top": 0, "left": 0, "width": config["width"], "height": config["height"]}
254
+ return np.array(thread_local_storage.sct.grab(monitor))[..., :3]
255
+ except: return None
256
+
257
+ async def recv(self):
258
+ FPS = 30
259
+ FRAME_TIME = 1.0 / FPS
260
+ pts, time_base = await self.next_timestamp()
261
+
262
+ current_time = time.time()
263
+ wait = FRAME_TIME - (current_time - self.last_frame_time)
264
+ if wait > 0: await asyncio.sleep(wait)
265
+ self.last_frame_time = time.time()
266
+
267
+ frame = await asyncio.get_event_loop().run_in_executor(executor, self._capture)
268
+ if frame is None:
269
+ blank = np.zeros((config["height"], config["width"], 3), dtype=np.uint8)
270
+ av_frame = VideoFrame.from_ndarray(blank, format="bgr24")
271
+ else:
272
+ av_frame = VideoFrame.from_ndarray(frame, format="bgr24")
273
+
274
+ av_frame.pts = pts
275
+ av_frame.time_base = time_base
276
+ return av_frame
277
 
278
  def process_input(data):
279
  try:
280
  msg = json.loads(data)
281
  t = msg.get("type")
282
+ w, h = config["width"], config["height"]
283
 
284
+ if t == "clipboard":
285
+ clipboard_manager.update_x11(msg.get("content", ""))
286
+ elif t == "mousemove":
287
+ input_manager.mouse_move(int(msg["x"]*w), int(msg["y"]*h))
288
+ elif t == "mousedown":
289
  input_manager.mouse_down({0:1, 1:2, 2:3}.get(msg.get("button"), 1))
290
+ elif t == "mouseup":
291
  input_manager.mouse_up({0:1, 1:2, 2:3}.get(msg.get("button"), 1))
292
+ elif t == "keydown":
293
+ input_manager.key_down(msg.get("key"))
294
+ elif t == "keyup":
295
+ input_manager.key_up(msg.get("key"))
296
  elif t == "wheel":
297
  input_manager.scroll(msg.get("deltaY", 0))
298
+ except: pass
 
 
 
 
 
 
 
 
299
 
300
  async def offer(request):
301
  try:
 
303
  offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
304
  except: return web.Response(status=400)
305
 
 
306
  pc = RTCPeerConnection(RTCConfiguration(iceServers=[
307
  RTCIceServer(urls=["turns:turn.cloudflare.com:443?transport=tcp", "turn:turn.cloudflare.com:3478?transport=udp"], username=TURN_USER, credential=TURN_PASS),
308
+ RTCIceServer(urls=["stun:stun.l.google.com:19302"])
309
  ]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  pcs.add(pc)
311
 
 
312
  @pc.on("connectionstatechange")
313
  async def on_state():
314
  if pc.connectionState in ["failed", "closed"]:
315
  await pc.close()
 
 
 
316
  pcs.discard(pc)
317
 
318
  @pc.on("datachannel")
319
  def on_dc(channel):
320
+ if channel.label == "input":
321
+ clipboard_manager.add_channel(channel)
322
+ channel.on("message", lambda m: asyncio.get_event_loop().run_in_executor(executor, process_input, m))
323
+ channel.on("close", lambda: clipboard_manager.remove_channel(channel))
324
+ elif channel.label == "terminal":
325
+ term = Terminal(channel)
326
+ channel.on("message", term.write_input)
327
+
328
+ pc.addTrack(VirtualScreenTrack())
329
  await pc.setRemoteDescription(offer)
330
  answer = await pc.createAnswer()
331
  await pc.setLocalDescription(answer)
332
+ return web.Response(content_type="application/json", text=json.dumps({"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}), headers={"Access-Control-Allow-Origin": "*"})
333
 
334
+ async def index(r): return web.Response(text="WebRTC Backend Running")
 
 
 
 
 
 
 
 
 
335
  async def options(r): return web.Response(headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type"})
336
 
 
337
  pcs = set()
338
+ async def on_shutdown(app): await asyncio.gather(*[pc.close() for pc in pcs])
 
 
339
 
 
340
  if __name__ == "__main__":
341
  start_system()
 
342
  app = web.Application()
343
  app.on_shutdown.append(on_shutdown)
344
  app.router.add_get("/", index)
345
  app.router.add_post("/offer", offer)
346
  app.router.add_options("/offer", options)
 
 
347
  web.run_app(app, host=HOST, port=PORT)