THEZYZSTUDIO commited on
Commit
bf7bc37
·
verified ·
1 Parent(s): 6cc3ef0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +205 -197
app.py CHANGED
@@ -1,40 +1,48 @@
1
  """
2
- THE Z AI — Computer Mode Server v8STABLE & FAST
3
- ====================================================
4
- التحسينات الجوهرية في v8:
5
- 1. Xvfb واحد فقط ثابت على :99 — يُشغَّل مرة واحدة عند بدء السيرفر
6
- بدلاً من Xvfb جديد لكل اتصال (كان يفشل في HuggingFace)
7
- 2. عزل المستخدمين عبر profile منفصل لكل جلسة (firefox --profile /tmp/profile_N)
8
- بدلاً من عزل DISPLAY منفصل (الذي كان غير ممكن في HF)
9
- 3. مجموعة screenshot موثوقة: scrot import ffmpeg PIL Xlib
10
- مع retry تلقائي وتحقق من أن الصورة ليست سوداء تماماً (black_screen_check)
11
- 4. Playwright اختياري إذا كان متاحاً يُستخدم للتحكم الأسرع
12
- وإن لم يكن متاحاً، يُعود لـ xdotool كالمعتاد
13
- 5. WebSocket heartbeat — ping/pong كل 20 ثانية لمنع قطع الاتصال
14
- 6. Screenshot delta compression إذا الصورة لم تتغير بأكثر من 3% لا تُرسل
15
- (يوفر bandwidth خصوصاً عند عمليات keyboard_type)
16
- 7. Frame buffer مشترك محمي بـ asyncio.Lock لا تعارض بين الجلسات
17
- 8. Rate limiting لـ screenshot: لا أكثر من 1 كل 0.3 ثانية لنفس الجلسة
18
- 9. auto_shot_grid يُنفَّذ في background task (لا يُجمّد الـ action loop)
19
- 10. أوامر terminal تُنفَّذ في asyncio.Semaphore(4) لمنع تزاحم العمليات
20
- 11. SafeSearch مفروضة على كل URL في كل مكان
21
- 12. تنظيف /tmp تلقائي كل 5 دقائق (يمنع امتلاء القرص في HF المجاني)
 
 
 
 
 
 
 
 
 
 
 
 
22
  """
23
 
24
  import asyncio
25
  import base64
26
- import contextvars
27
  import hashlib
28
  import io
29
  import json
30
  import os
31
  import re
32
  import subprocess
33
- import tempfile
34
  import time
35
  import urllib.parse
36
- import uuid
37
- from pathlib import Path
38
 
39
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
40
  from fastapi.middleware.cors import CORSMiddleware
@@ -52,15 +60,7 @@ os.environ["DISPLAY"] = SHARED_DISPLAY
52
  # semaphore للتحكم في عدد عمليات terminal المتوازية
53
  _terminal_sem = asyncio.Semaphore(4)
54
 
55
- # lock لحماية كتابة ملفات /tmp من التعارض
56
- _tmp_lock = asyncio.Lock()
57
-
58
- # ── contextvar للـ profile الخاص بالجلسة ──
59
- _current_profile: contextvars.ContextVar[str] = contextvars.ContextVar(
60
- "_current_profile", default=""
61
- )
62
-
63
- # ── تخزين الجلسات: session_id → {profile_dir, last_screenshot_ts, last_frame_hash} ──
64
  _active_sessions: dict[int, dict] = {}
65
  _sessions_lock = asyncio.Lock()
66
 
@@ -138,30 +138,25 @@ print(f"🌐 Browser: {BROWSER}")
138
 
139
  async def create_session(ws: WebSocket) -> dict:
140
  """
141
- ينشئ profile firefox منفصل لهذا المستخدم.
142
- كل مستخدم يحصل على profile نظيف خاص به في /tmp/zpc_XXXXX/
143
- لمنع التداخل بين جلسات متعددة على نفس DISPLAY.
144
  """
145
  sid = id(ws)
146
- profile_dir = f"/tmp/zpc_{sid}"
147
- os.makedirs(profile_dir, exist_ok=True)
148
-
149
  sess = {
150
  "id": sid,
151
- "profile": profile_dir,
152
- "last_shot_ts": 0.0,
153
- "last_frame_hash": "",
154
  "browser_proc": None,
155
  "created": time.time(),
156
  }
157
  async with _sessions_lock:
158
  _active_sessions[sid] = sess
159
- print(f"[session] ✅ Created session {sid} profile={profile_dir}")
160
  return sess
161
 
162
 
163
  async def destroy_session(ws: WebSocket):
164
- """يحذف profile الجلسة وينظّف الموارد."""
165
  sid = id(ws)
166
  async with _sessions_lock:
167
  sess = _active_sessions.pop(sid, None)
@@ -169,7 +164,6 @@ async def destroy_session(ws: WebSocket):
169
  return
170
 
171
  def _cleanup():
172
- # أغلق المتصفح إذا كان مفتوحاً
173
  bp = sess.get("browser_proc")
174
  if bp:
175
  try:
@@ -180,27 +174,15 @@ async def destroy_session(ws: WebSocket):
180
  bp.kill()
181
  except Exception:
182
  pass
183
- # احذف profile
184
- import shutil
185
- try:
186
- shutil.rmtree(sess["profile"], ignore_errors=True)
187
- except Exception:
188
- pass
189
 
190
  await asyncio.to_thread(_cleanup)
191
  print(f"[session] 🗑️ Destroyed session {sid}")
