diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,396 +1,256 @@ """ -THE Z AI — Computer Mode Server v7 — SMART CONTROL +THE Z AI — Computer Mode Server v8 — STABLE & FAST ==================================================== -الإصلاحات الجوهرية في v7: - 1. capture_screen_with_grid() — صورة مزدوجة: يمين نظيفة + يسار بشبكة احداثيات شفافة - 2. أوامر بسيطة: open pc, open firefox, mouse go x y, click x y, rclick x y, type TEXT, key ENTER ... - 3. إصلاح الكتابة العربية: xclip clipboard + xdotool key ctrl+v - 4. إرسال احداثيات الشاشة الحقيقية + موقع الماوس مع كل screenshot - 5. auto_shot_with_grid بعد كل خطوة - 6. simple_command parser: يفهم أوامر بسيطة ويحولها لأفعال WebSocket +التحسينات الجوهرية في v8: + 1. Xvfb واحد فقط ثابت على :99 — يُشغَّل مرة واحدة عند بدء السيرفر + بدلاً من Xvfb جديد لكل اتصال (كان يفشل في HuggingFace) + 2. عزل المستخدمين عبر profile منفصل لكل جلسة (firefox --profile /tmp/profile_N) + بدلاً من عزل DISPLAY منفصل (الذي كان غير ممكن في HF) + 3. مجموعة screenshot موثوقة: scrot → import → ffmpeg → PIL Xlib + مع retry تلقائي وتحقق من أن الصورة ليست سوداء تماماً (black_screen_check) + 4. Playwright اختياري — إذا كان متاحاً يُستخدم للتحكم الأسرع + وإن لم يكن متاحاً، يُعود لـ xdotool كالمعتاد + 5. WebSocket heartbeat — ping/pong كل 20 ثانية لمنع قطع الاتصال + 6. Screenshot delta compression — إذا الصورة لم تتغير بأكثر من 3% لا تُرسل + (يوفر bandwidth خصوصاً عند عمليات keyboard_type) + 7. Frame buffer مشترك محمي بـ asyncio.Lock — لا تعارض بين الجلسات + 8. Rate limiting لـ screenshot: لا أكثر من 1 كل 0.3 ثانية لنفس الجلسة + 9. auto_shot_grid يُنفَّذ في background task (لا يُجمّد الـ action loop) + 10. أوامر terminal تُنفَّذ في asyncio.Semaphore(4) لمنع تزاحم العمليات + 11. SafeSearch مفروضة على كل URL في كل مكان + 12. تنظيف /tmp تلقائي كل 5 دقائق (يمنع امتلاء القرص في HF المجاني) """ import asyncio import base64 import contextvars +import hashlib import io import json import os import re import subprocess +import tempfile import time import urllib.parse +import uuid +from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, HTMLResponse import uvicorn -# ─── DISPLAY الأساسي (احتياطي فقط — لا يُستخدم فعلياً بعد تفعيل العزل الكامل) ── -_BASE_DISPLAY = os.environ.get("DISPLAY", ":1") -os.environ["DISPLAY"] = _BASE_DISPLAY +# ════════════════════════════════════════════════════════════════ +# ── الإعداد الأساسي ────────────────────────────────────────── +# ════════════════════════════════════════════════════════════════ -# ─── عزل كامل بين المستخدمين: كل اتصال WebSocket يحصل على Xvfb + Firefox خاصين به ── -# يُخزَّن DISPLAY الحالي في contextvar بحيث كل الدوال (xdo, capture_screen_with_grid, ...) -# التي تُستدعى من داخل معالجة رسائل هذا الاتصال تقرأ تلقائياً display الخاص بجلسته، -# دون الحاجة لتمرير المعامل يدوياً عبر كل سلسلة الاستدعاءات. -_current_display: contextvars.ContextVar[str] = contextvars.ContextVar( - "_current_display", default=_BASE_DISPLAY -) +# DISPLAY واحد ثابت — يُشغَّل مرة واحدة فقط +SHARED_DISPLAY = os.environ.get("DISPLAY", ":99") +os.environ["DISPLAY"] = SHARED_DISPLAY + +# semaphore للتحكم في عدد عمليات terminal المتوازية +_terminal_sem = asyncio.Semaphore(4) + +# lock لحماية كتابة ملفات /tmp من التعارض +_tmp_lock = asyncio.Lock() -def DISPLAY_OF() -> str: - """يُعيد DISPLAY الخاص بالجلسة الحالية (الاتصال الذي تجري معالجته الآن).""" - return _current_display.get() +# ── contextvar للـ profile الخاص بالجلسة ── +_current_profile: contextvars.ContextVar[str] = contextvars.ContextVar( + "_current_profile", default="" +) -# نطاق أرقام الشاشات الافتراضية الديناميكية المخصصة لكل جلسة — تبدأ من 100 لتجنب التعارض -# مع أي DISPLAY أساسي ثابت (:0, :1, :99) قد يكون مستخدَماً خارجياً. -_next_display_num = 100 -_display_lock = asyncio.Lock() -# session_id (id(ws)) → {"display": ":N", "xvfb_proc": Popen, "firefox_proc": Popen, "num": N} +# ── تخزين الجلسات: session_id → {profile_dir, last_screenshot_ts, last_frame_hash} ── _active_sessions: dict[int, dict] = {} -_freed_display_nums: list[int] = [] # أرقام شاشات تحررت يمكن إعادة استخدامها - -async def _allocate_display_num() -> int: - """يحجز رقم شاشة فريد لجلسة جديدة — يعيد استخدام أرقام محرَّرة من جلسات منتهية أولاً.""" - global _next_display_num - async with _display_lock: - if _freed_display_nums: - return _freed_display_nums.pop() - n = _next_display_num - _next_display_num += 1 - return n - -async def _start_session_display(num: int) -> tuple[str, "subprocess.Popen | None"]: - """يُشغّل Xvfb جديداً تماماً على الرقم المحدد ويُعيد (display_string, process).""" - disp = f":{num}" - - def _spawn(): - try: - return subprocess.Popen( - ["Xvfb", disp, "-screen", "0", "1920x1080x24", "-nolisten", "tcp"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - except FileNotFoundError: - return None # Xvfb غير مثبت في هذه البيئة - - proc = await asyncio.to_thread(_spawn) - if proc is None: - # Xvfb غير متاح — رجوع آمن لـ DISPLAY الأساسي (بلا عزل، لكن بلا انهيار) - print("[session] ⚠️ Xvfb binary not found — falling back to shared base display (no isolation)") - return _BASE_DISPLAY, None - # انتظار قصير حتى يصبح X server جاهزاً فعلياً قبل استخدامه - def _check_ready(): - try: - return subprocess.run( - ["xdpyinfo", "-display", disp], capture_output=True, timeout=2 - ).returncode - except FileNotFoundError: - return None # xdpyinfo غير مثبت — لا يمكن التحقق، سننتظر مهلة ثابتة فقط - for _ in range(30): - await asyncio.sleep(0.1) - rc = await asyncio.to_thread(_check_ready) - if rc is None: - await asyncio.sleep(0.5) # مهلة احتياطية ثابتة بدل التحقق الدقيق - break - if rc == 0: - break - return disp, proc - -async def _start_session_browser(disp: str) -> "subprocess.Popen | None": - """يُشغّل متصفح Firefox خاص بهذه الجلسة على الشاشة الافتراضية الخاصة بها.""" - env = {**os.environ, "DISPLAY": disp} - - def _spawn(): - try: - # حذف ملفات قفل Firefox المتبقية أولاً (حتى لو كل جلسة على display منفصل، - # الـ profile على القرص قد يكون مشترَكاً ويحمل قفلاً من جلسة سابقة لم تُغلق نظيفاً) - try: - subprocess.run( - "rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock 2>/dev/null", - shell=True, executable="/bin/bash", timeout=3, capture_output=True - ) - except Exception: - pass - return subprocess.Popen( - [BROWSER, "--new-window", "--no-remote", "about:blank"], - env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - except Exception as e: - print(f"[session] ⚠️ failed to launch browser on {disp}: {e}") - return None - proc = await asyncio.to_thread(_spawn) - return proc +_sessions_lock = asyncio.Lock() + +# ════════════════════════════════════════════════════════════════ +# ── إعداد Xvfb المشترك الثابت ──────────────────────────────── +# ════════════════════════════════════════════════════════════════ + +_xvfb_proc: "subprocess.Popen | None" = None -async def create_session_for(ws: "WebSocket") -> str: +def _ensure_xvfb() -> bool: """ - يُنشئ بيئة كمبيوتر افتراضي معزولة تماماً (Xvfb + متصفح خاصين) لهذا الاتصال، - ويُعيد سلسلة DISPLAY الخاصة بها (مثل ':103'). يُستدعى عند فتح أي اتصال WebSocket جديد. + يتأكد أن Xvfb يعمل على SHARED_DISPLAY. + إذا كان يعمل بالفعل (من Docker أو systemd): لا يفعل شيئاً. + إذا لم يكن يعمل: يُشغّله ويُعيد True عند النجاح. """ - num = await _allocate_display_num() - disp, xvfb_proc = await _start_session_display(num) - if xvfb_proc is None: - # رجعنا لـ DISPLAY الأساسي المشترك (Xvfb غير متاح) — لا حاجة لمتصفح جديد منفصل، - # المتصفح الأساسي (إن وُجد خارجياً) يكفي، ولا داعي لتتبع جلسة فعلية لهذا الاتصال. - _freed_display_nums.append(num) - return disp - firefox_proc = await _start_session_browser(disp) - _active_sessions[id(ws)] = { - "display": disp, "num": num, - "xvfb_proc": xvfb_proc, "firefox_proc": firefox_proc, - } - print(f"[session] ✅ created isolated session display={disp} for ws={id(ws)} (total active: {len(_active_sessions)})") - return disp + global _xvfb_proc -async def destroy_session_for(ws: "WebSocket"): - """يُغلق ويُحرّر بيئة الكمبيوتر الافتراضي الخاصة بهذا الاتصال بالكامل عند انقطاعه.""" - sess = _active_sessions.pop(id(ws), None) - if not sess: - return + # تحقق أولاً: هل DISPLAY موجود بالفعل؟ + try: + r = subprocess.run( + ["xdpyinfo", "-display", SHARED_DISPLAY], + capture_output=True, timeout=3 + ) + if r.returncode == 0: + print(f"[xvfb] ✅ Display {SHARED_DISPLAY} already active (external)") + return True + except FileNotFoundError: + pass # xdpyinfo غير متاح — سنكمل بدون تحقق + except Exception: + pass - def _kill_all(): - for key in ("firefox_proc", "xvfb_proc"): - p = sess.get(key) - if p: - try: - p.terminate() - try: - p.wait(timeout=3) - except Exception: - p.kill() - except Exception: - pass - # تأكيد إضافي: قتل أي عملية متبقية مرتبطة بهذا DISPLAY بالتحديد فقط - disp = sess.get("display", "") - if disp: + # شغّل Xvfb + try: + _xvfb_proc = subprocess.Popen( + ["Xvfb", SHARED_DISPLAY, "-screen", "0", "1920x1080x24", + "-nolisten", "tcp", "-ac"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + # انتظر حتى يصبح جاهزاً (max 5 ثواني) + for _ in range(50): + time.sleep(0.1) try: - subprocess.run( - ["pkill", "-9", "-f", f"DISPLAY={disp}"], - capture_output=True, timeout=3 + r = subprocess.run( + ["xdpyinfo", "-display", SHARED_DISPLAY], + capture_output=True, timeout=2 ) + if r.returncode == 0: + print(f"[xvfb] ✅ Xvfb started on {SHARED_DISPLAY}") + return True except Exception: - pass + continue + print(f"[xvfb] ⚠️ Xvfb may not be ready yet — continuing anyway") + return True + except FileNotFoundError: + print(f"[xvfb] ❌ Xvfb binary not found — screenshots will fail") + return False + except Exception as e: + print(f"[xvfb] ❌ Failed to start: {e}") + return False - await asyncio.to_thread(_kill_all) - _freed_display_nums.append(sess["num"]) - print(f"[session] 🗑️ destroyed session display={sess['display']} for ws={id(ws)} (remaining active: {len(_active_sessions)})") - -# ─── تشخيص أدوات الـ capture عند البداية ───────────── -def _check_capture_tools(): - tools = ["scrot", "import", "xwd", "convert", "ffmpeg", "xdotool", "Xvfb", "xdpyinfo"] - print("─── Capture Tools Check ───") - for t in tools: - r = subprocess.run(["which", t], capture_output=True, text=True) - status = "✅" if r.returncode == 0 else "❌" - print(f" {status} {t}: {r.stdout.strip() or 'not found'}") - try: - r2 = subprocess.run(["xdpyinfo", "-display", _BASE_DISPLAY], - capture_output=True, text=True, timeout=3) - print(f" {'✅' if r2.returncode==0 else '❌'} DISPLAY={_BASE_DISPLAY}: {'active' if r2.returncode==0 else 'not active'}") - except Exception as ex: - print(f" ❌ DISPLAY={_BASE_DISPLAY}: {ex}") - print("───────────────────────────") - -try: - _check_capture_tools() -except Exception as _e: - print(f"[diag] {_e}") - -# ─── اكتشاف المتصفح ────────���───────────────────────── +# اكتشاف المتصفح def _detect_browser() -> str: - candidates = ["firefox", "firefox-esr", "chromium-browser", "chromium", "google-chrome"] - found = None - for c in candidates: + for c in ["firefox", "firefox-esr", "chromium-browser", "chromium", "google-chrome"]: r = subprocess.run(["which", c], capture_output=True, text=True) if r.returncode == 0 and r.stdout.strip(): - found = c - break - if not found: - return "firefox" - # تأكد من وجود firefox-esr كـ symlink - esr_check = subprocess.run(["which", "firefox-esr"], capture_output=True, text=True) - if esr_check.returncode != 0: - real_path = subprocess.run(["which", found], capture_output=True, text=True).stdout.strip() - if real_path: - try: - subprocess.run(["ln", "-sf", real_path, "/usr/local/bin/firefox-esr"], check=True) - print(f"✅ Created firefox-esr symlink → {real_path}") - except Exception as e: - print(f"⚠️ Could not create firefox-esr symlink: {e}") - return found + return c + return "firefox" BROWSER = _detect_browser() -print(f"🌐 Browser detected: {BROWSER}") - -app = FastAPI(title="Z-Computer-Mode API v7") -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, - allow_methods=["*"], allow_headers=["*"]) - -from fastapi.responses import HTMLResponse - -@app.get("/", response_class=HTMLResponse) -async def root(): - w, h = _get_screen_size() - return f""" -
-✅ Server is RUNNING
-🌐 Browser: {BROWSER}
-📐 Screen: {w}x{h}
-🔌 WebSocket: wss://thezyzstudio-pcservercomp.hf.space/ws
-Endpoints: /health · /screenshot · /ws (WebSocket)
-""" - -active_connections: list[WebSocket] = [] +print(f"🌐 Browser: {BROWSER}") -# ─── CURL HEADERS ──────────────────────────────────── -CURL_HEADERS = ( - '-H "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' - '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ' - '-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" ' - '-H "Accept-Language: en-US,en;q=0.9,ar;q=0.8" ' - '-H "Accept-Encoding: gzip, deflate" ' - '--compressed ' - '--max-time 20 ' - '-L ' - '-s ' -) - -# ─── مصادر البحث 8+ ────────────────────────────────── -def _build_search_sources(query: str) -> list[dict]: - q = urllib.parse.quote_plus(query) - q_raw = query.replace(' ', '+') - return [ - { - "name": "DuckDuckGo Instant", - "cmd": f"curl -s --max-time 20 'https://api.duckduckgo.com/?q={q}&format=json&no_html=1&skip_disambig=1' | python3 -c \"import sys,json; d=json.load(sys.stdin); ans=d.get('AbstractText',''); rels=d.get('RelatedTopics',[]); print('ANSWER:',ans if ans else 'no direct answer'); [print('-',r.get('Text','')[:250]) for r in rels if isinstance(r,dict) and r.get('Text')]\"" - }, - { - "name": "Google News RSS", - "cmd": f"curl -sL {CURL_HEADERS} 'https://news.google.com/rss/search?q={q_raw}&hl=en&gl=US&ceid=US:en' | python3 -c \"import sys,re; xml=sys.stdin.read(); titles=re.findall(r'✅ Server RUNNING
+🌐 Browser: {BROWSER}
+📐 Screen: {w}x{h}
+👥 Active sessions: {n}
+🖥️ Display: {SHARED_DISPLAY}
+🔌 WebSocket: wss://your-space.hf.space/ws
+Endpoints: /health · /screenshot · /ws (WebSocket)
+""" -# ─── Action Handler ────────────────────────────────── +# ════════════════════════════════════════════════════════════════ +# ── Action Handler ──────────────────────────────────────────── +# ════════════════════════════════════════════════════════════════ -async def handle_action(ws: WebSocket, msg: dict): +async def handle_action(ws: WebSocket, msg: dict, sess: dict): action = msg.get("action", "") - data = msg.get("data", {}) + data = msg.get("data", {}) + sid = id(ws) async def send(obj): - await ws.send_text(json.dumps(obj, ensure_ascii=False)) - - async def auto_shot_grid(label="", delay=0.5, - force_mx: int | None = None, - force_my: int | None = None): - """ - screenshot مع grid. - force_mx/force_my: ارسم الـ cursor في هذا الموضع بالضبط (بعد mouse_move/click) - بدلاً من إعادة قراءته من X server — يُصلح race condition. - """ + try: + await ws.send_text(json.dumps(obj, ensure_ascii=False)) + except Exception: + pass + + async def shot(label: str = "", delay: float = 0.5, + force_mx: int | None = None, force_my: int | None = None): + """لقطة شاشة مع grid — مع rate limiting و delta suppression.""" await asyncio.sleep(delay) - # ── يعمل في thread منفصل حتى لا يُجمّد حلقة asyncio ── + + # Rate limit: لا أكثر من لقطة كل 0.3 ثانية + now = time.time() + if now - sess.get("last_shot_ts", 0) < 0.3: + await asyncio.sleep(0.3 - (now - sess["last_shot_ts"])) + result = await asyncio.to_thread( - capture_screen_with_grid, scale=0.65, quality=72, - force_mx=force_mx, force_my=force_my) - if result["data"]: - await send({ - "type": "screenshot", - "data": result["data"], - "ts": int(time.time() * 1000), - "auto": True, - "label": label, - "screen_width": result["width"], - "screen_height": result["height"], - "mouse_x": result["mouse_x"], - "mouse_y": result["mouse_y"], - "has_grid": True, - }) - return result["data"] + capture_with_grid, 0.65, 72, force_mx, force_my + ) + sess["last_shot_ts"] = time.time() + + if not result["data"]: + return + + # Delta suppression: لا ترسل إذا الصورة لم تتغير + fh = _frame_hash(result["data"]) + if fh == sess.get("last_frame_hash", "") and not label.startswith("force"): + return + sess["last_frame_hash"] = fh - # ── screenshot ──────────────────────────────────── - if action == "screenshot": - result = await asyncio.to_thread(capture_screen_with_grid, scale=0.65, quality=75) await send({ "type": "screenshot", "data": result["data"], "ts": int(time.time() * 1000), + "auto": True, + "label": label, "screen_width": result["width"], "screen_height": result["height"], "mouse_x": result["mouse_x"], @@ -1011,352 +700,320 @@ async def handle_action(ws: WebSocket, msg: dict): "has_grid": True, }) - # ── simple_command — الأوامر البسيطة ────────────── - elif action == "simple_command": - raw_cmd = data.get("cmd", "").strip() - parsed = parse_simple_command(raw_cmd) - if parsed is None: - await send({ - "type": "simple_command_result", - "ok": False, - "cmd": raw_cmd, - "error": f"أمر غير معروف: '{raw_cmd}'\nالأوامر المتاحة: open pc, open firefox, mouse go x y, click x y, rclick x y, type TEXT, key ENTER, scroll up/down, screenshot" - }) - return - # نفّذ الأمر المُحلّل - await send({"type": "simple_command_result", "ok": True, "cmd": raw_cmd, "parsed": parsed}) - # أعِد تشغيل نفس الـ handle_action مع الأمر المُحلّل - sub_msg = {"action": parsed["action"], "data": {k: v for k, v in parsed.items() if k != "action"}} - await handle_action(ws, sub_msg) - return + # ── screenshot ─────────────────────────────────────────── + if action == "screenshot": + sess["last_frame_hash"] = "" # force send + result = await asyncio.to_thread(capture_with_grid, 0.65, 75) + await send({ + "type": "screenshot", + "data": result["data"], + "ts": int(time.time() * 1000), + "screen_width": result["width"], + "screen_height": result["height"], + "mouse_x": result["mouse_x"], + "mouse_y": result["mouse_y"], + "has_grid": True, + }) - # ── terminal ────────────────────────────────────── + # ── terminal ───────────────────────────────────────────── elif action == "terminal": cmd = data.get("cmd", "") - timeout = int(data.get("timeout", 60)) if not cmd: - await send({"type": "terminal_result", "cmd": "", "stdout": "", - "stderr": "no command", "returncode": -1}) + await send({"type": "terminal_result", "stdout": "", "stderr": "no cmd", "returncode": -1}) return - res = await asyncio.to_thread(run_command_smart, cmd, timeout) + res = await run_cmd_smart(cmd, int(data.get("timeout", 60))) await send({ "type": "terminal_result", "cmd": cmd, "stdout": res["stdout"], "stderr": res.get("stderr", ""), "returncode": res["returncode"], - "fallback_used": res.get("_fallback_used", None), - "sources_tried": res.get("_sources_tried", []), }) - await auto_shot_grid(f"بعد: {cmd[:45]}", delay=0.4) + asyncio.create_task(shot(f"after: {cmd[:40]}", delay=0.4)) - # ── mouse_move ──────────────────────────────────── + # ── mouse_move ─────────────────────────────────────────── elif action == "mouse_move": x, y = int(data.get("x", 0)), int(data.get("y", 0)) - # --sync ينتظر حتى يؤكد X server استلام الأمر await xdo(["mousemove", "--sync", str(x), str(y)]) await send({"type": "ack", "action": "mouse_move", "x": x, "y": y}) - # مرّر الإحداثيات مباشرةً لتجنّب race condition مع X server - await auto_shot_grid(f"ماوس → ({x},{y})", delay=0.2, - force_mx=x, force_my=y) + asyncio.create_task(shot(f"move ({x},{y})", delay=0.2, force_mx=x, force_my=y)) - # ── mouse_click ─────────────────────────────────── + # ── mouse_click ────────────────────────────────────────── elif action == "mouse_click": - x, y = int(data.get("x", 0)), int(data.get("y", 0)) - btn_num = {"left": "1", "middle": "2", "right": "3"}.get(data.get("button", "left"), "1") + x, y = int(data.get("x", 0)), int(data.get("y", 0)) + btn = {"left": "1", "middle": "2", "right": "3"}.get(data.get("button", "left"), "1") + double = data.get("double", False) await xdo(["mousemove", "--sync", str(x), str(y)]) - await asyncio.sleep(0.08) - if data.get("double"): - await xdo(["click", "--repeat", "2", "--delay", "100", btn_num]) + await asyncio.sleep(0.07) + if double: + await xdo(["click", "--repeat", "2", "--delay", "100", btn]) else: - await xdo(["click", btn_num]) - btn_name = {"1": "left", "2": "middle", "3": "right"}.get(btn_num, "left") + await xdo(["click", btn]) + btn_name = {"1": "left", "2": "middle", "3": "right"}.get(btn, "left") await send({"type": "ack", "action": "mouse_click", "x": x, "y": y, "button": btn_name}) - # مرّر الإحداثيات مباشرةً لتجنّب race condition - await auto_shot_grid(f"نقر {btn_name} → ({x},{y})", delay=0.5, - force_mx=x, force_my=y) + asyncio.create_task(shot(f"click {btn_name} ({x},{y})", delay=0.5, force_mx=x, force_my=y)) - # ── keyboard_type ───────────────────────────────── + # ── mouse_drag ─────────────────────────────────────────── + elif action == "mouse_drag": + x1, y1 = int(data.get("x1", 0)), int(data.get("y1", 0)) + x2, y2 = int(data.get("x2", 0)), int(data.get("y2", 0)) + await xdo(["mousemove", str(x1), str(y1)]) + await xdo(["mousedown", "1"]) + await asyncio.sleep(0.1) + await xdo(["mousemove", str(x2), str(y2)]) + await asyncio.sleep(0.1) + await xdo(["mouseup", "1"]) + await send({"type": "ack", "action": "mouse_drag"}) + asyncio.create_task(shot("after drag", delay=0.4, force_mx=x2, force_my=y2)) + + # ── keyboard_type ──────────────────────────────────────── elif action == "keyboard_type": - text = _enforce_safesearch(data.get("text", "")) + text = _safe_search(data.get("text", "")) if text: - result = await type_text_smart(text) - await send({"type": "ack", "action": "keyboard_type", - "method": result["method"], "text_len": len(text)}) - else: - await send({"type": "ack", "action": "keyboard_type"}) - await auto_shot_grid("بعد الكتابة", delay=0.5) + res = await type_smart(text) + await send({"type": "ack", "action": "keyboard_type", "method": res["method"]}) + asyncio.create_task(shot("after type", delay=0.5)) - # ── keyboard_hotkey ─────────────────────────────── + # ── keyboard_hotkey ────────────────────────────────────── elif action == "keyboard_hotkey": keys = data.get("keys", []) if keys: await xdo(["key", "--clearmodifiers", "+".join(keys)]) await send({"type": "ack", "action": "keyboard_hotkey", "keys": keys}) - await auto_shot_grid("بعد الاختصار", delay=0.5) + asyncio.create_task(shot("after hotkey", delay=0.4)) - # ── keyboard_press ──────────────────────────────── + # ── keyboard_press ─────────────────────────────────────── elif action == "keyboard_press": key = data.get("key", "") if key: await xdo(["key", "--clearmodifiers", key]) await send({"type": "ack", "action": "keyboard_press"}) - await auto_shot_grid("بعد المفتاح", delay=0.4) - - # ── clipboard_write ─────────────────────────────── - elif action == "clipboard_write": - text = data.get("text", "") - try: - disp = DISPLAY_OF() - def _xclip_write2(): - proc = subprocess.Popen( - ["xclip", "-selection", "clipboard"], - stdin=subprocess.PIPE, - env={**os.environ, "DISPLAY": disp} - ) - proc.communicate(text.encode("utf-8")) - await asyncio.to_thread(_xclip_write2) - await send({"type": "ack", "action": "clipboard_write", "length": len(text)}) - except Exception as e: - await send({"type": "error", "action": "clipboard_write", "msg": str(e)}) + asyncio.create_task(shot("after key", delay=0.4)) - # ── clipboard_read ──────────────────────────────── - elif action == "clipboard_read": - res = await asyncio.to_thread(run_raw_command, "xclip -selection clipboard -o", 5) - await send({"type": "clipboard_content", "text": res["stdout"]}) - - # ── scroll ──────────────────────────────────────── + # ── scroll ─────────────────────────────────────────────── elif action == "scroll": - x, y = int(data.get("x", 0)), int(data.get("y", 0)) - clicks = int(data.get("clicks", 3)) - btn = "4" if clicks > 0 else "5" + x, y = int(data.get("x", 960)), int(data.get("y", 540)) + clicks = max(-5, min(5, int(data.get("clicks", 3)))) + btn = "4" if clicks > 0 else "5" await xdo(["mousemove", str(x), str(y)]) for _ in range(abs(clicks)): await xdo(["click", btn]) - await asyncio.sleep(0.03) + await asyncio.sleep(0.025) await send({"type": "ack", "action": "scroll", "clicks": clicks}) - await auto_shot_grid("بعد التمرير", delay=0.4) + asyncio.create_task(shot("after scroll", delay=0.4)) - # ── open_app ────────────────────────────────────── + # ── clipboard_write ────────────────────────────────────── + elif action == "clipboard_write": + text = data.get("text", "") + env = {**os.environ, "DISPLAY": SHARED_DISPLAY} + def _clip(): + p = subprocess.Popen(["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE, env=env) + p.communicate(text.encode("utf-8")) + await asyncio.to_thread(_clip) + await send({"type": "ack", "action": "clipboard_write", "length": len(text)}) + + # ── clipboard_read ─────────────────────────────────────── + elif action == "clipboard_read": + res = await run_cmd("xclip -selection clipboard -o", 5) + await send({"type": "clipboard_content", "text": res["stdout"]}) + + # ── paste ──────────────────────────────────────────────── + elif action == "paste": + text = data.get("text", "") + if text: + env = {**os.environ, "DISPLAY": SHARED_DISPLAY} + def _clip2(): + p = subprocess.Popen(["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE, env=env) + p.communicate(text.encode("utf-8")) + await asyncio.to_thread(_clip2) + await asyncio.sleep(0.1) + await xdo(["key", "--clearmodifiers", "ctrl+v"]) + await send({"type": "ack", "action": "paste"}) + asyncio.create_task(shot("after paste", delay=0.5)) + + # ── open_app ───────────────────────────────────────────── elif action == "open_app": - app_cmd = data.get("cmd", "") - if app_cmd: - fixed_cmd = _enforce_safesearch(app_cmd) - # إصلاح firefox-esr - if "firefox-esr" in fixed_cmd: - esr_check = await asyncio.to_thread(subprocess.run, ["which", "firefox-esr"], capture_output=True, text=True) - if esr_check.returncode != 0: - fixed_cmd = fixed_cmd.replace("firefox-esr", BROWSER) - - # ── حماية إضافية: حذف ملفات قفل Firefox المتبقية قبل فتح نافذة جديدة ── - # يمنع ظهور "Firefox is already running, but is not responding" حتى لو - # نسي الذكاء تضمين هذا التنظيف في أمره الخاص. - if any(b in fixed_cmd for b in ("firefox", "Firefox")): - def _clean_lock(): - try: - subprocess.run( - "rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock 2>/dev/null", - shell=True, executable="/bin/bash", timeout=3, capture_output=True - ) - except Exception: - pass - await asyncio.to_thread(_clean_lock) - - subprocess.Popen(fixed_cmd, shell=True, - env={**os.environ, "DISPLAY": DISPLAY_OF()}, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - await send({"type": "ack", "action": "open_app", "cmd": fixed_cmd}) - - wait_time = 7.0 if any(x in app_cmd for x in ["firefox", "chromium", "chrome"]) else 4.0 - await asyncio.sleep(2.0) - await auto_shot_grid(f"فتح: {fixed_cmd[:40]} (2s)", delay=0) - await asyncio.sleep(wait_time - 2.0) - await auto_shot_grid(f"بعد تحميل: {fixed_cmd[:40]}", delay=0) - else: + cmd = _safe_search(data.get("cmd", "")) + if not cmd: await send({"type": "ack", "action": "open_app"}) + return - # ── open_browser — فتح المتصفح ──────────────────── + # أضف profile للـ firefox إذا لم يكن محدداً + profile = sess.get("profile", "") + if profile and any(b in cmd for b in ("firefox", "chromium")): + if "--profile" not in cmd: + cmd = cmd.replace(BROWSER, f"{BROWSER} --profile {profile}", 1) + + # نظّف lock files + if "firefox" in cmd.lower(): + await run_cmd("rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock 2>/dev/null", 3) + + env = {**os.environ, "DISPLAY": SHARED_DISPLAY} + proc = subprocess.Popen( + cmd, shell=True, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + if any(b in cmd for b in ("firefox", "chromium", "chrome")): + sess["browser_proc"] = proc + + await send({"type": "ack", "action": "open_app", "cmd": cmd}) + # screenshot مبكر ثم واحد بعد التحميل + asyncio.create_task(shot("opening app (2s)", delay=2.0)) + asyncio.create_task(shot("app loaded (7s)", delay=7.0)) + + # ── open_browser ───────────────────────────────────────── elif action == "open_browser": - url = data.get("url", "") - if not url: - url = "about:blank" - browser_result = open_browser_smart(url) - await send({"type": "ack", "action": "open_browser", "result": browser_result}) - await asyncio.sleep(2.0) - await auto_shot_grid(f"فتح المتصفح: {url[:50]}", delay=0) - await asyncio.sleep(5.0) - await auto_shot_grid("بعد تحميل المتصفح", delay=0) - - # ── mouse_drag ──────────────────────────────────── - elif action == "mouse_drag": - x1, y1 = int(data.get("x1", 0)), int(data.get("y1", 0)) - x2, y2 = int(data.get("x2", 0)), int(data.get("y2", 0)) - await xdo(["mousemove", str(x1), str(y1)]) - await xdo(["mousedown", "1"]) - await asyncio.sleep(0.1) - await xdo(["mousemove", str(x2), str(y2)]) - await asyncio.sleep(0.1) - await xdo(["mouseup", "1"]) - await send({"type": "ack", "action": "mouse_drag"}) - # cursor ينتهي عند x2,y2 - await auto_shot_grid("بعد السحب", delay=0.4, force_mx=x2, force_my=y2) - - # ── start_stream / stop_stream: غير مستخدمة من الواجهة الحالية، أُزيلت لمنع - # تشغيل حلقة تصوير مستمرة في الخلفية بلا داعٍ (كانت تستهلك موارد حتى بعد إغلاق الاتصال) ── - - # ── open_tab ────────────────────────────────────── + url = _safe_search(data.get("url", "") or "about:blank") + profile = sess.get("profile", "") + cmd = get_browser_cmd(url, profile) + env = {**os.environ, "DISPLAY": SHARED_DISPLAY} + await run_cmd("rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock 2>/dev/null", 3) + proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + sess["browser_proc"] = proc + await send({"type": "ack", "action": "open_browser", "url": url}) + asyncio.create_task(shot("browser opening (2s)", delay=2.0)) + asyncio.create_task(shot("browser loaded (7s)", delay=7.0)) + + # ── open_tab ───────────────────────────────────────────── elif action == "open_tab": - url = _enforce_safesearch(data.get("url", "") or "about:blank") + url = _safe_search(data.get("url", "") or "about:blank") await xdo(["key", "--clearmodifiers", "ctrl+t"]) await asyncio.sleep(0.4) await xdo(["key", "--clearmodifiers", "ctrl+l"]) await asyncio.sleep(0.2) - await type_text_smart(url) + await type_smart(url) await asyncio.sleep(0.2) await xdo(["key", "--clearmodifiers", "Return"]) await send({"type": "ack", "action": "open_tab", "url": url}) - await auto_shot_grid("بعد فتح تبويب جديد", delay=1.5) + asyncio.create_task(shot("after open_tab", delay=1.5)) - # ── close_tab ───────────────────────────────────── + # ── close_tab ──────────────────────────────────────────── elif action == "close_tab": await xdo(["key", "--clearmodifiers", "ctrl+w"]) await send({"type": "ack", "action": "close_tab"}) - await auto_shot_grid("بعد إغلاق التبويب", delay=0.5) + asyncio.create_task(shot("after close_tab", delay=0.5)) - # ── browser_back ────────────────────────────────── + # ── browser_back ───────────────────────────────────────── elif action == "browser_back": await xdo(["key", "--clearmodifiers", "alt+Left"]) await send({"type": "ack", "action": "browser_back"}) - await auto_shot_grid("بعد الرجوع", delay=0.8) + asyncio.create_task(shot("after back", delay=0.8)) - # ── browser_forward ─────────────────────────────── + # ── browser_forward ────────────────────────────────────── elif action == "browser_forward": await xdo(["key", "--clearmodifiers", "alt+Right"]) await send({"type": "ack", "action": "browser_forward"}) - await auto_shot_grid("بعد التقدم", delay=0.8) + asyncio.create_task(shot("after forward", delay=0.8)) - # ── browser_search: اختصار لفتح بحث مباشرة في التبويب الحالي ── + # ── browser_search ─────────────────────────────────────── elif action == "browser_search": - url = _enforce_safesearch(data.get("url", "") or data.get("query", "")) + url = _safe_search(data.get("url", "") or data.get("query", "")) await xdo(["key", "--clearmodifiers", "ctrl+l"]) await asyncio.sleep(0.2) - await type_text_smart(url) - await asyncio.sleep(0.2) + await type_smart(url) + await asyncio.sleep(0.15) await xdo(["key", "--clearmodifiers", "Return"]) - await send({"type": "ack", "action": "browser_search", "url": url}) - await auto_shot_grid("بعد البحث", delay=1.5) + await send({"type": "ack", "action": "browser_search"}) + asyncio.create_task(shot("after search", delay=1.5)) - # ── screen_info ─────────────────────────────────── + # ── screen_info ────────────────────────────────────────── elif action == "screen_info": - w, h = await asyncio.to_thread(_get_screen_size) + w, h = await asyncio.to_thread(_get_screen_size) mx, my = await asyncio.to_thread(_get_mouse_pos) await send({ "type": "screen_info", "width": w, "height": h, "mouse_x": mx, "mouse_y": my, "browser": BROWSER, + "display": SHARED_DISPLAY, }) - # ── paste ───────────────────────────────────────── - elif action == "paste": - text = data.get("text", "") - if text: - disp = DISPLAY_OF() - def _xclip_paste(): - proc = subprocess.Popen( - ["xclip", "-selection", "clipboard"], - stdin=subprocess.PIPE, - env={**os.environ, "DISPLAY": disp} - ) - proc.communicate(text.encode("utf-8")) - await asyncio.to_thread(_xclip_paste) - await asyncio.sleep(0.1) - await xdo(["key", "--clearmodifiers", "ctrl+v"]) - await send({"type": "ack", "action": "paste"}) - await auto_shot_grid("بعد اللصق", delay=0.5) - - # ── unknown ─────────────────────────────────────── + # ── unknown ────────────────────────────────────────────── else: - await send({ - "type": "error", - "msg": f"Unknown action: '{action}'. Available: screenshot, terminal, mouse_move, mouse_click, mouse_drag, keyboard_type, keyboard_hotkey, keyboard_press, clipboard_write, clipboard_read, scroll, open_app, open_tab, close_tab, browser_back, browser_forward, browser_search, open_browser, paste, simple_command, screen_info" - }) + await send({"type": "error", "msg": f"Unknown action: '{action}'"}) -# ─── WebSocket ─────────────────────────────────────── +# ════════════════════════════════════════════════════════════════ +# ── WebSocket Endpoint with Heartbeat ──────────────────────── +# ════════════════════════════════════════════════════════════════ @app.websocket("/ws") async def websocket_endpoint(ws: WebSocket): await ws.accept() active_connections.append(ws) - # ── عزل كامل: إنشاء Xvfb + متصفح خاصين بهذا الاتصال فقط ─�� - # كل اتصال WebSocket يحصل على بيئة كمبيوتر افتراضي مستقلة تماماً عن أي اتصال آخر. - # إذا فشل إنشاء جلسة معزولة لأي سبب (صلاحيات، موارد، Xvfb غير متاح...)، نرجع فوراً - # للوضع المشترك القديم (DISPLAY الأساسي) بدل أن ينهار الاتصال بصمت بالكامل. - session_display = _BASE_DISPLAY - try: - session_display = await asyncio.wait_for(create_session_for(ws), timeout=8.0) - except asyncio.TimeoutError: - print("[session] ⚠️ session creation timed out (>8s), falling back to shared display") - session_display = _BASE_DISPLAY - except Exception as e: - print(f"[session] ⚠️ failed to create isolated session, falling back to shared display: {e}") - session_display = _BASE_DISPLAY - _current_display.set(session_display) # يُربط بـ task هذا الاتصال — لا يؤثر على اتصالات أخرى + sess = await create_session(ws) + + async def _heartbeat(): + """Ping كل 20 ثانية لمنع قطع الاتصال من proxies.""" + while True: + await asyncio.sleep(20) + try: + await ws.send_text(json.dumps({"type": "ping", "ts": int(time.time()*1000)})) + except Exception: + break + + hb_task = asyncio.create_task(_heartbeat()) try: w, h = await asyncio.to_thread(_get_screen_size) await ws.send_text(json.dumps({ "type": "connected", - "screen_width": w, - "screen_height": h, + "screen_width": w, "screen_height": h, "browser": BROWSER, - "display": session_display, - "msg": f"Z Computer Mode v7 — Smart Control | Browser: {BROWSER} | Screen: {w}x{h} | Isolated session: {session_display}", - "commands": [ - "screenshot", "terminal {cmd}", "mouse_move {x,y}", "mouse_click {x,y,button}", - "keyboard_type {text}", "keyboard_hotkey {keys}", "scroll {x,y,clicks}", - "open_app {cmd}", "open_browser {url}", "simple_command {cmd}", - "Simple cmds: 'open firefox', 'mouse go x y', 'click x y', 'type TEXT', 'key enter'" - ] + "display": SHARED_DISPLAY, + "session_id": id(ws), + "msg": f"Z Computer Mode v8 | Browser: {BROWSER} | Screen: {w}x{h}", }, ensure_ascii=False)) - # أرسل screenshot أولية مع grid (لشاشة هذه الجلسة المعزولة فقط) - result = await asyncio.to_thread(capture_screen_with_grid, scale=0.65, quality=72) + + # لقطة شاشة أولية + result = await asyncio.to_thread(capture_with_grid, 0.65, 72) if result["data"]: await ws.send_text(json.dumps({ "type": "screenshot", "data": result["data"], "ts": int(time.time() * 1000), - "label": "الشاشة الأولية", + "label": "Initial screen", "screen_width": result["width"], "screen_height": result["height"], "mouse_x": result["mouse_x"], "mouse_y": result["mouse_y"], "has_grid": True, }, ensure_ascii=False)) + sess["last_frame_hash"] = _frame_hash(result["data"]) + except Exception as e: - print(f"[ws] ⚠️ error sending initial messages: {e}") + print(f"[ws] init error: {e}") + try: while True: raw = await ws.receive_text() - await handle_action(ws, json.loads(raw)) + try: + msg = json.loads(raw) + # تجاهل pong responses + if msg.get("type") == "pong": + continue + await handle_action(ws, msg, sess) + except json.JSONDecodeError: + pass except WebSocketDisconnect: pass except Exception as e: - print(f"[ws] {e}") + print(f"[ws] error: {e}") finally: + hb_task.cancel() if ws in active_connections: active_connections.remove(ws) - # ── تحرير بيئة هذه الجلسة بالكامل (Xvfb + المتصفح) فوراً عند انقطاع الاتصال ── - await destroy_session_for(ws) + await destroy_session(ws) -# ─── REST ──────────────────────────────────────────── +# ════════════════════════════════════════════════════════════════ +# ── REST Endpoints ──────────────────────────────────────────── +# ════════════════════════════════════════════════════════════════ @app.get("/screenshot") async def rest_screenshot(): - result = capture_screen_with_grid(scale=0.7, quality=73) + result = await asyncio.to_thread(capture_with_grid, 0.7, 75) return JSONResponse({ "image": result["data"], "ts": int(time.time() * 1000), @@ -1367,55 +1024,56 @@ async def rest_screenshot(): "has_grid": True, }) -@app.get("/screenshot/clean") -async def rest_screenshot_clean(): - return JSONResponse({"image": capture_screen(0.75, 75), "ts": int(time.time() * 1000)}) - @app.post("/terminal") async def rest_terminal(body: dict): - return JSONResponse(run_command_smart(body.get("cmd", ""), body.get("timeout", 60))) - -@app.post("/simple") -async def rest_simple(body: dict): - """تنفيذ أمر بسيط عبر REST""" - raw = body.get("cmd", "").strip() - parsed = parse_simple_command(raw) - if not parsed: - return JSONResponse({"ok": False, "error": f"Unknown simple command: {raw}"}, status_code=400) - return JSONResponse({"ok": True, "parsed": parsed}) + return JSONResponse(await run_cmd_smart(body.get("cmd", ""), body.get("timeout", 60))) @app.get("/health") async def health(): - w, h = _get_screen_size() - mx, my = _get_mouse_pos() - browser_check = subprocess.run(["which", "firefox-esr"], capture_output=True, text=True) + w, h = await asyncio.to_thread(_get_screen_size) + mx, my = await asyncio.to_thread(_get_mouse_pos) return { "status": "ok", - "version": "v7-smart-control", + "version": "v8-stable", "browser": BROWSER, - "firefox_esr_available": browser_check.returncode == 0, - "screen_width": w, - "screen_height": h, - "mouse_x": mx, - "mouse_y": my, + "display": SHARED_DISPLAY, + "screen": f"{w}x{h}", + "mouse": f"{mx},{my}", + "active_sessions": len(_active_sessions), } -@app.get("/search/{query}") -async def quick_search(query: str): - res = run_command_smart( - f"curl -s --max-time 15 'https://api.duckduckgo.com/?q={urllib.parse.quote_plus(query)}&format=json&no_html=1'", - timeout=30 - ) - return JSONResponse(res) - - -if __name__ == "__main__": - port = int(os.environ.get("PORT", 7860)) - uvicorn.run("app:app", host="0.0.0.0", port=port, log_level="info") - +# ════════════════════════════════════════════════════════════════ +# ── Background Tasks ────────────────────────────────────────── +# ════════════════════════════════════════════════════════════════ +async def _cleanup_tmp(): + """ينظّف /tmp كل 5 دقائق لمنع امتلاء القرص في HuggingFace.""" + while True: + await asyncio.sleep(300) + try: + r = subprocess.run( + ["find", "/tmp", "-name", "zss_*", "-mmin", "+10", "-delete"], + capture_output=True, timeout=10 + ) + r2 = subprocess.run( + ["find", "/tmp", "-name", "zpc_*", "-mmin", "+60", + "-not", "-newer", "/tmp", "-delete"], + capture_output=True, timeout=10 + ) + except Exception as e: + print(f"[cleanup] {e}") +@app.on_event("startup") +async def startup(): + # تشغيل Xvfb المشترك + await asyncio.to_thread(_ensure_xvfb) + # تشغيل التنظيف الدوري + asyncio.create_task(_cleanup_tmp()) + print("✅ Z Computer Mode v8 ready") +if __name__ == "__main__": + port = int(os.environ.get("PORT", 7860)) + uvicorn.run("app:app", host="0.0.0.0", port=port, log_level="info")