192
 
193
 
194
- def get_browser_cmd(url: str = "", profile: str = "") -> list:
195
- """يبني أمر فتح المتصفح مع profile منفصل لكل مستخدم."""
196
- cmd = [BROWSER]
197
- if profile:
198
- cmd += ["--profile", profile]
199
- cmd += ["--no-remote"]
200
- if url:
201
- cmd += [url]
202
- else:
203
- cmd += ["about:blank"]
204
  return cmd
205
 
206
 
@@ -224,9 +206,8 @@ def _is_black_screen(img) -> bool:
224
 
225
  def _capture_raw() -> tuple:
226
  """
227
- يلتقط الشاشة بـ 4 طرق متسلسلة.
228
  يُعيد (PIL.Image | None, width, height).
229
- ملاحظة: كل ملفات /tmp تُحذف دائماً (finally block).
230
  """
231
  from PIL import Image
232
  env = {**os.environ, "DISPLAY": SHARED_DISPLAY}
@@ -257,93 +238,96 @@ def _capture_raw() -> tuple:
257
  except Exception:
258
  pass
259
 
260
- try:
261
- # ── Method 1: scrot (fastest) ──────────────────────
262
- p1 = f"{tmp_base}_scrot.png"
263
- created_files.append(p1)
 
264
  try:
265
- r = subprocess.run(
266
- ["scrot", "-q", "95", p1],
267
- env=env, timeout=8, capture_output=True
268
- )
269
- img, w, h = _try_load(p1)
270
- if img and not _is_black_screen(img):
271
- print("[cap] scrot")
272
- return img, w, h
273
- if img:
274
- print("[cap] ⚠️ scrot got black screen — trying next")
275
- except Exception as e:
276
- print(f"[cap] scrot: {e}")
 
 
 
 
277
 
278
- # ── Method 2: ImageMagick import ────────────────────
279
- p2 = f"{tmp_base}_im.png"
280
- created_files.append(p2)
281
- try:
282
- r = subprocess.run(
283
- ["import", "-window", "root", "-silent", p2],
284
- env=env, timeout=10, capture_output=True
285
- )
286
- img, w, h = _try_load(p2)
287
- if img and not _is_black_screen(img):
288
- print("[cap] ✅ ImageMagick import")
289
- return img, w, h
290
- except Exception as e:
291
- print(f"[cap] import: {e}")
292
 
293
- # ── Method 3: ffmpeg x11grab ────────────────────────
294
- p3 = f"{tmp_base}_ff.png"
295
- created_files.append(p3)
296
- try:
297
- sw, sh = _get_screen_size()
298
- r = subprocess.run([
299
- "ffmpeg", "-y", "-f", "x11grab",
300
- "-video_size", f"{sw}x{sh}",
301
- "-i", SHARED_DISPLAY,
302
- "-vframes", "1", "-q:v", "1", p3
303
- ], env=env, timeout=12, capture_output=True)
304
- img, w, h = _try_load(p3)
305
- if img and not _is_black_screen(img):
306
- print("[cap] ✅ ffmpeg")
307
- return img, w, h
308
- except Exception as e:
309
- print(f"[cap] ffmpeg: {e}")
310
 
311
- # ── Method 4: python-xlib ────────────────────────────
312
- try:
313
- from Xlib import display as Xdisp, X
314
- import struct
315
- xd = Xdisp.Display(SHARED_DISPLAY)
316
- root = xd.screen().root
317
- geom = root.get_geometry()
318
- w, h = geom.width, geom.height
319
- raw = root.get_image(0, 0, w, h, X.ZPixmap, 0xFFFFFFFF)
320
- data = raw.data
321
- pixels = bytearray(len(data) // 4 * 3)
322
- for i in range(0, len(data) - 3, 4):
323
- b, g, r_c = data[i], data[i+1], data[i+2]
324
- j = (i // 4) * 3
325
- pixels[j], pixels[j+1], pixels[j+2] = r_c, g, b
326
- from PIL import Image as PILImg
327
- img = PILImg.frombytes("RGB", (w, h), bytes(pixels))
328
- if not _is_black_screen(img):
329
- print("[cap] xlib")
330
- return img, w, h
331
- except Exception as e:
332
- print(f"[cap] xlib: {e}")
333
-
334
- # ── Fallback: placeholder ────────────────────────────
335
- print("[cap] ⚠️ All methods failed or black — returning placeholder")
336
- from PIL import Image as PILImg, ImageDraw
337
- sw, sh = _get_screen_size()
338
- img = PILImg.new("RGB", (sw or 1920, sh or 1080), (15, 15, 25))
339
- draw = ImageDraw.Draw(img)
340
- draw.rectangle([(0, 0), (sw, 50)], fill=(30, 30, 60))
341
- draw.text((10, 15), f"⚠️ Screenshot failed — DISPLAY={SHARED_DISPLAY}", fill=(255, 100, 100))
342
- draw.text((10, 35), f"Methods tried: scrot, import, ffmpeg, xlib", fill=(120, 120, 120))
343
- return img, sw or 1920, sh or 1080
344
 
345
- finally:
346
- _cleanup_files()
 
 
 
 
 
 
 
 
 
 
 
347
 
348
 
349
  def _get_screen_size() -> tuple:
@@ -663,29 +647,33 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
663
  except Exception:
664
  pass
665
 
666
- async def shot(label: str = "", delay: float = 0.5,
667
- force_mx: int | None = None, force_my: int | None = None):
668
- """لقطة شاشة مع grid — مع rate limiting و delta suppression."""
 
 
 
 
669
  await asyncio.sleep(delay)
670
 
671
- # Rate limit: لا أكثر من لقطة كل 0.3 ثانية
672
  now = time.time()
673
- if now - sess.get("last_shot_ts", 0) < 0.3:
674
- await asyncio.sleep(0.3 - (now - sess["last_shot_ts"]))
675
 
676
  result = await asyncio.to_thread(
677
  capture_with_grid, 0.65, 72, force_mx, force_my
678
  )
679
- sess["last_shot_ts"] = time.time()
680
 
681
  if not result["data"]:
682
  return
683
 
684
  # Delta suppression: لا ترسل إذا الصورة لم تتغير
685
  fh = _frame_hash(result["data"])
686
- if fh == sess.get("last_frame_hash", "") and not label.startswith("force"):
687
- return
688
- sess["last_frame_hash"] = fh
689
 
690
  await send({
691
  "type": "screenshot",
@@ -700,14 +688,23 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
700
  "has_grid": True,
701
  })
702
 
703
- # ── screenshot ───────────────────────────────────────────
704
- if action == "screenshot":
705
- sess["last_frame_hash"] = "" # force send
 
 
 
706
  result = await asyncio.to_thread(capture_with_grid, 0.65, 75)
 
 
 
 
707
  await send({
708
  "type": "screenshot",
709
  "data": result["data"],
710
  "ts": int(time.time() * 1000),
 
 
711
  "screen_width": result["width"],
712
  "screen_height": result["height"],
713
  "mouse_x": result["mouse_x"],
@@ -715,6 +712,10 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
715
  "has_grid": True,
716
  })
717
 
 
 
 
 
718
  # ── terminal ─────────────────────────────────────────────
719
  elif action == "terminal":
720
  cmd = data.get("cmd", "")
@@ -729,14 +730,15 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
729
  "stderr": res.get("stderr", ""),
730
  "returncode": res["returncode"],
731
  })
732
- asyncio.create_task(shot(f"after: {cmd[:40]}", delay=0.4))
 
733
 
734
  # ── mouse_move ───────────────────────────────────────────
735
  elif action == "mouse_move":
736
  x, y = int(data.get("x", 0)), int(data.get("y", 0))
737
  await xdo(["mousemove", "--sync", str(x), str(y)])
738
  await send({"type": "ack", "action": "mouse_move", "x": x, "y": y})
739
- asyncio.create_task(shot(f"move ({x},{y})", delay=0.2, force_mx=x, force_my=y))
740
 
741
  # ── mouse_click ──────────────────────────────────────────
742
  elif action == "mouse_click":
@@ -751,7 +753,7 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
751
  await xdo(["click", btn])
752
  btn_name = {"1": "left", "2": "middle", "3": "right"}.get(btn, "left")
753
  await send({"type": "ack", "action": "mouse_click", "x": x, "y": y, "button": btn_name})
754
- asyncio.create_task(shot(f"click {btn_name} ({x},{y})", delay=0.5, force_mx=x, force_my=y))
755
 
756
  # ── mouse_drag ───────────────────────────────────────────
757
  elif action == "mouse_drag":
@@ -764,7 +766,7 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
764
  await asyncio.sleep(0.1)
765
  await xdo(["mouseup", "1"])
766
  await send({"type": "ack", "action": "mouse_drag"})
767
- asyncio.create_task(shot("after drag", delay=0.4, force_mx=x2, force_my=y2))
768
 
769
  # ── keyboard_type ────────────────────────────────────────
770
  elif action == "keyboard_type":
@@ -772,7 +774,7 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
772
  if text:
773
  res = await type_smart(text)
774
  await send({"type": "ack", "action": "keyboard_type", "method": res["method"]})
775
- asyncio.create_task(shot("after type", delay=0.5))
776
 
777
  # ── keyboard_hotkey ──────────────────────────────────────
778
  elif action == "keyboard_hotkey":
@@ -780,7 +782,7 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
780
  if keys:
781
  await xdo(["key", "--clearmodifiers", "+".join(keys)])
782
  await send({"type": "ack", "action": "keyboard_hotkey", "keys": keys})
783
- asyncio.create_task(shot("after hotkey", delay=0.4))
784
 
785
  # ── keyboard_press ───────────────────────────────────────
786
  elif action == "keyboard_press":
@@ -788,7 +790,7 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
788
  if key:
789
  await xdo(["key", "--clearmodifiers", key])
790
  await send({"type": "ack", "action": "keyboard_press"})
791
- asyncio.create_task(shot("after key", delay=0.4))
792
 
793
  # ── scroll ───────────────────────────────────────────────
794
  elif action == "scroll":
@@ -800,7 +802,7 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
800
  await xdo(["click", btn])
801
  await asyncio.sleep(0.025)
802
  await send({"type": "ack", "action": "scroll", "clicks": clicks})
803
- asyncio.create_task(shot("after scroll", delay=0.4))
804
 
805
  # ── clipboard_write ──────────────────────────────────────
806
  elif action == "clipboard_write":
@@ -829,7 +831,7 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
829
  await asyncio.sleep(0.1)
830
  await xdo(["key", "--clearmodifiers", "ctrl+v"])
831
  await send({"type": "ack", "action": "paste"})
832
- asyncio.create_task(shot("after paste", delay=0.5))
833
 
834
  # ── open_app ─────────────────────────────────────────────
835
  elif action == "open_app":
@@ -838,15 +840,14 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
838
  await send({"type": "ack", "action": "open_app"})
839
  return
840
 
841
- # أضف profile للـ firefox إذا لم يكن محدداً
842
- profile = sess.get("profile", "")
843
- if profile and any(b in cmd for b in ("firefox", "chromium")):
844
- if "--profile" not in cmd:
845
- cmd = cmd.replace(BROWSER, f"{BROWSER} --profile {profile}", 1)
846
-
847
- # نظّف lock files
848
  if "firefox" in cmd.lower():
849
- await run_cmd("rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock 2>/dev/null", 3)
 
 
 
 
 
850
 
851
  env = {**os.environ, "DISPLAY": SHARED_DISPLAY}
852
  proc = subprocess.Popen(
@@ -857,22 +858,34 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
857
  sess["browser_proc"] = proc
858
 
859
  await send({"type": "ack", "action": "open_app", "cmd": cmd})
860
- # screenshot مبكر ثم واحد بعد التحميل
861
- asyncio.create_task(shot("opening app (2s)", delay=2.0))
862
- asyncio.create_task(shot("app loaded (7s)", delay=7.0))
 
 
 
863
 
864
  # ── open_browser ─────────────────────────────────────────
865
  elif action == "open_browser":
866
  url = _safe_search(data.get("url", "") or "about:blank")
867
- profile = sess.get("profile", "")
868
- cmd = get_browser_cmd(url, profile)
 
 
 
 
 
 
869
  env = {**os.environ, "DISPLAY": SHARED_DISPLAY}
870
- await run_cmd("rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock 2>/dev/null", 3)
871
  proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
872
  sess["browser_proc"] = proc
873
  await send({"type": "ack", "action": "open_browser", "url": url})
874
- asyncio.create_task(shot("browser opening (2s)", delay=2.0))
875
- asyncio.create_task(shot("browser loaded (7s)", delay=7.0))
 
 
 
 
876
 
877
  # ── open_tab ─────────────────────────────────────────────
878
  elif action == "open_tab":
@@ -885,25 +898,25 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
885
  await asyncio.sleep(0.2)
886
  await xdo(["key", "--clearmodifiers", "Return"])
887
  await send({"type": "ack", "action": "open_tab", "url": url})
888
- asyncio.create_task(shot("after open_tab", delay=1.5))
889
 
890
  # ── close_tab ────────────────────────────────────────────
891
  elif action == "close_tab":
892
  await xdo(["key", "--clearmodifiers", "ctrl+w"])
893
  await send({"type": "ack", "action": "close_tab"})
894
- asyncio.create_task(shot("after close_tab", delay=0.5))
895
 
896
  # ── browser_back ─────────────────────────────────────────
897
  elif action == "browser_back":
898
  await xdo(["key", "--clearmodifiers", "alt+Left"])
899
  await send({"type": "ack", "action": "browser_back"})
900
- asyncio.create_task(shot("after back", delay=0.8))
901
 
902
  # ── browser_forward ───────────���──────────────────────────
903
  elif action == "browser_forward":
904
  await xdo(["key", "--clearmodifiers", "alt+Right"])
905
  await send({"type": "ack", "action": "browser_forward"})
906
- asyncio.create_task(shot("after forward", delay=0.8))
907
 
908
  # ── browser_search ───────────────────────────────────────
909
  elif action == "browser_search":
@@ -914,7 +927,7 @@ async def handle_action(ws: WebSocket, msg: dict, sess: dict):
914
  await asyncio.sleep(0.15)
915
  await xdo(["key", "--clearmodifiers", "Return"])
916
  await send({"type": "ack", "action": "browser_search"})
917
- asyncio.create_task(shot("after search", delay=1.5))
918
 
919
  # ── screen_info ──────────────────────────────────────────
920
  elif action == "screen_info":
@@ -963,10 +976,10 @@ async def websocket_endpoint(ws: WebSocket):
963
  "browser": BROWSER,
964
  "display": SHARED_DISPLAY,
965
  "session_id": id(ws),
966
- "msg": f"Z Computer Mode v8 | Browser: {BROWSER} | Screen: {w}x{h}",
967
  }, ensure_ascii=False))
968
 
969
- # لقطة شاشة أولية
970
  result = await asyncio.to_thread(capture_with_grid, 0.65, 72)
971
  if result["data"]:
972
  await ws.send_text(json.dumps({
@@ -980,7 +993,7 @@ async def websocket_endpoint(ws: WebSocket):
980
  "mouse_y": result["mouse_y"],
981
  "has_grid": True,
982
  }, ensure_ascii=False))
983
- sess["last_frame_hash"] = _frame_hash(result["data"])
984
 
985
  except Exception as e:
986
  print(f"[ws] init error: {e}")
@@ -1052,15 +1065,10 @@ async def _cleanup_tmp():
1052
  while True:
1053
  await asyncio.sleep(300)
1054
  try:
1055
- r = subprocess.run(
1056
  ["find", "/tmp", "-name", "zss_*", "-mmin", "+10", "-delete"],
1057
  capture_output=True, timeout=10
1058
  )
1059
- r2 = subprocess.run(
1060
- ["find", "/tmp", "-name", "zpc_*", "-mmin", "+60",
1061
- "-not", "-newer", "/tmp", "-delete"],
1062
- capture_output=True, timeout=10
1063
- )
1064
  except Exception as e:
1065
  print(f"[cleanup] {e}")
1066
 
 
1
  """
2
+ THE Z AI — Computer Mode Server v10SCREENSHOT RACE-CONDITION FIXED
3
+ =======================================================================
4
+ المشاكل التي تسبب "لم تصل لقطة شاشة" — وجدناها وأصلحناها:
5
+
6
+ 🐛 BUG 1 Delta Suppression يكتم Screenshot المطلوبة صراحةً:
7
+ في v9، دالة shot() كانت تتحقق من hash الصورة السابقة وتُلغي الإرسال إذا
8
+ "لم تتغير الصورة" لكن الـ HTML يطلب screenshot بشكل صريح ويستظهر وصولها
9
+ بمقارنة _pcLastFrameTs. إذا الشاشة لم تتغير (مثلاً بعد keyboard_hotkey)،
10
+ السيرفر يُلغي الإرسال الـ HTML ينتظر إلى ما لا نهاية → "لم تصل لقطة شاشة".
11
+ الإصلاح: Delta suppression تنطبق على auto_shot فقط (الخلفية)، وليس على
12
+ طلبات screenshot الصريحة (action == "screenshot").
13
+
14
+ 🐛 BUG 2auto_shot كـ background task يتأخر أو يُلغى:
15
+ asyncio.create_task(shot_bg(...)) كانت تُنشئ task في الخلفية قد يتأخر تنفيذها
16
+ لأن asyncio يُجدولها لاحقاً — الـ HTML يطلب screenshot ثم ينتظر 12 ثانية
17
+ لكن الـ auto_shot قد لم يُرسل بعد بسبب تزاحم الـ tasks.
18
+ الإصلاح: الأوامر الصريحة (screenshot action) تُجيب فوراً بـ await، لا create_task.
19
+ فقط auto_shot الخلفي (بعد mouse_move مثلاً) يبقى كـ create_task.
20
+
21
+ 🐛 BUG 3 Rate Limit 0.3s يمنع الرد الفوري:
22
+ الـ HTML يطلب screenshot ثم ينتظر — لكن السيرفر كان ينتظر 0.3 ثانية أولاً
23
+ بسبب rate limiting. مع تكدس الطلبات، التأخر يتراكم → timeout → "لم تصل".
24
+ الإصلاح: Rate limit تنطبق على auto_shot الخلفي فقط، لا على الطلبات الصريحة.
25
+
26
+ ✅ إصلاحات إضافية من v9 (تبقى):
27
+ - حذف --profile (كان يسبب "Firefox profile cannot be loaded")
28
+ - Xvfb واحد ثابت مشترك
29
+ - screenshot retry x3 إذا الصورة سوداء
30
+ - Heartbeat ping/pong كل 20 ثانية
31
+ - terminal semaphore(4)
32
+ - SafeSearch على كل URL
33
+ - تنظيف /tmp كل 5 دقائق
34
  """
35
 
36
  import asyncio
37
  import base64
 
38
  import hashlib
39
  import io
40
  import json
41
  import os
42
  import re
43
  import subprocess
 
44
  import time
45
  import urllib.parse
 
 
46
 
47
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
48
  from fastapi.middleware.cors import CORSMiddleware
 
60
  # semaphore للتحكم في عدد عمليات terminal المتوازية
61
  _terminal_sem = asyncio.Semaphore(4)
62
 
63
+ # ── تخزين الجلسات: session_id {last_bg_shot_ts, last_bg_hash, browser_proc} ──
 
 
 
 
 
 
 
 
64
  _active_sessions: dict[int, dict] = {}
65
  _sessions_lock = asyncio.Lock()
66
 
 
138
 
139
  async def create_session(ws: WebSocket) -> dict:
140
  """
141
+ ينشئ session بسيطة لهذا المستخدم.
142
+ لا profile منفصل Firefox يعمل بـ --no-remote فقط للعزل.
 
143
  """
144
  sid = id(ws)
 
 
 
145
  sess = {
146
  "id": sid,
147
+ "last_bg_shot_ts": 0.0,
148
+ "last_bg_hash": "",
 
149
  "browser_proc": None,
150
  "created": time.time(),
151
  }
152
  async with _sessions_lock:
153
  _active_sessions[sid] = sess
154
+ print(f"[session] ✅ Created session {sid}")
155
  return sess
156
 
157
 
158
  async def destroy_session(ws: WebSocket):
159
+ """يحرر موارد الجلسة عند انقطاع الاتصال."""
160
  sid = id(ws)
161
  async with _sessions_lock:
162
  sess = _active_sessions.pop(sid, None)
 
164
  return
165
 
166
  def _cleanup():
 
167
  bp = sess.get("browser_proc")
168
  if bp:
169
  try:
 
174
  bp.kill()
175
  except Exception:
176
  pass
 
 
 
 
 
 
177
 
178
  await asyncio.to_thread(_cleanup)
179
  print(f"[session] 🗑️ Destroyed session {sid}")
180
 
181
 
182
+ def get_browser_cmd(url: str = "") -> list:
183
+ """يبني أمر فتح المتصفح بدون profile — --no-remote يمنع التداخل."""
184
+ cmd = [BROWSER, "--no-remote"]
185
+ cmd.append(url if url else "about:blank")
 
 
 
 
 
 
186
  return cmd
187
 
188
 
 
206
 
207
  def _capture_raw() -> tuple:
208
  """
209
+ يلتقط الشاشة بـ 4 طرق متسلسلة مع retry إذا الصورة سوداء.
210
  يُعيد (PIL.Image | None, width, height).
 
211
  """
212
  from PIL import Image
213
  env = {**os.environ, "DISPLAY": SHARED_DISPLAY}
 
238
  except Exception:
239
  pass
240
 
241
+ # نحاول 3 مرات إذا الصورة سوداء (مثلاً: Firefox لا يزال يتحمّل)
242
+ for attempt in range(3):
243
+ if attempt > 0:
244
+ time.sleep(0.8)
245
+ print(f"[cap] retry attempt {attempt+1} (black screen)")
246
  try:
247
+ # ── Method 1: scrot ──────────────────────────────
248
+ p1 = f"{tmp_base}_a{attempt}_scrot.png"
249
+ created_files.append(p1)
250
+ try:
251
+ r = subprocess.run(
252
+ ["scrot", "-q", "95", p1],
253
+ env=env, timeout=8, capture_output=True
254
+ )
255
+ img, w, h = _try_load(p1)
256
+ if img and not _is_black_screen(img):
257
+ print(f"[cap] scrot (attempt {attempt+1})")
258
+ return img, w, h
259
+ if img:
260
+ print(f"[cap] scrot: black screen (attempt {attempt+1})")
261
+ except Exception as e:
262
+ print(f"[cap] scrot: {e}")
263
 
264
+ # ── Method 2: ImageMagick import ─────────────────
265
+ p2 = f"{tmp_base}_a{attempt}_im.png"
266
+ created_files.append(p2)
267
+ try:
268
+ subprocess.run(
269
+ ["import", "-window", "root", "-silent", p2],
270
+ env=env, timeout=10, capture_output=True
271
+ )
272
+ img, w, h = _try_load(p2)
273
+ if img and not _is_black_screen(img):
274
+ print(f"[cap] ✅ import (attempt {attempt+1})")
275
+ return img, w, h
276
+ except Exception as e:
277
+ print(f"[cap] import: {e}")
278
 
279
+ # ── Method 3: ffmpeg x11grab ──────────────────────
280
+ p3 = f"{tmp_base}_a{attempt}_ff.png"
281
+ created_files.append(p3)
282
+ try:
283
+ sw, sh = _get_screen_size()
284
+ subprocess.run([
285
+ "ffmpeg", "-y", "-f", "x11grab",
286
+ "-video_size", f"{sw}x{sh}",
287
+ "-i", SHARED_DISPLAY,
288
+ "-vframes", "1", "-q:v", "1", p3
289
+ ], env=env, timeout=12, capture_output=True)
290
+ img, w, h = _try_load(p3)
291
+ if img and not _is_black_screen(img):
292
+ print(f"[cap] ✅ ffmpeg (attempt {attempt+1})")
293
+ return img, w, h
294
+ except Exception as e:
295
+ print(f"[cap] ffmpeg: {e}")
296
 
297
+ # ── Method 4: python-xlib ─────────────────────────
298
+ try:
299
+ from Xlib import display as Xdisp, X
300
+ xd = Xdisp.Display(SHARED_DISPLAY)
301
+ root = xd.screen().root
302
+ geom = root.get_geometry()
303
+ w, h = geom.width, geom.height
304
+ raw = root.get_image(0, 0, w, h, X.ZPixmap, 0xFFFFFFFF)
305
+ data = raw.data
306
+ pixels = bytearray(len(data) // 4 * 3)
307
+ for i in range(0, len(data) - 3, 4):
308
+ b, g, r_c = data[i], data[i+1], data[i+2]
309
+ j = (i // 4) * 3
310
+ pixels[j], pixels[j+1], pixels[j+2] = r_c, g, b
311
+ img = Image.frombytes("RGB", (w, h), bytes(pixels))
312
+ if not _is_black_screen(img):
313
+ print(f"[cap] xlib (attempt {attempt+1})")
314
+ return img, w, h
315
+ except Exception as e:
316
+ print(f"[cap] xlib: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
317
 
318
+ finally:
319
+ _cleanup_files()
320
+
321
+ # إذا فشلت كل المحاولات — placeholder واضح
322
+ print("[cap] ⚠️ All methods failed or black — placeholder")
323
+ from PIL import Image as PILImg, ImageDraw
324
+ sw, sh = _get_screen_size()
325
+ img = PILImg.new("RGB", (sw or 1920, sh or 1080), (15, 20, 40))
326
+ draw = ImageDraw.Draw(img)
327
+ draw.rectangle([(0, 0), (sw, 55)], fill=(30, 40, 80))
328
+ draw.text((10, 10), f"⚠️ Screenshot failed — DISPLAY={SHARED_DISPLAY}", fill=(255, 120, 80))
329
+ draw.text((10, 32), "Methods tried: scrot, import, ffmpeg, xlib (3 attempts each)", fill=(130, 130, 150))
330
+ return img, sw or 1920, sh or 1080
331
 
332
 
333
  def _get_screen_size() -> tuple:
 
647
  except Exception:
648
  pass
649
 
650
+ async def shot_bg(label: str = "", delay: float = 0.5,
651
+ force_mx: int | None = None, force_my: int | None = None):
652
+ """
653
+ auto_shot في الخلفية — فقط للمعاينة البصرية (بعد mouse/keyboard/scroll).
654
+ يطبّق: rate limit + delta suppression.
655
+ لا يُرسل إذا الصورة لم تتغير — المستخدم يرى التحديث عند الطلب الصريح.
656
+ """
657
  await asyncio.sleep(delay)
658
 
659
+ # Rate limit: لا أكثر من لقطة كل 0.3 ثانية لنفس الجلسة
660
  now = time.time()
661
+ if now - sess.get("last_bg_shot_ts", 0) < 0.3:
662
+ await asyncio.sleep(0.3 - (now - sess["last_bg_shot_ts"]))
663
 
664
  result = await asyncio.to_thread(
665
  capture_with_grid, 0.65, 72, force_mx, force_my
666
  )
667
+ sess["last_bg_shot_ts"] = time.time()
668
 
669
  if not result["data"]:
670
  return
671
 
672
  # Delta suppression: لا ترسل إذا الصورة لم تتغير
673
  fh = _frame_hash(result["data"])
674
+ if fh == sess.get("last_bg_hash", ""):
675
+ return # لم تتغير — لا ترسل
676
+ sess["last_bg_hash"] = fh
677
 
678
  await send({
679
  "type": "screenshot",
 
688
  "has_grid": True,
689
  })
690
 
691
+ async def shot_explicit(label: str = ""):
692
+ """
693
+ screenshot صريح يُرسل دائماً بدون delta suppression ولا rate limit.
694
+ يُستخدم عند action == "screenshot" أو بعد terminal/open_app.
695
+ الـ HTML ينتظر هذا الرد بمقارنة _pcLastFrameTs — يجب أن يصل دائماً.
696
+ """
697
  result = await asyncio.to_thread(capture_with_grid, 0.65, 75)
698
+ if not result["data"]:
699
+ return
700
+ # حدّث hash الخلفي أيضاً لمنع إرسال نفس الصورة مرتين في auto_shot
701
+ sess["last_bg_hash"] = _frame_hash(result["data"])
702
  await send({
703
  "type": "screenshot",
704
  "data": result["data"],
705
  "ts": int(time.time() * 1000),
706
+ "auto": False,
707
+ "label": label,
708
  "screen_width": result["width"],
709
  "screen_height": result["height"],
710
  "mouse_x": result["mouse_x"],
 
712
  "has_grid": True,
713
  })
714
 
715
+ # ── screenshot (صريح من الـ HTML — يُرسل دائماً بدون تأخير) ──────
716
+ if action == "screenshot":
717
+ await shot_explicit("explicit screenshot")
718
+
719
  # ── terminal ─────────────────────────────────────────────
720
  elif action == "terminal":
721
  cmd = data.get("cmd", "")
 
730
  "stderr": res.get("stderr", ""),
731
  "returncode": res["returncode"],
732
  })
733
+ # screenshot صريح بعد terminal — الـ HTML ينتظره
734
+ asyncio.create_task(shot_explicit(f"after terminal"))
735
 
736
  # ── mouse_move ───────────────────────────────────────────
737
  elif action == "mouse_move":
738
  x, y = int(data.get("x", 0)), int(data.get("y", 0))
739
  await xdo(["mousemove", "--sync", str(x), str(y)])
740
  await send({"type": "ack", "action": "mouse_move", "x": x, "y": y})
741
+ asyncio.create_task(shot_bg(f"move ({x},{y})", delay=0.2, force_mx=x, force_my=y))
742
 
743
  # ── mouse_click ──────────────────────────────────────────
744
  elif action == "mouse_click":
 
753
  await xdo(["click", btn])
754
  btn_name = {"1": "left", "2": "middle", "3": "right"}.get(btn, "left")
755
  await send({"type": "ack", "action": "mouse_click", "x": x, "y": y, "button": btn_name})
756
+ asyncio.create_task(shot_bg(f"click {btn_name} ({x},{y})", delay=0.5, force_mx=x, force_my=y))
757
 
758
  # ── mouse_drag ───────────────────────────────────────────
759
  elif action == "mouse_drag":
 
766
  await asyncio.sleep(0.1)
767
  await xdo(["mouseup", "1"])
768
  await send({"type": "ack", "action": "mouse_drag"})
769
+ asyncio.create_task(shot_bg("after drag", delay=0.4, force_mx=x2, force_my=y2))
770
 
771
  # ── keyboard_type ────────────────────────────────────────
772
  elif action == "keyboard_type":
 
774
  if text:
775
  res = await type_smart(text)
776
  await send({"type": "ack", "action": "keyboard_type", "method": res["method"]})
777
+ asyncio.create_task(shot_bg("after type", delay=0.5))
778
 
779
  # ── keyboard_hotkey ──────────────────────────────────────
780
  elif action == "keyboard_hotkey":
 
782
  if keys:
783
  await xdo(["key", "--clearmodifiers", "+".join(keys)])
784
  await send({"type": "ack", "action": "keyboard_hotkey", "keys": keys})
785
+ asyncio.create_task(shot_bg("after hotkey", delay=0.4))
786
 
787
  # ── keyboard_press ───────────────────────────────────────
788
  elif action == "keyboard_press":
 
790
  if key:
791
  await xdo(["key", "--clearmodifiers", key])
792
  await send({"type": "ack", "action": "keyboard_press"})
793
+ asyncio.create_task(shot_bg("after key", delay=0.4))
794
 
795
  # ── scroll ───────────────────────────────────────────────
796
  elif action == "scroll":
 
802
  await xdo(["click", btn])
803
  await asyncio.sleep(0.025)
804
  await send({"type": "ack", "action": "scroll", "clicks": clicks})
805
+ asyncio.create_task(shot_bg("after scroll", delay=0.4))
806
 
807
  # ── clipboard_write ──────────────────────────────────────
808
  elif action == "clipboard_write":
 
831
  await asyncio.sleep(0.1)
832
  await xdo(["key", "--clearmodifiers", "ctrl+v"])
833
  await send({"type": "ack", "action": "paste"})
834
+ asyncio.create_task(shot_bg("after paste", delay=0.5))
835
 
836
  # ── open_app ─────────────────────────────────────────────
837
  elif action == "open_app":
 
840
  await send({"type": "ack", "action": "open_app"})
841
  return
842
 
843
+ # نظّف lock files قبل فتح Firefox (يمنع "already running" dialog)
 
 
 
 
 
 
844
  if "firefox" in cmd.lower():
845
+ await run_cmd(
846
+ "pkill -f '[f]irefox' 2>/dev/null; sleep 0.5; "
847
+ "rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock 2>/dev/null; "
848
+ "echo CLEANED",
849
+ timeout=8
850
+ )
851
 
852
  env = {**os.environ, "DISPLAY": SHARED_DISPLAY}
853
  proc = subprocess.Popen(
 
858
  sess["browser_proc"] = proc
859
 
860
  await send({"type": "ack", "action": "open_app", "cmd": cmd})
861
+ async def _open_app_shots():
862
+ await asyncio.sleep(3.0)
863
+ await shot_explicit("app opening 3s")
864
+ await asyncio.sleep(5.0)
865
+ await shot_explicit("app loaded 8s")
866
+ asyncio.create_task(_open_app_shots())
867
 
868
  # ── open_browser ─────────────────────────────────────────
869
  elif action == "open_browser":
870
  url = _safe_search(data.get("url", "") or "about:blank")
871
+ # نظّف أي Firefox قديم أولاً
872
+ await run_cmd(
873
+ "pkill -f '[f]irefox' 2>/dev/null; sleep 0.5; "
874
+ "rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock 2>/dev/null; "
875
+ "echo CLEANED",
876
+ timeout=8
877
+ )
878
+ cmd = get_browser_cmd(url)
879
  env = {**os.environ, "DISPLAY": SHARED_DISPLAY}
 
880
  proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
881
  sess["browser_proc"] = proc
882
  await send({"type": "ack", "action": "open_browser", "url": url})
883
+ async def _open_browser_shots():
884
+ await asyncio.sleep(3.0)
885
+ await shot_explicit("browser opening 3s")
886
+ await asyncio.sleep(6.0)
887
+ await shot_explicit("browser loaded 9s")
888
+ asyncio.create_task(_open_browser_shots())
889
 
890
  # ── open_tab ─────────────────────────────────────────────
891
  elif action == "open_tab":
 
898
  await asyncio.sleep(0.2)
899
  await xdo(["key", "--clearmodifiers", "Return"])
900
  await send({"type": "ack", "action": "open_tab", "url": url})
901
+ asyncio.create_task(shot_bg("after open_tab", delay=1.5))
902
 
903
  # ── close_tab ────────────────────────────────────────────
904
  elif action == "close_tab":
905
  await xdo(["key", "--clearmodifiers", "ctrl+w"])
906
  await send({"type": "ack", "action": "close_tab"})
907
+ asyncio.create_task(shot_bg("after close_tab", delay=0.5))
908
 
909
  # ── browser_back ─────────────────────────────────────────
910
  elif action == "browser_back":
911
  await xdo(["key", "--clearmodifiers", "alt+Left"])
912
  await send({"type": "ack", "action": "browser_back"})
913
+ asyncio.create_task(shot_bg("after back", delay=0.8))
914
 
915
  # ── browser_forward ───────────���──────────────────────────
916
  elif action == "browser_forward":
917
  await xdo(["key", "--clearmodifiers", "alt+Right"])
918
  await send({"type": "ack", "action": "browser_forward"})
919
+ asyncio.create_task(shot_bg("after forward", delay=0.8))
920
 
921
  # ── browser_search ───────────────────────────────────────
922
  elif action == "browser_search":
 
927
  await asyncio.sleep(0.15)
928
  await xdo(["key", "--clearmodifiers", "Return"])
929
  await send({"type": "ack", "action": "browser_search"})
930
+ asyncio.create_task(shot_bg("after search", delay=1.5))
931
 
932
  # ── screen_info ──────────────────────────────────────────
933
  elif action == "screen_info":
 
976
  "browser": BROWSER,
977
  "display": SHARED_DISPLAY,
978
  "session_id": id(ws),
979
+ "msg": f"Z Computer Mode v10 | Browser: {BROWSER} | Screen: {w}x{h}",
980
  }, ensure_ascii=False))
981
 
982
+ # لقطة شاشة أولية — دائماً تُرسل بغض النظر عن أي شيء
983
  result = await asyncio.to_thread(capture_with_grid, 0.65, 72)
984
  if result["data"]:
985
  await ws.send_text(json.dumps({
 
993
  "mouse_y": result["mouse_y"],
994
  "has_grid": True,
995
  }, ensure_ascii=False))
996
+ sess["last_bg_hash"] = _frame_hash(result["data"])
997
 
998
  except Exception as e:
999
  print(f"[ws] init error: {e}")
 
1065
  while True:
1066
  await asyncio.sleep(300)
1067
  try:
1068
+ subprocess.run(
1069
  ["find", "/tmp", "-name", "zss_*", "-mmin", "+10", "-delete"],
1070
  capture_output=True, timeout=10
1071
  )
 
 
 
 
 
1072
  except Exception as e:
1073
  print(f"[cleanup] {e}")
1074