THEZYZSTUDIO commited on
Commit
c8dca01
·
verified ·
1 Parent(s): 537a87e

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1712
app.py DELETED
@@ -1,1712 +0,0 @@
1
- """
2
- THE Z AI — Computer Mode Server v11 — PER-USER ISOLATED DISPLAYS
3
- =================================================================
4
- كل مستخدم لديه:
5
- ✅ Display Xvfb خاص (:100, :101, :102 ...)
6
- ✅ متصفح Firefox معزول تماماً لا يتشارك مع أي مستخدم آخر
7
- ✅ لقطات شاشة مستقلة — ما يراه مستخدم A لا يختلط بمستخدم B
8
- ✅ تاريخ terminal مستقل لكل مستخدم
9
- ✅ عند كل اتصال جديد للمستخدم: نفس الـ display (لا يُعاد إنشاء Xvfb)
10
- ✅ عند طلب ريستارت / كمبيوتر جديد: Xvfb + Firefox يُعادان تماماً
11
-
12
- الفكرة:
13
- - كل user_id (email) ↦ display رقم ثابت (مثلاً :100 للأول، :101 للثاني)
14
- - إذا المستخدم يتصل مجدداً، نُعيد استخدام نفس الـ display الخاص به
15
- - إذا طلب ريستارت، نقتل الـ Xvfb والمتصفح ونُعيد إنشاءهما
16
- - إذا المستخدم جديد، نخصص له display جديد ونُشغّل Xvfb له
17
- """
18
-
19
- import asyncio
20
- import base64
21
- import hashlib
22
- import io
23
- import json
24
- import os
25
- import re
26
- import shutil
27
- import subprocess
28
- import sys
29
- import time
30
- import urllib.parse
31
- import threading
32
-
33
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
34
- from fastapi.middleware.cors import CORSMiddleware
35
- from fastapi.responses import JSONResponse, HTMLResponse
36
- import uvicorn
37
-
38
- # ════════════════════════════════════════════════════════════════
39
- # ── إعداد Display Pool ─────────────────────────────────────────
40
- # ════════════════════════════════════════════════════════════════
41
-
42
- # بداية نطاق الـ displays الافتراضية (يمكن تغييره)
43
- DISPLAY_BASE = 100
44
- DISPLAY_MAX = 200 # أقصى عدد مستخدمين متزامنين
45
-
46
- _display_lock = threading.Lock()
47
-
48
- # user_id → display_info
49
- # display_info = {
50
- # "display": ":101",
51
- # "xvfb_proc": subprocess.Popen | None,
52
- # "browser_proc": subprocess.Popen | None,
53
- # "last_bg_shot_ts": float,
54
- # "last_bg_hash": str,
55
- # "active_ws": WebSocket | None, # الاتصال الحالي لهذا المستخدم
56
- # }
57
- _user_displays: dict[str, dict] = {}
58
- _display_numbers: set[int] = set() # أرقام الـ displays المستخدمة
59
-
60
- # semaphore للتحكم في عمليات terminal (global لكل السيرفر)
61
- _terminal_sem = asyncio.Semaphore(8)
62
-
63
- # ════════════════════════════════════════════════════════════════
64
- # ── اكتشاف المتصفح ────────────────────────────────────────────
65
- # ════════════════════════════════════════════════════════════════
66
-
67
- def _detect_browser() -> str:
68
- for c in ["firefox", "firefox-esr", "chromium-browser", "chromium", "google-chrome"]:
69
- r = subprocess.run(["which", c], capture_output=True, text=True)
70
- if r.returncode == 0 and r.stdout.strip():
71
- return c
72
- return "firefox"
73
-
74
- BROWSER = _detect_browser()
75
- print(f"🌐 Browser: {BROWSER}")
76
-
77
- # ── CLI flags ثابتة تُضاف لكل تشغيل Firefox ──
78
- # تسرّع الإقلاع (لا GPU فيزيائي حقيقي في Xvfb فلا داعي لمحاولة
79
- # التسريع المرئي الذي يفشل دائماً هنا ويسبّب تأخيراً في الإقلاع)،
80
- # وتمنع أي nag screens أو crash-reporter windows من الظهور.
81
- FIREFOX_CLI_FLAGS = ["--no-remote", "--new-instance"]
82
- FIREFOX_ENV_EXTRA = {
83
- # يمنع محاولات تسريع GPU غير المتوفرة في Xvfb من إبطاء الإقلاع
84
- "MOZ_DISABLE_GPU_SANDBOX": "1",
85
- "MOZ_ACCELERATED": "0",
86
- }
87
-
88
- # ════════════════════════════════════════════════════════════════
89
- # ── Firefox Profile (per-user, isolated, crash-recovery DISABLED) ─
90
- # ════════════════════════════════════════════════════════════════
91
- # المشكلة الجذرية التي هذا القسم يحلّها:
92
- # عند قتل Firefox (حتى بـ pkill "لطيف")، لا يُغلق بشكل نظيف دائماً على
93
- # سيرفر بموارد محدودة/Xvfb، فتُسجَّل الجلسة على أنها "crashed" داخل
94
- # sessionstore.jsonlz4 الخاص بالبروفايل. في المرة التالية يفتح Firefox
95
- # ويعرض شاشة "Sorry. We're having trouble getting your pages back" بدل
96
- # الصفحة المطلوبة — وهذه هي الشاشة السوداء/الفاشلة التي تصل كلقطة "ناجحة"
97
- # تقنياً (ليست سوداء فعلاً) لكنها لا تحتوي على المحتوى المطلوب أبداً.
98
- # الحل الجذري: بروفايل خاص بكل مستخدم (isolated) مع user.js يعطّل تماماً:
99
- # - استرجاع الجلسة بعد التحطّم (session restore prompt)
100
- # - أي محاولة لإعادة فتح تبويبات سابقة
101
- # بهذا، كل فتح لفايرفوكس يبدأ صفحة نظيفة فارغة دائماً، بغض النظر عن كيفية
102
- # إغلاقه سابقاً.
103
-
104
- FIREFOX_PROFILE_PREFS = """
105
- // ── تعطيل استرجاع الجلسة نهائياً (سبب شاشة "Restore Session") ──
106
- user_pref("browser.sessionstore.resume_from_crash", false);
107
- user_pref("browser.sessionstore.resume_session_once", false);
108
- user_pref("browser.sessionstore.max_resumed_crashes", -1);
109
- user_pref("browser.sessionstore.restore_on_demand", false);
110
- user_pref("browser.sessionstore.enabled", false);
111
- user_pref("browser.sessionstore.privacy_level", 2);
112
- user_pref("browser.startup.page", 0);
113
- user_pref("browser.startup.homepage_override.mstone", "ignore");
114
- user_pref("toolkit.startup.max_resumed_crashes", -1);
115
- // تعطيل نافذة "Restore previous session" ونوافذ الأزمات المختلفة
116
- user_pref("browser.sessionstore.max_tabs_undo", 0);
117
- user_pref("browser.sessionstore.max_windows_undo", 0);
118
- user_pref("browser.tabs.crashReporting.sendReport", false);
119
- user_pref("browser.crashReports.unsubmittedCheck.autoSubmit2", false);
120
- user_pref("browser.crashReports.unsubmittedCheck.enabled", false);
121
- user_pref("toolkit.crashreporter.infoURL", "");
122
- // تعطيل شاشة "Restore" وكذلك أي إشعار تحطّم
123
- user_pref("browser.sessionstore.resumeFromCrash", false);
124
- user_pref("dom.ipc.plugins.flash.subprocess.crashreporter.enabled", false);
125
- // إلغاء استعادة آخر جلسة تماماً + دائماً about:blank
126
- user_pref("browser.startup.homepage", "about:blank");
127
- // تعطيل تحديثات ورسائل onboarding التي قد تبطئ أو تحجب اللقطة
128
- user_pref("browser.shell.checkDefaultBrowser", false);
129
- user_pref("browser.aboutwelcome.enabled", false);
130
- user_pref("browser.startup.firstrunSkipsHomepage", true);
131
- user_pref("startup.homepage_welcome_url", "");
132
- user_pref("startup.homepage_welcome_url.additional", "");
133
- user_pref("browser.uitour.enabled", false);
134
- user_pref("browser.newtabpage.activity-stream.feeds.telemetry", false);
135
- user_pref("datareporting.policy.dataSubmissionEnabled", false);
136
- user_pref("app.normandy.enabled", false);
137
- user_pref("app.update.enabled", false);
138
- user_pref("app.update.auto", false);
139
- // ── إصلاحات تجمد التحميل ("Connecting to ... تبقى للأبد") ──
140
- // السبب الغالب على سيرفرات مجانية/container: محاولة IPv6 أولاً (غير
141
- // مدعوم فعلياً على أغلب مزودي Render/HuggingFace المجانية) قبل الرجوع
142
- // لـ IPv4 بعد timeout طويل جداً (قد يصل 20-30 ثانية لكل طلب DNS)،
143
- // وهذا بالضبط السبب الذي يظهر كشاشة "Connecting..." معلقة لا تتقدم أبداً.
144
- user_pref("network.dns.disableIPv6", true);
145
- user_pref("network.http.fast-fallback-to-IPv4", true);
146
- // تقليل مهلة connect الإجمالية لكل طلب من 15 ثانية (الافتراضي) إلى 8 ثوانٍ
147
- // — أفضل أن يفشل الطلب بسرعة ويقرر الذكاء الاصطناعي بديلاً من أن يبقى عالقاً طويلاً.
148
- user_pref("network.http.connection-timeout", 8);
149
- user_pref("network.http.response.timeout", 12);
150
- // تعطيل predictive prefetching/speculative connect التي تستهلك عرض حزمة على
151
- // سيرفر محدود الموارد بدون فائدة حقيقية هنا.
152
- user_pref("network.dns.disablePrefetch", true);
153
- user_pref("network.prefetch-next", false);
154
- user_pref("network.predictor.enabled", false);
155
- user_pref("network.http.speculative-parallel-limit", 0);
156
- // تعطيل safebrowsing الذي يستدعي طلبات خارجية إضافية عند فتح كل صفحة
157
- // (يُبطئ التحميل أو يعلقه لو الطلب لقوائم Google فشل أو استغرق).
158
- user_pref("browser.safebrowsing.malware.enabled", false);
159
- user_pref("browser.safebrowsing.phishing.enabled", false);
160
- user_pref("browser.safebrowsing.downloads.enabled", false);
161
- user_pref("browser.safebrowsing.provider.google4.updateURL", "");
162
- user_pref("browser.safebrowsing.provider.google.updateURL", "");
163
- // تعطيل telemetry/captive-portal checks التي تف��ح اتصالات خلفية غير ضرورية عند الإقلاع
164
- user_pref("network.captive-portal-service.enabled", false);
165
- user_pref("network.connectivity-service.enabled", false);
166
- user_pref("toolkit.telemetry.server", "");
167
- // تقليل عدد اتصالات HTTP المتزامنة لكل دومين — يقلل الضغط على شبكة محدودة
168
- // النطاق للسيرفرات المجانية ويقلل احتمال التعلق.
169
- user_pref("network.http.max-persistent-connections-per-server", 4);
170
- user_pref("network.http.max-connections", 48);
171
- """
172
-
173
- def _ensure_firefox_profile(user_id: str, display: str) -> str:
174
- """
175
- يُنشئ (أو يُعيد استخدام) بروفايل Firefox مخصص ونظيف لهذا المستخدم،
176
- مع إعدادات تعطّل استرجاع الجلسة (crash recovery) نهائياً.
177
- يُعيد المسار الكامل للبروفايل.
178
- """
179
- safe_id = re.sub(r"[^a-zA-Z0-9_.-]", "_", user_id) or "anon"
180
- profile_dir = os.path.expanduser(f"~/.zai_ff_profiles/{safe_id}")
181
- try:
182
- os.makedirs(profile_dir, exist_ok=True)
183
- prefs_path = os.path.join(profile_dir, "user.js")
184
- with open(prefs_path, "w", encoding="utf-8") as f:
185
- f.write(FIREFOX_PROFILE_PREFS)
186
- except Exception as e:
187
- print(f"[profile] ⚠️ failed to prepare profile for {user_id}: {e}")
188
- return profile_dir
189
-
190
-
191
- def _wipe_firefox_session_data(profile_dir: str):
192
- """
193
- يمسح ملفات الجلسة الفاسدة (sessionstore) + أي lock متبقٍّ.
194
- هذا ضروري لأن مجرد تعطيل session restore في user.js لا يمسح ملفات
195
- جلسة سابقة موجودة بالفعل على القرص من قبل تفعيل هذه الإعدادات.
196
- """
197
- try:
198
- subprocess.run(
199
- f"rm -f '{profile_dir}'/lock '{profile_dir}'/.parentlock "
200
- f"'{profile_dir}'/sessionstore.jsonlz4 "
201
- f"'{profile_dir}'/sessionstore-backups/*.jsonlz4 "
202
- f"'{profile_dir}'/sessionCheckpoints.json 2>/dev/null",
203
- shell=True, timeout=5, capture_output=True
204
- )
205
- except Exception:
206
- pass
207
-
208
-
209
- # ════════════════════════════════════════════════════════════════
210
- # ── Xvfb Management (per-display) ────────────────────────────
211
- # ════════════════════════════════════════════════════════════════
212
-
213
- def _next_free_display() -> int:
214
- """يجد رقم display حر غير مستخدم."""
215
- used = _display_numbers.copy()
216
- for n in range(DISPLAY_BASE, DISPLAY_MAX):
217
- if n not in used:
218
- return n
219
- # إذا امتلأت القائمة — أعد استخدام أقدم display غير نشط
220
- return DISPLAY_BASE
221
-
222
-
223
- def _start_xvfb(display: str) -> "subprocess.Popen | None":
224
- """يُشغّل Xvfb على display محدد ويُعيد الـ Popen أو None."""
225
- # تحقق أولاً: هل الـ display يعمل بالفعل؟
226
- try:
227
- r = subprocess.run(
228
- ["xdpyinfo", "-display", display],
229
- capture_output=True, timeout=3
230
- )
231
- if r.returncode == 0:
232
- print(f"[xvfb] ✅ Display {display} already active")
233
- return None # يعمل بالفعل بدون Popen نشغّله
234
- except Exception:
235
- pass
236
-
237
- try:
238
- # ── دقة 1280x800 بدل 1920x1080 ──────────────────────────────
239
- # هذا يقلل حجم بيانات كل لقطة شاشة بنسبة ~60%، وبالتالي يسرّع كل
240
- # مراحل الالتقاط والترميز بشكل كبير على سيرفر بموارد محدودة.
241
- # نفس الدقة المستخدمة في التطبيقات المرجعية لـ computer-use.
242
- proc = subprocess.Popen(
243
- ["Xvfb", display, "-screen", "0", "1280x800x24",
244
- "-nolisten", "tcp", "-ac"],
245
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
246
- )
247
- # انتظر حتى يصبح جاهزاً (max 6 ثواني)
248
- for _ in range(60):
249
- time.sleep(0.1)
250
- try:
251
- r = subprocess.run(
252
- ["xdpyinfo", "-display", display],
253
- capture_output=True, timeout=2
254
- )
255
- if r.returncode == 0:
256
- print(f"[xvfb] ✅ Xvfb started on {display}")
257
- # إصلاح: Xvfb بدون أي رسم لاحق يكون framebuffer فارغاً تماماً
258
- # (لا لون خلفية أصلاً)، فأول لقطة شاشة قد تبدو "فارغة بشكل
259
- # مربك" رغم أنها التقاط ناجح تقنياً لشاشة نظيفة فعلاً. نرسم
260
- # لوناً محايداً بسيطاً فوراً عبر xsetroot لتوضيح أن الشاشة
261
- # جاهزة وفارغة عمداً (سطح مكتب نظيف) لا أنها خطأ في الالتقاط.
262
- try:
263
- env_xs = {**os.environ, "DISPLAY": display}
264
- subprocess.run(
265
- ["xsetroot", "-solid", "#2b2b2b"],
266
- env=env_xs, timeout=2, capture_output=True
267
- )
268
- except Exception:
269
- pass
270
- return proc
271
- except Exception:
272
- continue
273
- print(f"[xvfb] ⚠️ Xvfb may not be ready yet on {display}")
274
- return proc
275
- except FileNotFoundError:
276
- print(f"[xvfb] ❌ Xvfb binary not found")
277
- return None
278
- except Exception as e:
279
- print(f"[xvfb] ❌ Failed to start on {display}: {e}")
280
- return None
281
-
282
-
283
- def _kill_proc(proc: "subprocess.Popen | None"):
284
- """يقتل process بأمان."""
285
- if not proc:
286
- return
287
- try:
288
- proc.terminate()
289
- proc.wait(timeout=3)
290
- except Exception:
291
- try:
292
- proc.kill()
293
- except Exception:
294
- pass
295
-
296
-
297
- def _kill_display_processes(display: str):
298
- """يقتل كل العمليات المرتبطة بـ display معين."""
299
- env_d = {**os.environ, "DISPLAY": display}
300
- # قتل المتصفح — SIGKILL مباشر (-9) لتفادي أي حالة إغلاق نصفي تُسجَّل
301
- # لاحقاً كتحطّم (crash) داخل Firefox وتُنتج شاشة "Restore Session"
302
- for b in ["firefox", "firefox-esr", "chromium", "chrome"]:
303
- try:
304
- subprocess.run(
305
- ["pkill", "-9", "-f", f"[{b[0]}]{b[1:]}.*{display}"],
306
- timeout=3, capture_output=True
307
- )
308
- except Exception:
309
- pass
310
- # قتل Xvfb على هذا الـ display
311
- try:
312
- subprocess.run(
313
- ["pkill", "-f", f"[X]vfb {display}"],
314
- timeout=3, capture_output=True
315
- )
316
- except Exception:
317
- pass
318
- time.sleep(0.5)
319
-
320
-
321
- # ════════════════════════════════════════════════════════════════
322
- # ── Session Management (per user_id) ─────────────────────────
323
- # ════════════════════════════════════════════════════════════════
324
-
325
- async def get_or_create_user_session(user_id: str, ws: WebSocket) -> dict:
326
- """
327
- يُعيد session المستخدم (أو يُنشئ واحدة جديدة إذا لم تكن موجودة).
328
- كل user_id ← display خاص + Xvfb خاص.
329
- """
330
- with _display_lock:
331
- if user_id in _user_displays:
332
- sess = _user_displays[user_id]
333
- # حدّث الـ WebSocket الحالي
334
- sess["active_ws"] = ws
335
- print(f"[session] 🔁 Reconnected user '{user_id}' on display {sess['display']}")
336
- return sess
337
-
338
- # مستخدم جديد — خصص له display
339
- disp_num = _next_free_display()
340
- _display_numbers.add(disp_num)
341
- display = f":{disp_num}"
342
-
343
- sess = {
344
- "user_id": user_id,
345
- "display": display,
346
- "xvfb_proc": None, # سيُشغَّل لاحقاً
347
- "browser_proc": None,
348
- "last_bg_shot_ts": 0.0,
349
- "last_bg_hash": "",
350
- "active_ws": ws,
351
- "created": time.time(),
352
- "consecutive_capture_failures": 0, # عدّاد فشل الالتقاط المتتالي — يُصفَّر عند أي نجاح أو reset
353
- }
354
- _user_displays[user_id] = sess
355
- print(f"[session] ✅ New user '{user_id}' → display {display}")
356
- return sess
357
-
358
-
359
- async def reset_user_computer(user_id: str) -> dict:
360
- """
361
- يُعيد ضبط الكمبيوتر الافتراضي للمستخدم:
362
- يقتل Xvfb والمتصفح ويُشغّل Xvfb جديداً نظيفاً.
363
- """
364
- with _display_lock:
365
- sess = _user_displays.get(user_id)
366
- if not sess:
367
- return {}
368
-
369
- display = sess["display"]
370
- print(f"[reset] 🔄 Resetting computer for user '{user_id}' on {display}")
371
-
372
- # قتل المتصفح — SIGKILL مباشر (بدل terminate اللطيف) لأن الأخير قد
373
- # يترك فايرفوكس في حالة نصف-مغلقة تُسجَّل كتحطّم (crash) في المرة
374
- # القادمة، وهذا بالضبط ما يُنتج شاشة "Restore Session" لاحقاً.
375
- browser_proc = sess.get("browser_proc")
376
- if browser_proc:
377
- try:
378
- browser_proc.kill()
379
- browser_proc.wait(timeout=3)
380
- except Exception:
381
- pass
382
- sess["browser_proc"] = None
383
-
384
- # قتل Xvfb
385
- _kill_proc(sess.get("xvfb_proc"))
386
- sess["xvfb_proc"] = None
387
-
388
- # تنظيف شامل لهذا الـ display
389
- _kill_display_processes(display)
390
-
391
- # ── تنظيف شامل لبيانات جلسة Firefox الفاسدة (السبب الجذري لشاشة
392
- # "Restore Session") — ليس فقط lock files بل sessionstore كاملاً،
393
- # في كل من البروفايل الافتراضي وأي بروفايل مخصص لهذا المستخدم ──
394
- try:
395
- subprocess.run(
396
- "rm -f ~/.mozilla/firefox/*/lock ~/.mozilla/firefox/*/.parentlock "
397
- "~/.mozilla/firefox/*/sessionstore.jsonlz4 "
398
- "~/.mozilla/firefox/*/sessionstore-backups/*.jsonlz4 "
399
- "~/.mozilla/firefox/*/sessionCheckpoints.json 2>/dev/null",
400
- shell=True, timeout=5, capture_output=True
401
- )
402
- except Exception:
403
- pass
404
- safe_id = re.sub(r"[^a-zA-Z0-9_.-]", "_", user_id) or "anon"
405
- custom_profile = os.path.expanduser(f"~/.zai_ff_profiles/{safe_id}")
406
- _wipe_firefox_session_data(custom_profile)
407
-
408
- # تشغيل Xvfb جديد (خارج الـ lock لأن start_xvfb يستغرق وقتاً)
409
- new_proc = await asyncio.to_thread(_start_xvfb, display)
410
- with _display_lock:
411
- if user_id in _user_displays:
412
- _user_displays[user_id]["xvfb_proc"] = new_proc
413
- _user_displays[user_id]["last_bg_shot_ts"] = 0.0
414
- _user_displays[user_id]["last_bg_hash"] = ""
415
- _user_displays[user_id]["consecutive_capture_failures"] = 0
416
-
417
- print(f"[reset] ✅ Computer reset done for '{user_id}' on {display}")
418
- return _user_displays.get(user_id, {})
419
-
420
-
421
- async def ensure_xvfb_for_session(sess: dict):
422
- """يتأكد أن Xvfb يعمل لهذا الـ session — يُشغّله إذا لم يكن كذلك."""
423
- display = sess["display"]
424
-
425
- # تحقق إذا كان يعمل بالفعل
426
- try:
427
- r = subprocess.run(
428
- ["xdpyinfo", "-display", display],
429
- capture_output=True, timeout=3
430
- )
431
- if r.returncode == 0:
432
- return # يعمل
433
- except Exception:
434
- pass
435
-
436
- # شغّله
437
- proc = await asyncio.to_thread(_start_xvfb, display)
438
- with _display_lock:
439
- if sess["user_id"] in _user_displays:
440
- _user_displays[sess["user_id"]]["xvfb_proc"] = proc
441
-
442
-
443
- async def destroy_user_ws(user_id: str, ws: WebSocket):
444
- """
445
- يُزيل الـ WebSocket من الـ session عند انقطاع الاتصال.
446
- لا يحذف الـ session نفسها — المستخدم يحتفظ بكمبيوتره.
447
- """
448
- with _display_lock:
449
- sess = _user_displays.get(user_id)
450
- if sess and sess.get("active_ws") is ws:
451
- sess["active_ws"] = None
452
- print(f"[session] 📴 User '{user_id}' disconnected (session kept)")
453
-
454
-
455
- # ════════════════════════════════════════════════════════════════
456
- # ── Screenshot Engine (per-display) ──────────────────────────
457
- # ════════════════════════════════════════════════════════════════
458
-
459
- def _is_black_screen(img) -> bool:
460
- try:
461
- small = img.resize((100, 100))
462
- pixels = list(small.getdata())
463
- avg = sum(sum(p[:3]) for p in pixels) / (len(pixels) * 3 * 255)
464
- return avg < 0.04
465
- except Exception:
466
- return False
467
-
468
-
469
- def _load_capture_image(path: str):
470
- from PIL import Image
471
- if not path or not os.path.exists(path):
472
- return None, 0, 0
473
- if os.path.getsize(path) < 1024:
474
- return None, 0, 0
475
- try:
476
- img = Image.open(path).convert("RGB")
477
- w, h = img.size
478
- if w < 100 or h < 100:
479
- return None, 0, 0
480
- return img, w, h
481
- except Exception as ex:
482
- print(f"[cap] load error for {path}: {ex}")
483
- return None, 0, 0
484
-
485
-
486
- def _capture_via_import_pipe(display: str):
487
- """
488
- الطريقة الأساسية والوحيدة: `import -window root` مع الإخراج مباشرة
489
- عبر stdout (pipe) — بدون أي ملف مؤقت على القرص. أسرع بشكل ملحوظ من
490
- الكتابة لملف ثم إعادة قراءته، خصوصاً على تخزين شبكي بطيء.
491
- """
492
- from PIL import Image
493
- env = {**os.environ, "DISPLAY": display}
494
- try:
495
- r = subprocess.run(
496
- ["import", "-window", "root", "-silent", "png:-"],
497
- env=env, capture_output=True
498
- )
499
- if r.returncode != 0 or not r.stdout or len(r.stdout) < 500:
500
- return None, 0, 0
501
- img = Image.open(io.BytesIO(r.stdout)).convert("RGB")
502
- w, h = img.size
503
- if w < 100 or h < 100:
504
- return None, 0, 0
505
- return img, w, h
506
- except FileNotFoundError:
507
- print(f"[cap:{display}] import (ImageMagick) not installed")
508
- return None, 0, 0
509
- except Exception as e:
510
- print(f"[cap:{display}] import-pipe: {e}")
511
- return None, 0, 0
512
-
513
-
514
- def _capture_via_xlib_direct(display: str):
515
- """
516
- خط الدفاع الثاني: قراءة X11 framebuffer مباشرة عبر python-xlib،
517
- بدون subprocess إطلاقاً (أسرع من أي أداة خارجية، ويعمل حتى لو
518
- ImageMagick غير مثبّت على السيرفر).
519
- """
520
- from PIL import Image
521
- try:
522
- from Xlib import display as Xdisp, X
523
- xd = Xdisp.Display(display)
524
- root = xd.screen().root
525
- geom = root.get_geometry()
526
- w, h = geom.width, geom.height
527
- raw = root.get_image(0, 0, w, h, X.ZPixmap, 0xFFFFFFFF)
528
- img = Image.frombuffer("RGB", (w, h), raw.data, "raw", "BGRX", 0, 1)
529
- xd.close()
530
- return img, w, h
531
- except Exception as e:
532
- print(f"[cap:{display}] xlib-direct: {e}")
533
- return None, 0, 0
534
-
535
-
536
- def _capture_via_xwd(display: str):
537
- """
538
- خط الدفاع الثالث — طريقة جديدة تماماً، مستقلة كلياً عن الطريقتين
539
- السابقتين في مسارها البرمجي: `xwd` (X Window Dump) هي أداة أصلية من
540
- حزمة x11-apps الأساسية نفسها (وليست ImageMagick مثل `import`، ولا
541
- مكتبة python-xlib عبر اتصال socket مباشر مثل الطريقة الثانية) —
542
- فإذا كان سبب فشل الطريقتين الأخريين متعلقاً تحديداً بـ ImageMagick
543
- (مفقود/معطوب) أو بمكتبة python-xlib نفسها (تعارض إصدار، اتصال عالق)،
544
- فإن xwd تسلك مساراً مختلفاً بالكامل في النواة قد ينجح حين تفشل الطريقتان
545
- الأخريان. تكتب xwd تنسيق XWD الخام إلى stdout مباشرة (بدون ملف مؤقت على
546
- القرص، بنفس فلسفة import-pipe)، ثم نحوّله عبر PIL.
547
- """
548
- from PIL import Image
549
- env = {**os.environ, "DISPLAY": display}
550
- try:
551
- r = subprocess.run(
552
- ["xwd", "-root", "-display", display, "-silent"],
553
- env=env, capture_output=True
554
- )
555
- if r.returncode != 0 or not r.stdout or len(r.stdout) < 500:
556
- return None, 0, 0
557
- # PIL يدعم قراءة تنسيق XWD مباشرة (plugin XVThumb/XWD مدمج)
558
- img = Image.open(io.BytesIO(r.stdout))
559
- img = img.convert("RGB")
560
- w, h = img.size
561
- if w < 100 or h < 100:
562
- return None, 0, 0
563
- return img, w, h
564
- except FileNotFoundError:
565
- print(f"[cap:{display}] xwd not installed")
566
- return None, 0, 0
567
- except Exception as e:
568
- print(f"[cap:{display}] xwd: {e}")
569
- return None, 0, 0
570
-
571
-
572
- def _capture_raw(display: str) -> tuple:
573
- """
574
- يلتقط الشاشة بأسرع طريقة موثوقة: import-pipe أولاً (سريعة جداً)،
575
- ثم xlib-direct، وأخيراً xwd كخط دفاع ثالث جديد كلياً بمسار برمجي
576
- مختلف تماماً عن الطريقتين الأوليين (أداة X11 أصلية أخرى غير
577
- ImageMagick وغير python-xlib) — هذا يضمن أن فشل مصدر واحد (مثل
578
- ImageMagick معطوب أو تعارض في مكتبة xlib) لا يعني فشل الالتقاط
579
- بالكامل، لأن xwd تسلك مساراً مستقلاً تماماً وقد تنجح حين تفشل الأخريان.
580
- لا يوجد أي حد زمني (timeout) على أي محاولة — كل محاولة تُترك تكتمل
581
- فعلياً مهما طال الوقت؛ الحماية من التعليق الفعلي (X server ميت) تأتي
582
- من طبقة أعلى (شمّاعة الفشل المتتالي + reset تلقائي) وليس من قطع
583
- العملية في المنتصف.
584
-
585
- ── القيمة الرابعة المُعادة (is_placeholder) ──
586
- True فقط عندما فشلت الطرق الثلاث فعلياً ورجعنا لصورة "Screenshot
587
- failed" المرسومة يدوياً. الطبقة الأعلى (capture_with_grid) تستخدم
588
- هذا العلم لتفعيل عدّاد الفشل المتتالي وتحفيز reset تلقائي كامل —
589
- بدل الاعتماد على فحص محتوى النص داخل الصورة.
590
- """
591
- for method_name, method in (
592
- ("import-pipe", _capture_via_import_pipe),
593
- ("xlib-direct", _capture_via_xlib_direct),
594
- ("xwd", _capture_via_xwd),
595
- ):
596
- for attempt in range(2):
597
- try:
598
- img, w, h = method(display)
599
- if img and not _is_black_screen(img):
600
- if attempt > 0:
601
- print(f"[cap:{display}] ✅ {method_name} succeeded on retry")
602
- return img, w, h, False
603
- except Exception as e:
604
- print(f"[cap:{display}] {method_name}: {e}")
605
- if attempt == 0:
606
- time.sleep(0.3)
607
-
608
- print(f"[cap:{display}] ⚠️ All methods failed (import-pipe, xlib-direct, xwd) — placeholder")
609
- from PIL import Image as PILImg, ImageDraw
610
- sw, sh = _get_screen_size(display)
611
- img = PILImg.new("RGB", (sw or 1280, sh or 800), (15, 20, 40))
612
- draw = ImageDraw.Draw(img)
613
- draw.rectangle([(0, 0), (sw, 55)], fill=(30, 40, 80))
614
- draw.text((10, 10), f"⚠️ Screenshot failed — DISPLAY={display}", fill=(255, 120, 80))
615
- draw.text((10, 32), "Methods tried: import-pipe, xlib-direct, xwd", fill=(130, 130, 150))
616
- return img, sw or 1280, sh or 800, True
617
-
618
-
619
- def _get_screen_size(display: str) -> tuple:
620
- try:
621
- r = subprocess.run(
622
- ["xdotool", "getdisplaygeometry"],
623
- env={**os.environ, "DISPLAY": display},
624
- capture_output=True, text=True
625
- )
626
- parts = r.stdout.strip().split()
627
- return int(parts[0]), int(parts[1])
628
- except Exception:
629
- return 1280, 800
630
-
631
-
632
- def _get_mouse_pos(display: str) -> tuple:
633
- try:
634
- r = subprocess.run(
635
- ["xdotool", "getmouselocation"],
636
- env={**os.environ, "DISPLAY": display},
637
- capture_output=True, text=True
638
- )
639
- mx = int(re.search(r"x:(\d+)", r.stdout).group(1))
640
- my = int(re.search(r"y:(\d+)", r.stdout).group(1))
641
- return mx, my
642
- except Exception:
643
- return 0, 0
644
-
645
-
646
- def _draw_mouse_marker(draw, msx, msy, color=(255, 50, 50, 240)):
647
- """يرسم علامة الماوس (دائرة + خطوط تقاطع) في نقطة محددة على صورة."""
648
- r = 11
649
- line_color = (color[0], color[1], color[2], 200)
650
- draw.ellipse([(msx-r, msy-r), (msx+r, msy+r)], outline=color, width=2)
651
- draw.line([(msx-18, msy), (msx+18, msy)], fill=line_color, width=1)
652
- draw.line([(msx, msy-18), (msx, msy+18)], fill=line_color, width=1)
653
-
654
-
655
- def _render_grid_variant(base_img, ow, oh, sw, sh, mx, my, display,
656
- grid_step: int, line_color: tuple, label_every: int,
657
- major_only_labels: bool = True):
658
- """
659
- يرسم نسخة شبكة إحداثيات واحدة فوق نسخة من الصورة الأساسية.
660
- grid_step → المسافة بالبكسل الحقيقي بين كل خط شبكة.
661
- line_color → لون الخطوط والأرقام (RGBA).
662
- label_every → كل كم بكسل تُكتب فيه تسمية إحداثي (x,y) كاملة عند التقاطعات.
663
- major_only_labels → إذا True، الأرقام على الحواف تظهر فقط عند خطوط "رئيسية".
664
- """
665
- from PIL import ImageDraw
666
- img = base_img.copy()
667
- draw = ImageDraw.Draw(img, "RGBA")
668
-
669
- step_x = max(1, int(grid_step * sw / ow))
670
- step_y = max(1, int(grid_step * sh / oh))
671
- minor_color = (line_color[0], line_color[1], line_color[2], 35)
672
- major_color = (line_color[0], line_color[1], line_color[2], 110)
673
- text_color = line_color
674
-
675
- x_sc, x_r = step_x, grid_step
676
- x_majors = []
677
- while x_sc < sw:
678
- is_major = (x_r % label_every == 0)
679
- draw.line([(x_sc, 0), (x_sc, sh)], fill=(major_color if is_major else minor_color), width=1)
680
- if is_major or not major_only_labels:
681
- draw.rectangle([(x_sc+1, 2), (x_sc+34, 15)], fill=(0, 0, 0, 175))
682
- draw.text((x_sc+2, 3), str(x_r), fill=text_color)
683
- if is_major:
684
- x_majors.append((x_sc, x_r))
685
- x_sc += step_x; x_r += grid_step
686
-
687
- y_sc, y_r = step_y, grid_step
688
- y_majors = []
689
- while y_sc < sh:
690
- is_major = (y_r % label_every == 0)
691
- draw.line([(0, y_sc), (sw, y_sc)], fill=(major_color if is_major else minor_color), width=1)
692
- if is_major or not major_only_labels:
693
- draw.rectangle([(2, y_sc+1), (38, y_sc+14)], fill=(0, 0, 0, 175))
694
- draw.text((3, y_sc+2), str(y_r), fill=text_color)
695
- if is_major:
696
- y_majors.append((y_sc, y_r))
697
- y_sc += step_y; y_r += grid_step
698
-
699
- for (xs, xr) in x_majors:
700
- for (ys, yr) in y_majors:
701
- label = f"{xr},{yr}"
702
- tw = 6 * len(label) + 4
703
- draw.rectangle([(xs+2, ys+2), (xs+2+tw, ys+13)], fill=(0, 0, 0, 150))
704
- draw.text((xs+4, ys+2), label, fill=text_color)
705
-
706
- msx = int(mx * sw / ow)
707
- msy = int(my * sh / oh)
708
- _draw_mouse_marker(draw, msx, msy, color=(line_color[0], line_color[1], line_color[2], 240))
709
-
710
- final = img.convert("RGB")
711
- draw2 = ImageDraw.Draw(final)
712
- draw2.rectangle([(0, 0), (sw, 20)], fill=(0, 0, 0))
713
- draw2.text((4, 3), f"SCREEN {ow}x{oh} | MOUSE:({mx},{my}) | GRID={grid_step}px | DSP:{display}", fill=(0, 220, 160))
714
- draw2.rectangle([(0, sh-20), (sw, sh)], fill=(0, 0, 0))
715
- draw2.text((4, sh-17), "CLICKCOORDS = numbers at every intersection (real screen pixels)", fill=(180, 180, 70))
716
- return final
717
-
718
-
719
- def capture_with_grid(display: str, scale: float = 0.85, quality: int = 72,
720
- force_mx: int | None = None,
721
- force_my: int | None = None,
722
- grid_step: int = 50) -> dict:
723
- """
724
- يلتقط الشاشة من display محدد ويُنتج 3 نسخ من نفس اللقطة بالضبط (نفس اللحظة):
725
- - "data" → النسخة العادية (نظيفة تماماً) + علامة الماوس فقط، بدون أي Grid.
726
- - "data_grid" → شبكة إحداثيات عادية (كل 50px)، أخضر/سماوي — للذكاء الاصطناعي.
727
- - "data_grid2" → شبكة إحداثيات أدق (كل 20px)، أحمر — للنقرات الدقيقة.
728
- —— تبسيط v13: نسختان فقط (بدل 3) لتقليل عدد عمليات resize/encode لكل
729
- لقطة بنسبة قرابة س4إلى س2، ودقة المقاس الواحد 1280x800 الجديدة لم
730
- تعد تحتاج لثلاث تدرجات دقة منفصلة لتحديد الإحداثيات.
731
- """
732
- from PIL import Image, ImageDraw
733
-
734
- img, ow, oh, is_placeholder = _capture_raw(display)
735
- if img is None:
736
- return {"data": "", "data_grid": "", "data_grid2": "", "data_grid3": "",
737
- "width": 1280, "height": 800, "mouse_x": 0, "mouse_y": 0,
738
- "is_placeholder": True}
739
-
740
- mx, my = (force_mx, force_my) if force_mx is not None else _get_mouse_pos(display)
741
-
742
- sw = int(ow * scale)
743
- sh = int(oh * scale)
744
- base_img = img.resize((sw, sh), Image.LANCZOS)
745
- msx = int(mx * sw / ow)
746
- msy = int(my * sh / oh)
747
-
748
- clean_img = base_img.copy()
749
- draw_clean = ImageDraw.Draw(clean_img, "RGBA")
750
- _draw_mouse_marker(draw_clean, msx, msy)
751
- clean_final = clean_img.convert("RGB")
752
- buf_clean = io.BytesIO()
753
- clean_final.save(buf_clean, format="JPEG", quality=quality, optimize=True)
754
- data_clean = base64.b64encode(buf_clean.getvalue()).decode()
755
-
756
- grid1 = _render_grid_variant(base_img, ow, oh, sw, sh, mx, my, display,
757
- grid_step=grid_step, line_color=(0, 255, 180, 235),
758
- label_every=100)
759
- buf1 = io.BytesIO(); grid1.save(buf1, format="JPEG", quality=quality, optimize=True)
760
- data_grid1 = base64.b64encode(buf1.getvalue()).decode()
761
-
762
- grid2 = _render_grid_variant(base_img, ow, oh, sw, sh, mx, my, display,
763
- grid_step=20, line_color=(255, 60, 60, 235),
764
- label_every=60, major_only_labels=True)
765
- buf2 = io.BytesIO(); grid2.save(buf2, format="JPEG", quality=max(quality, 78), optimize=True)
766
- data_grid2 = base64.b64encode(buf2.getvalue()).decode()
767
-
768
- return {
769
- "data": data_clean,
770
- "data_grid": data_grid1,
771
- "data_grid2": data_grid2,
772
- "data_grid3": data_grid2, # توافقية رجعية: نفس قيمة data_grid2 لأي كود قديم يقرأ data_grid3
773
- "width": ow, "height": oh, "mouse_x": mx, "mouse_y": my,
774
- "is_placeholder": is_placeholder, # True إذا فشلت الطرق الثلاث وهذه صورة "Screenshot failed"
775
- }
776
-
777
-
778
- def _frame_hash(data: str) -> str:
779
- """
780
- إصلاح: كان يُحسب على أول 2000 حرف فقط من بيانات الصورة، مما يعني أي تغيير
781
- بصري يقع خارج الجزء الممثَّل ضمن تلك الأحرف الأولى (مثل تحديد نص شريط العنوان
782
- بعد Ctrl+L، أو أي تغيير طفيف/في منطقة لا تتوافق مع بداية ترميز base64) لا
783
- يُكتشف أبداً، فتُحجب الصورة الجديدة عبر delta suppression رغم اختلافها فعلياً
784
- عن السابقة، ولا تصل أي لقطة محدَّثة للعميل. الحل: حساب MD5 على كامل البيانات
785
- بدل تقطيعها — هذا سريع جداً (أقل من مللي ثانية حتى لصور كبيرة) فلا تكلفة أداء
786
- حقيقية، ويضمن اكتشاف أي تغيير بصري حقيقي في أي مكان من الصورة.
787
- """
788
- return hashlib.md5(data.encode()).hexdigest()
789
-
790
-
791
- # ════════════════════════════════════════════════════════════════
792
- # ── SafeSearch ───────────────────────────────────────────────
793
- # ════════════════════════════════════════════════════════════════
794
-
795
- _BING_RE = re.compile(r"https?://(?:www\.)?bing\.com[^\s'\"]*")
796
- _DDG_RE = re.compile(r"https?://(?:www\.)?duckduckgo\.com[^\s'\"]*")
797
- _GOOG_RE = re.compile(r"https?://(?:www\.)?google\.[a-z.]+[^\s'\"]*")
798
-
799
- def _safe_search(text: str) -> str:
800
- def _bing(m):
801
- u = m.group(0)
802
- return re.sub(r"adlt=\w+", "adlt=strict", u) if "adlt=" in u else u + ("&" if "?" in u else "?") + "adlt=strict"
803
- def _ddg(m):
804
- u = m.group(0)
805
- return re.sub(r"kp=\d", "kp=1", u) if "kp=" in u else u + ("&" if "?" in u else "?") + "kp=1"
806
- def _goog(m):
807
- u = m.group(0)
808
- return re.sub(r"safe=\w+", "safe=strict", u) if "safe=" in u else u + ("&" if "?" in u else "?") + "safe=strict"
809
- return _GOOG_RE.sub(_goog, _DDG_RE.sub(_ddg, _BING_RE.sub(_bing, text)))
810
-
811
-
812
- # ════════════════════════════════════════════════════════════════
813
- # ── Terminal Execution (per-display) ─────────────────────────
814
- # ════════════════════════════════════════════════════════════════
815
-
816
- _PKILL_RE = re.compile(r"\b(pkill|killall)\s+(-9\s+)?-f\s+(['\"]?)([a-zA-Z0-9_./-]+)\3")
817
-
818
- def _sanitize_pkill(cmd: str) -> str:
819
- def _fix(m):
820
- tool, d9, _q, p = m.group(1), m.group(2) or "", m.group(3), m.group(4)
821
- sp = f"[{p[0]}]{p[1:]}" if len(p) > 1 else p
822
- return f"{tool} {d9}-f '{sp}'"
823
- return _PKILL_RE.sub(_fix, cmd)
824
-
825
-
826
- async def run_cmd(cmd: str, display: str, timeout: int = 60) -> dict:
827
- """ينفّذ أمر bash مع DISPLAY خاص بالمستخدم."""
828
- cmd = _sanitize_pkill(_safe_search(cmd))
829
- async with _terminal_sem:
830
- env = {**os.environ, "DISPLAY": display,
831
- "PYTHONIOENCODING": "utf-8", "LANG": "en_US.UTF-8"}
832
-
833
- def _exec():
834
- try:
835
- r = subprocess.run(
836
- cmd, shell=True, capture_output=True,
837
- text=True, timeout=timeout, env=env, executable="/bin/bash"
838
- )
839
- return {"stdout": r.stdout[-15000:], "stderr": r.stderr[-3000:], "returncode": r.returncode}
840
- except subprocess.TimeoutExpired:
841
- return {"stdout": "", "stderr": f"⏱️ Timeout {timeout}s", "returncode": -1}
842
- except Exception as e:
843
- return {"stdout": "", "stderr": str(e), "returncode": -1}
844
-
845
- return await asyncio.to_thread(_exec)
846
-
847
-
848
- # ════════════════════════════════════════════════════════════════
849
- # ── xdotool helpers (per-display) ────────────────────────────
850
- # ════════════════════════════════════════════════════════════════
851
-
852
- async def xdo(args: list, display: str, timeout: int = 10) -> dict:
853
- env = {**os.environ, "DISPLAY": display}
854
- def _run():
855
- r = subprocess.run(
856
- ["xdotool"] + args, env=env,
857
- timeout=timeout, capture_output=True, text=True
858
- )
859
- return {"rc": r.returncode, "out": r.stdout, "err": r.stderr}
860
- return await asyncio.to_thread(_run)
861
-
862
-
863
- async def type_smart(text: str, display: str) -> dict:
864
- """كتابة نص ذكية بـ display محدد."""
865
- has_arabic = bool(re.search(r'[\u0600-\u06FF]', text))
866
- env = {**os.environ, "DISPLAY": display}
867
-
868
- if has_arabic:
869
- def _paste():
870
- p = subprocess.Popen(
871
- ["xclip", "-selection", "clipboard"],
872
- stdin=subprocess.PIPE, env=env
873
- )
874
- p.communicate(text.encode("utf-8"))
875
- await asyncio.to_thread(_paste)
876
- await asyncio.sleep(0.15)
877
- await xdo(["key", "--clearmodifiers", "ctrl+v"], display)
878
- return {"method": "clipboard+paste"}
879
- else:
880
- r = await xdo(["type", "--clearmodifiers", "--delay", "25", text], display)
881
- return {"method": "xdotool", "rc": r["rc"]}
882
-
883
-
884
- # ════════════════════════════════════════════════════════════════
885
- # ── Search Sources ────────────────────────────────────────────
886
- # ════════════════════════════════════════════════════════════════
887
-
888
- def _search_sources(query: str) -> list:
889
- q = urllib.parse.quote_plus(query)
890
- return [
891
- {"name": "DuckDuckGo Instant",
892
- "cmd": f"curl -s --max-time 15 'https://api.duckduckgo.com/?q={q}&format=json&no_html=1&skip_disambig=1' | python3 -c \"import sys,json;d=json.load(sys.stdin);a=d.get('AbstractText','');r=d.get('RelatedTopics',[]);print('ANS:',a or 'none');[print('-',x.get('Text','')[:200]) for x in r[:6] if isinstance(x,dict)]\""},
893
- {"name": "Google News RSS",
894
- "cmd": f"curl -sL --max-time 15 'https://news.google.com/rss/search?q={q}&hl=ar&gl=AR&ceid=AR:ar' | python3 -c \"import sys,re;x=sys.stdin.read();t=re.findall(r'<title><!\\[CDATA\\[(.*?)\\]\\]></title>|<title>(.*?)</title>',x);[(print(str(i+1)+'. '+(a or b).strip()[:160])) for i,(a,b) in enumerate(t[1:8]) if (a or b).strip()]\""},
895
- {"name": "Wikipedia EN",
896
- "cmd": f"curl -s --max-time 12 'https://en.wikipedia.org/api/rest_v1/page/summary/{q}' | python3 -c \"import sys,json;d=json.load(sys.stdin);print(d.get('title','')+'\\n'+d.get('extract','')[:1200])\""},
897
- {"name": "DuckDuckGo HTML",
898
- "cmd": f"curl -sL --max-time 15 -H 'User-Agent: Mozilla/5.0' 'https://html.duckduckgo.com/html/?q={q}' | python3 -c \"import sys,re;h=sys.stdin.read();s=re.findall(r'class=.result__snippet[^>]*>(.*?)</a>',h,re.DOTALL);clean=lambda x:re.sub('<[^>]+>','',x).strip();[print(str(i+1)+'. '+clean(x)[:200]) for i,x in enumerate(s[:7])]\""},
899
- {"name": "HackerNews",
900
- "cmd": f"curl -s --max-time 12 'https://hn.algolia.com/api/v1/search?query={q}&hitsPerPage=6&tags=story' | python3 -c \"import sys,json;d=json.load(sys.stdin);[print(str(i+1)+'. '+h.get('title','')+' Pts:'+str(h.get('points',0))) for i,h in enumerate(d.get('hits',[])[:5])]\""},
901
- {"name": "Reddit",
902
- "cmd": f"curl -sL --max-time 12 -H 'Accept: application/json' 'https://www.reddit.com/search.json?q={q}&sort=new&limit=6' | python3 -c \"import sys,json;d=json.load(sys.stdin);[print(str(i+1)+'. '+p['data'].get('title','')[:160]) for i,p in enumerate(d.get('data',{{}}).get('children',[])[:5])]\""},
903
- ]
904
-
905
-
906
- def _result_empty(s: str) -> bool:
907
- if not s or len(s.strip()) < 15:
908
- return True
909
- return "ans: none" in s.lower()
910
-
911
-
912
- async def run_cmd_smart(cmd: str, display: str, timeout: int = 60) -> dict:
913
- res = await run_cmd(cmd, display, timeout)
914
- if res["returncode"] == 0 and not _result_empty(res["stdout"]):
915
- return res
916
-
917
- is_search = "curl" in cmd and any(
918
- x in cmd for x in ["search", "duckduck", "bing", "google", "wikipedia", "reddit"]
919
- )
920
- if not is_search:
921
- return res
922
-
923
- m = re.search(r"[?&]q=([^&'\"\s]+)", cmd)
924
- query = urllib.parse.unquote(m.group(1).replace("+", " ")).strip() if m else ""
925
- if len(query) < 3:
926
- return res
927
-
928
- sources = _search_sources(query)
929
- results = []
930
- for src in sources:
931
- r2 = await run_cmd(src["cmd"], display, 20)
932
- if not _result_empty(r2["stdout"]):
933
- results.append(f"[{src['name']}]\n{r2['stdout']}")
934
- if len(results) >= 2:
935
- break
936
-
937
- if results:
938
- return {"stdout": "\n\n".join(results)[:15000], "stderr": "", "returncode": 0}
939
- return res
940
-
941
-
942
- # ════════════════════════════════════════════════════════════════
943
- # ── FastAPI App ───────────────────────────────────────────────
944
- # ════════════════════════════════════════════════════════════════
945
-
946
- app = FastAPI(title="Z-Computer-Mode v11 — Per-User Isolation")
947
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
948
- allow_methods=["*"], allow_headers=["*"])
949
-
950
-
951
- @app.get("/", response_class=HTMLResponse)
952
- async def root():
953
- n = len(_user_displays)
954
- users_info = ""
955
- with _display_lock:
956
- for uid, sess in _user_displays.items():
957
- ws_status = "🟢 متصل" if sess.get("active_ws") else "⚫ غير متصل"
958
- users_info += f"<tr><td>{uid[:30]}...</td><td>{sess['display']}</td><td>{ws_status}</td></tr>"
959
-
960
- return f"""<!DOCTYPE html><html><head><meta charset="utf-8">
961
- <title>Z Computer Mode v11</title>
962
- <style>body{{background:#080808;color:#00d4aa;font-family:monospace;padding:40px}}
963
- h1{{color:#4a90d9}}.ok{{color:#00ff88}}.info{{color:#888;font-size:13px}}
964
- table{{border-collapse:collapse;width:100%}}td,th{{border:1px solid #333;padding:6px 10px;font-size:13px}}
965
- th{{background:#1a1a2e;color:#4a90d9}}</style>
966
- </head><body>
967
- <h1>🖥️ THE Z AI — Computer Mode Server v11</h1>
968
- <p class="ok">✅ Server RUNNING — Per-User Isolated Displays</p>
969
- <p>🌐 Browser: <b>{BROWSER}</b></p>
970
- <p>👥 Active users: <b>{n}</b></p>
971
- <p>📋 Display range: <b>:{DISPLAY_BASE} → :{DISPLAY_MAX}</b></p>
972
- <hr style="border-color:#333">
973
- <h3>👤 User Sessions</h3>
974
- <table><tr><th>User ID</th><th>Display</th><th>Status</th></tr>
975
- {users_info or '<tr><td colspan="3" style="text-align:center;color:#666">No active sessions</td></tr>'}
976
- </table>
977
- <hr style="border-color:#333">
978
- <p class="info">Endpoints: /health · /ws?user_id=email (WebSocket)</p>
979
- </body></html>"""
980
-
981
-
982
- # ════════════════════════════════════════════════════════════════
983
- # ── Action Handler (per-session) ─────────────────────────────
984
- # ════════════════════════════════════════════════════════════════
985
-
986
- async def handle_action(ws: WebSocket, msg: dict, sess: dict):
987
- action = msg.get("action", "")
988
- data = msg.get("data", {})
989
- display = sess["display"]
990
-
991
- async def send(obj):
992
- try:
993
- await ws.send_text(json.dumps(obj, ensure_ascii=False))
994
- except Exception:
995
- pass
996
-
997
- # ── إصلاح جذري: قفل خاص بكل session لمنع تراكم/تضارب عمليات capture المتزامنة ──
998
- # المشكلة الأصلية: action == "screenshot" كان يُنفَّذ بـ await مباشر ضمن حلقة
999
- # while True الرئيسية في websocket_endpoint، فإذا تأخرت capture_with_grid (قد تصل
1000
- # لدقيقة ونصف في أسوأ سيناريو فشل/تأخر scrot+import+ffmpeg)، تبقى الحلقة كلها
1001
- # محجوبة ولا تستقبل أي رسالة عميل جديدة (نقرة، طلب screenshot آخر، إلخ) حتى تنتهي.
1002
- # الحل: كل طلب screenshot يُشغَّل فوراً عبر create_task (لا يحجب الحلقة أبداً).
1003
- #
1004
- # ── إصلاح ثانٍ (مهم جداً): طلب screenshot الصريح من العميل له أولوية مطلقة ──
1005
- # المشكلة المكتشفة لاحقاً: لو استُخدم قفل واحد مشترك بين shot_bg (تلقائية، بعد
1006
- # كل فعل كنقرة/فتح تبويب) و shot_explicit (صريحة، يطلبها العميل مباشرة بعد كل
1007
- # خطوة لتحديث الصورة المعروضة)، فإن shot_explicit قد تنتظر خلف shot_bg طويلاً
1008
- # إذا كانت الأخيرة قد بدأت فعلاً وعلقت داخل capture_with_grid (محاولات فاشلة
1009
- # متتالية). هذا يجعل العميل يرى "لم تصل لقطة شاشة" بشكل متكرر حتى بعد نجاح
1010
- # الخطوة الفعلية (مثل open_tab)، لأن طلبه العاجل كان يصطف خلف عملية تلقائية بطيئة.
1011
- # الحل: shot_explicit (الصريحة فقط) تحاول الحصول على القفل لفترة قصيرة جداً
1012
- # (1 ثانية)، وإن لم تنجح (لأن shot_bg تستخدمه)، تُنفَّذ التصوير مباشرة بدون قفل
1013
- # بدل الانتظار — لأن استجابة العميل الفورية أهم من تفادي تزاحم CPU عرضي بسيط،
1014
- # وقراءة الشاشة (X11) عملية قراءة فقط لا تُسبب أي تلف بيانات عند التزاحم.
1015
- if "_shot_lock" not in sess:
1016
- sess["_shot_lock"] = asyncio.Lock()
1017
- _shot_lock = sess["_shot_lock"]
1018
-
1019
- async def _safe_capture(scale, quality, force_mx=None, force_my=None):
1020
- """
1021
- تُستخدم من shot_bg (التلقائية) — تنتظر القفل ثم تنتظر التقاط الشاشة
1022
- حتى ينتهي فعلياً، بدون أي سقف زمني يقطعها. هذا يضمن أن العملية لن
1023
- تُقطع أبداً في منتصفها وتُرجع فراغاً؛ ستكمل حتى تنجح (أو تفشل كل
1024
- المحاولات الداخلية في capture_with_grid وتُرجع placeholder صريح).
1025
- """
1026
- async with _shot_lock:
1027
- return await asyncio.to_thread(
1028
- capture_with_grid, display, scale, quality, force_mx, force_my
1029
- )
1030
-
1031
- async def _priority_capture(scale, quality, force_mx=None, force_my=None):
1032
- """
1033
- تُستخدم من shot_explicit (الطلب الصريح من العميل) فقط — أولوية قصوى.
1034
- تحاول الحصول على القفل لمدة قصيرة (1 ثانية) فقط؛ إن لم تنجح (القفل
1035
- محجوز من shot_bg تلقائية بطيئة)، تُنفَّذ التصوير فوراً بدون قفل بدل
1036
- الانتظار خلف عملية أخرى — العميل يجب أن يحصل على رد سريع دائماً.
1037
- بعد الحصول على القفل (أو تجاوزه)، لا يوجد أي سقف زمني على عملية
1038
- الالتقاط نفسها — تنتظر حتى تكتمل فعلياً بدل أن تُقطع في المنتصف.
1039
- """
1040
- try:
1041
- await asyncio.wait_for(_shot_lock.acquire(), timeout=1.0)
1042
- try:
1043
- return await asyncio.to_thread(
1044
- capture_with_grid, display, scale, quality, force_mx, force_my
1045
- )
1046
- finally:
1047
- _shot_lock.release()
1048
- except asyncio.TimeoutError:
1049
- # لم نحصل على القفل بسرعة كافية — ننفّذ التصوير مباشرة بدون قفل
1050
- # بدل الانتظار خلف عملية أخرى، لكن بدون أي سقف زمني على الالتقاط
1051
- return await asyncio.to_thread(
1052
- capture_with_grid, display, scale, quality, force_mx, force_my
1053
- )
1054
-
1055
- async def shot_bg(label: str = "", delay: float = 0.5,
1056
- force_mx: int | None = None, force_my: int | None = None,
1057
- extra_shot_delay: float | None = None):
1058
- """
1059
- auto_shot في الخلفية — مع rate limit + delta suppression.
1060
- إذا تم تمرير extra_shot_delay، تُرسل لقطة ثانية إضافية بعد ذلك التأخير
1061
- الإضافي (محسوباً من وقت انتهاء اللقطة الأولى) — هذا يغطي حالة النقر على
1062
- رابط ينقل لصفحة جديدة قد لا تكتمل تحميلها خلال التأخير الأولي القصير
1063
- (مثلاً صفحة بطيئة على سيرفر محدود الموارد)، دون الحاجة لمعرفة مسبقة بنوع
1064
- العنصر المنقور عليه. تتجاهل delta suppression لهذه اللقطة الثانية تحديداً
1065
- لأن المحتوى متوقع أن يكون مختلفاً (صفحة تحمّلت أكثر) حتى لو تشابه الـ hash
1066
- جزئياً مع خلفية مشابهة.
1067
- """
1068
- try:
1069
- await asyncio.sleep(delay)
1070
- now = time.time()
1071
- if now - sess.get("last_bg_shot_ts", 0) < 0.3:
1072
- await asyncio.sleep(0.3 - (now - sess["last_bg_shot_ts"]))
1073
-
1074
- result = await _safe_capture(0.65, 72, force_mx, force_my)
1075
- sess["last_bg_shot_ts"] = time.time()
1076
- # ── مساهمة في عدّاد الفشل المشترك مع shot_explicit ──
1077
- # shot_bg تبقى صامتة عند الفشل (لا ترسل شيئاً — هذا سلوكها الأصلي
1078
- # المقصود، فهي مجرد محاولة إضافية سريعة وليست خط الدفاع الموثوق)،
1079
- # لكنها الآن تُحدّث نفس عدّاد الفشل المتتالي الذي يستخدمه
1080
- # shot_explicit. هذا يعني: لو الـ display معطوب فعلاً، العدّاد
1081
- # يبدأ بالارتفاع من أول لقطة تلقائية فاشلة، فتصل shot_explicit
1082
- # (التي دائماً تُطلَب صراحة بعدها من العميل) للعتبة وتُفعّل الـ
1083
- # reset التلقائي أسرع، بدل الانتظار حتى تتراكم فشلتان داخل
1084
- # shot_explicit نفسها فقط.
1085
- if result.get("data") and not result.get("is_placeholder"):
1086
- sess["consecutive_capture_failures"] = 0
1087
- fh = _frame_hash(result["data"])
1088
- if fh != sess.get("last_bg_hash", ""):
1089
- sess["last_bg_hash"] = fh
1090
- await send({
1091
- "type": "screenshot",
1092
- "data": result["data"],
1093
- "data_grid": result.get("data_grid", ""),
1094
- "data_grid2": result.get("data_grid2", ""),
1095
- "data_grid3": result.get("data_grid3", ""),
1096
- "ts": int(time.time() * 1000),
1097
- "auto": True, "label": label,
1098
- "screen_width": result["width"],
1099
- "screen_height": result["height"],
1100
- "mouse_x": result["mouse_x"],
1101
- "mouse_y": result["mouse_y"],
1102
- "has_grid": True,
1103
- })
1104
- else:
1105
- sess["consecutive_capture_failures"] = sess.get("consecutive_capture_failures", 0) + 1
1106
-
1107
- if extra_shot_delay:
1108
- await asyncio.sleep(extra_shot_delay)
1109
- result2 = await _safe_capture(0.65, 72, force_mx, force_my)
1110
- sess["last_bg_shot_ts"] = time.time()
1111
- if result2["data"]:
1112
- fh2 = _frame_hash(result2["data"])
1113
- sess["last_bg_hash"] = fh2
1114
- await send({
1115
- "type": "screenshot",
1116
- "data": result2["data"],
1117
- "data_grid": result2.get("data_grid", ""),
1118
- "data_grid2": result2.get("data_grid2", ""),
1119
- "data_grid3": result2.get("data_grid3", ""),
1120
- "ts": int(time.time() * 1000),
1121
- "auto": True, "label": f"{label} (delayed)",
1122
- "screen_width": result2["width"],
1123
- "screen_height": result2["height"],
1124
- "mouse_x": result2["mouse_x"],
1125
- "mouse_y": result2["mouse_y"],
1126
- "has_grid": True,
1127
- })
1128
- except Exception as e:
1129
- # لقطة خلفية فاشلة لا يجب أن توقف الجلسة أو تظهر بصمت في اللوق فقط.
1130
- print(f"[shot_bg:{display}] ⚠️ {e}")
1131
-
1132
- # ── عتبة الفشل المتتالي قبل تفعيل reset تلقائي كامل للـ display ──
1133
- # فشلة واحدة عابرة (ضغط CPU لحظي، تزاحم مع shot_bg) لا تستدعي reset —
1134
- # لكن فشلتان متتاليتان فعليتان (كلتاهما رجعتا is_placeholder=True) تعني
1135
- # أن هناك مشكلة حقيقية في الـ display نفسه (Xvfb ميت/عالق)، وليس عطلاً
1136
- # عابراً، فنعيد ضبط الكمبيوتر تلقائياً بدل الاستسلام وإرسال "error".
1137
- _AUTO_RESET_FAILURE_THRESHOLD = 2
1138
- # حد أقصى لعدد جولات (محاولة التقاط → عند الفشل المتكرر → reset → إعادة
1139
- # محاولة) داخل نفس طلب screenshot الواحد، لضمان أن الحلقة تنتهي دائماً
1140
- # ولو استمر الفشل حتى بعد عدة عمليات reset (بيئة معطوبة جذرياً)، بدل أن
1141
- # تدور للأبد بصمت. هذا ليس "timeout" زمنياً — هو سقف على عدد المحاولات.
1142
- _MAX_CAPTURE_ROUNDS = 6
1143
-
1144
- async def shot_explicit(label: str = ""):
1145
- """screenshot صريح — يُرسل دائماً بدون delta suppression، وله أولوية مطلقة
1146
- على أي عملية shot_bg تلقائية جارية (لا ينتظر خلفها طويلاً).
1147
-
1148
- ── إصلاح جذري v14: لا "error" نهائي بعد الآن — بل reset تلقائي كامل ──
1149
- سابقاً: فشل التقاط واحد (نتيجة فارغة) كان يُرسل "error" فوراً للعميل،
1150
- فيبقى العميل يعيد طلب screenshot بلا نهاية بينما السيرفر لا يفعل شيئاً
1151
- مختلفاً في كل مرة (نفس الـ display المعطوب، بلا أي محاولة إصلاح فعلية).
1152
-
1153
- الآن: فشل واحد → إعادة محاولة فورية (قد يكون عابراً). فشلان متتاليان
1154
- فعليان (كلاهما is_placeholder=True) → reset تلقائي كامل لهذا المستخدم
1155
- (نفس ما يفعله المستخدم يدوياً بزر "إعادة الضبط") ثم إعادة المحاولة
1156
- تلقائياً على الـ display الجديد النظيف — كل هذا داخل نفس الطلب، بلا
1157
- أي تدخل من العميل. رسالة "error" الصريحة لم تعد تُرسَل أبداً من هذا
1158
- المسار؛ العميل يستقبل إما screenshot ناجحاً حقيقياً، أو (في أسوأ حالة
1159
- نادرة جداً: بيئة معطوبة جذرياً حتى بعد عدة محاولات reset) رسالة
1160
- "computer_reset" تخبره أن الجلسة بدأت من جديد ليطلب screenshot تالياً
1161
- بنفسه — أبداً حالة صمت لا نهائية.
1162
- """
1163
- for _round in range(_MAX_CAPTURE_ROUNDS):
1164
- try:
1165
- result = await _priority_capture(0.65, 75)
1166
- got_real_frame = bool(result and result.get("data") and not result.get("is_placeholder"))
1167
-
1168
- if got_real_frame:
1169
- sess["consecutive_capture_failures"] = 0
1170
- sess["last_bg_hash"] = _frame_hash(result["data"])
1171
- await send({
1172
- "type": "screenshot",
1173
- "data": result["data"],
1174
- "data_grid": result.get("data_grid", ""),
1175
- "data_grid2": result.get("data_grid2", ""),
1176
- "data_grid3": result.get("data_grid3", ""),
1177
- "ts": int(time.time() * 1000),
1178
- "auto": False, "label": label,
1179
- "screen_width": result["width"],
1180
- "screen_height": result["height"],
1181
- "mouse_x": result["mouse_x"],
1182
- "mouse_y": result["mouse_y"],
1183
- "has_grid": True,
1184
- })
1185
- return
1186
-
1187
- # ── فشل هذه الجولة (نتيجة فارغة تماماً، أو placeholder) ──
1188
- sess["consecutive_capture_failures"] = sess.get("consecutive_capture_failures", 0) + 1
1189
- fail_count = sess["consecutive_capture_failures"]
1190
- print(f"[shot_explicit:{display}] ⚠️ capture failed (round {_round+1}/{_MAX_CAPTURE_ROUNDS}, "
1191
- f"consecutive={fail_count})")
1192
-
1193
- if fail_count >= _AUTO_RESET_FAILURE_THRESHOLD:
1194
- # ── مشكلة حقيقية وليست عابرة — reset تلقائي كامل ──
1195
- print(f"[shot_explicit:{display}] 🔄 auto-reset triggered after {fail_count} consecutive failures")
1196
- await send({
1197
- "type": "computer_reset",
1198
- "msg": "🔄 إعادة ضبط تلقائية بعد فشل التقاط متكرر — الشاشة تبدأ من جديد نظيفة",
1199
- "auto": True,
1200
- })
1201
- await reset_user_computer(sess["user_id"])
1202
- # بعد الـ reset الكامل، أمهل Xvfb الجديد لحظة قصيرة ليستقر
1203
- # قبل أول محاولة التقاط عليه (نفس المهلة المستخدمة في مسار
1204
- # reset_computer اليدوي أعلاه لضمان اتساق السلوك).
1205
- await asyncio.sleep(2.0)
1206
- # الجولة التالية من هذه الحلقة نفسها ستعيد المحاولة على
1207
- # الـ display الجديد النظيف تلقائياً — بلا حاجة لأي طلب
1208
- # إضافي من العميل ولا لإرسال "error" له في الأثناء.
1209
- continue
1210
-
1211
- # فشل عابر فقط (أول فشلة) — أعد المحاولة فوراً بلا reset
1212
- await asyncio.sleep(0.4)
1213
- continue
1214
-
1215
- except Exception as e:
1216
- # استثناء غير متوقع تماماً (وليس مجرد نتيجة فارغة) — يُعامَل
1217
- # بنفس منطق الفشل أعلاه بدل الاستسلام الفوري، لأن أسباباً
1218
- # كهذه (اتصال X عالق، انهيار عملية) هي بالضبط ما يُصلحه reset.
1219
- sess["consecutive_capture_failures"] = sess.get("consecutive_capture_failures", 0) + 1
1220
- fail_count = sess["consecutive_capture_failures"]
1221
- print(f"[shot_explicit:{display}] ❌ EXCEPTION (round {_round+1}/{_MAX_CAPTURE_ROUNDS}, "
1222
- f"consecutive={fail_count}): {e}")
1223
- if fail_count >= _AUTO_RESET_FAILURE_THRESHOLD:
1224
- try:
1225
- await send({
1226
- "type": "computer_reset",
1227
- "msg": "🔄 إعادة ضبط تلقائية بعد خطأ متكرر في الالتقاط — الشاشة تبدأ من جديد نظيفة",
1228
- "auto": True,
1229
- })
1230
- except Exception:
1231
- pass
1232
- try:
1233
- await reset_user_computer(sess["user_id"])
1234
- await asyncio.sleep(2.0)
1235
- except Exception as reset_ex:
1236
- print(f"[shot_explicit:{display}] ❌ reset itself failed: {reset_ex}")
1237
- continue
1238
-
1239
- # ── وصلنا لحد _MAX_CAPTURE_ROUNDS جولة دون أي نجاح حقيقي، حتى بعد
1240
- # محاولات reset متعددة — بيئة معطوبة جذرياً (نادر جداً). بدل الصمت
1241
- # التام، نُعلم العميل صراحة أن الجلسة أُعيد ضبطها ليطلب screenshot
1242
- # تالياً بنفسه بدل انتظار frame لن يصل أبداً في هذا الطلب تحديداً.
1243
- print(f"[shot_explicit:{display}] ❌ giving up after {_MAX_CAPTURE_ROUNDS} rounds — "
1244
- f"environment appears broken even after reset attempts")
1245
- try:
1246
- await send({
1247
- "type": "computer_reset",
1248
- "msg": "⚠️ استمرت مشكلة الالتقاط رغم إعادة الضبط — الرجاء طلب لقطة شاشة جديدة",
1249
- "auto": True,
1250
- })
1251
- except Exception:
1252
- pass
1253
-
1254
- # ── reset_computer: إعادة ضبط الكمبيوتر كأنه جديد ─────────
1255
- if action == "reset_computer":
1256
- user_id = sess["user_id"]
1257
- await send({"type": "ack", "action": "reset_computer", "status": "resetting"})
1258
- await reset_user_computer(user_id)
1259
- # أرسل لقطة شاشة بعد الريستارت
1260
- await asyncio.sleep(2.0)
1261
- await shot_explicit("after reset")
1262
- await send({"type": "computer_reset", "msg": "✅ تم إعادة ضبط الكمبيوتر — الشاشة جديدة تماماً"})
1263
- return
1264
-
1265
- # ── screenshot ────────────────────────────────────────────
1266
- # إصلاح: create_task بدل await مباشر — لا يحجب حلقة استقبال الرسائل أبداً،
1267
- # حتى لو تأخرت capture_with_grid لأي سبب (CPU ضعيف، X11 بطيء، إلخ).
1268
- # هذا يضمن أن أي إجراء لاحق (نقرة، طباعة، طلب screenshot آخر) يصل ويُعالَج
1269
- # فوراً دون انتظار اكتمال هذه اللقطة أولاً.
1270
- if action == "screenshot":
1271
- # ── طبقة حماية إضافية: حتى لو حصل خطأ غير متوقع تماماً داخل
1272
- # shot_explicit تفلت من الـ try/except الداخلي (مثل CancelledError
1273
- # أو خطأ عند إنشاء الـ task نفسه)، هذا الغلاف الخارجي يضمن تسجيله
1274
- # في اللوق بدل أن يختفي بصمت كـ "unhandled task exception" في event
1275
- # loop بايثون (وهذا كان له سلوك افتراضي بصامت في بعض إعدادات uvicorn).
1276
- _task = asyncio.create_task(shot_explicit("explicit screenshot"))
1277
- def _on_shot_done(t: asyncio.Task):
1278
- exc = t.exception() if not t.cancelled() else None
1279
- if exc:
1280
- print(f"[screenshot-task:{display}] ❌ unhandled exception: {exc}")
1281
- _task.add_done_callback(_on_shot_done)
1282
-
1283
- # ── terminal ─────────────────────────────────────────────
1284
- elif action == "terminal":
1285
- cmd = data.get("cmd", "")
1286
- if not cmd:
1287
- await send({"type": "terminal_result", "stdout": "", "stderr": "no cmd", "returncode": -1})
1288
- return
1289
- res = await run_cmd_smart(cmd, display, int(data.get("timeout", 60)))
1290
- await send({
1291
- "type": "terminal_result",
1292
- "cmd": cmd,
1293
- "stdout": res["stdout"],
1294
- "stderr": res.get("stderr", ""),
1295
- "returncode": res["returncode"],
1296
- })
1297
- # ── تعطيل الالتقاط التلقائي: لا تُؤخذ لقطة شاشة إلا بطلب صريح ──
1298
-
1299
- # ── mouse_move ───────────────────────────────────────────
1300
- elif action == "mouse_move":
1301
- x, y = int(data.get("x", 0)), int(data.get("y", 0))
1302
- await xdo(["mousemove", "--sync", str(x), str(y)], display)
1303
- await send({"type": "ack", "action": "mouse_move", "x": x, "y": y})
1304
- # ── تعطيل الالتقاط التلقائي ──
1305
-
1306
- # ── mouse_click ──────────────────────────────────────────
1307
- elif action == "mouse_click":
1308
- x, y = int(data.get("x", 0)), int(data.get("y", 0))
1309
- btn = {"left": "1", "middle": "2", "right": "3"}.get(data.get("button", "left"), "1")
1310
- double = data.get("double", False)
1311
- await xdo(["mousemove", "--sync", str(x), str(y)], display)
1312
- await asyncio.sleep(0.07)
1313
- if double:
1314
- await xdo(["click", "--repeat", "2", "--delay", "100", btn], display)
1315
- else:
1316
- await xdo(["click", btn], display)
1317
- btn_name = {"1": "left", "2": "middle", "3": "right"}.get(btn, "left")
1318
- await send({"type": "ack", "action": "mouse_click", "x": x, "y": y, "button": btn_name})
1319
- # ── تعطيل الالتقاط التلقائي ──
1320
-
1321
- # ── mouse_drag ───────────────────────────────────────────
1322
- elif action == "mouse_drag":
1323
- x1, y1 = int(data.get("x1", 0)), int(data.get("y1", 0))
1324
- x2, y2 = int(data.get("x2", 0)), int(data.get("y2", 0))
1325
- await xdo(["mousemove", str(x1), str(y1)], display)
1326
- await xdo(["mousedown", "1"], display)
1327
- await asyncio.sleep(0.1)
1328
- await xdo(["mousemove", str(x2), str(y2)], display)
1329
- await asyncio.sleep(0.1)
1330
- await xdo(["mouseup", "1"], display)
1331
- await send({"type": "ack", "action": "mouse_drag"})
1332
- # ── تعطيل الالتقاط التلقائي ──
1333
-
1334
- # ── keyboard_type ────────────────────────────────────────
1335
- elif action == "keyboard_type":
1336
- text = _safe_search(data.get("text", ""))
1337
- if text:
1338
- res = await type_smart(text, display)
1339
- await send({"type": "ack", "action": "keyboard_type", "method": res["method"]})
1340
- # ── تعطيل الالتقاط التلقائي ──
1341
-
1342
- # ── keyboard_hotkey ──────────────────────────────────────
1343
- elif action == "keyboard_hotkey":
1344
- keys = data.get("keys", [])
1345
- if keys:
1346
- await xdo(["key", "--clearmodifiers", "+".join(keys)], display)
1347
- await send({"type": "ack", "action": "keyboard_hotkey", "keys": keys})
1348
- # ── تعطيل الالتقاط التلقائي ──
1349
-
1350
- # ── keyboard_press ───────────────────────────────────────
1351
- elif action == "keyboard_press":
1352
- key = data.get("key", "")
1353
- if key:
1354
- await xdo(["key", "--clearmodifiers", key], display)
1355
- await send({"type": "ack", "action": "keyboard_press"})
1356
- # ── تعطيل الالتقاط التلقائي ──
1357
-
1358
- # ── scroll ───────────────────────────────────────────────
1359
- elif action == "scroll":
1360
- x, y = int(data.get("x", 960)), int(data.get("y", 540))
1361
- clicks = max(-5, min(5, int(data.get("clicks", 3))))
1362
- btn = "4" if clicks > 0 else "5"
1363
- await xdo(["mousemove", str(x), str(y)], display)
1364
- for _ in range(abs(clicks)):
1365
- await xdo(["click", btn], display)
1366
- await asyncio.sleep(0.025)
1367
- await send({"type": "ack", "action": "scroll", "clicks": clicks})
1368
- # ── تعطيل الالتقاط التلقائي ──
1369
-
1370
- # ── clipboard_write ──────────────────────────────────────
1371
- elif action == "clipboard_write":
1372
- text = data.get("text", "")
1373
- env = {**os.environ, "DISPLAY": display}
1374
- def _clip():
1375
- p = subprocess.Popen(["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE, env=env)
1376
- p.communicate(text.encode("utf-8"))
1377
- await asyncio.to_thread(_clip)
1378
- await send({"type": "ack", "action": "clipboard_write", "length": len(text)})
1379
-
1380
- # ── clipboard_read ───────────────────────────────────────
1381
- elif action == "clipboard_read":
1382
- res = await run_cmd("xclip -selection clipboard -o", display, 5)
1383
- await send({"type": "clipboard_content", "text": res["stdout"]})
1384
-
1385
- # ── paste ────────────────────────────────────────────────
1386
- elif action == "paste":
1387
- text = data.get("text", "")
1388
- if text:
1389
- env = {**os.environ, "DISPLAY": display}
1390
- def _clip2():
1391
- p = subprocess.Popen(["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE, env=env)
1392
- p.communicate(text.encode("utf-8"))
1393
- await asyncio.to_thread(_clip2)
1394
- await asyncio.sleep(0.1)
1395
- await xdo(["key", "--clearmodifiers", "ctrl+v"], display)
1396
- await send({"type": "ack", "action": "paste"})
1397
- # ── تعطيل الالتقاط التلقائي ──
1398
-
1399
- # ── open_app ─────────────────────────────────────────────
1400
- elif action == "open_app":
1401
- cmd = _safe_search(data.get("cmd", ""))
1402
- if not cmd:
1403
- await send({"type": "ack", "action": "open_app"})
1404
- return
1405
- if "firefox" in cmd.lower():
1406
- profile_dir = _ensure_firefox_profile(sess["user_id"], display)
1407
- # قتل فوري وقوي (SIGKILL) بدل pkill اللطيف: يمنع فايرفوكس من
1408
- # "التقاط" حالة نصف-مفتوحة تُسجَّل لاحقاً كتحطّم (crash) يُنتج
1409
- # شاشة "Restore Session" في المرة القادمة.
1410
- await run_cmd(
1411
- "pkill -9 -f '[f]irefox' 2>/dev/null; sleep 0.6; echo CLEANED",
1412
- display, timeout=8
1413
- )
1414
- _wipe_firefox_session_data(profile_dir)
1415
- # حقن --profile داخل أمر فايرفوكس (إن لم يكن محقوناً مسبقاً)
1416
- if "--profile" not in cmd and "-P " not in cmd:
1417
- flags_str = " ".join(FIREFOX_CLI_FLAGS)
1418
- cmd = cmd.replace("firefox", f"firefox --profile '{profile_dir}' {flags_str}", 1)
1419
- env = {**os.environ, "DISPLAY": display, **(FIREFOX_ENV_EXTRA if "firefox" in cmd.lower() else {})}
1420
- proc = subprocess.Popen(cmd, shell=True, env=env,
1421
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
1422
- if any(b in cmd for b in ("firefox", "chromium", "chrome")):
1423
- with _display_lock:
1424
- if sess["user_id"] in _user_displays:
1425
- _user_displays[sess["user_id"]]["browser_proc"] = proc
1426
- await send({"type": "ack", "action": "open_app", "cmd": cmd})
1427
- # ── تعطيل الالتقاط التلقائي بعد فتح التطبيق ──
1428
-
1429
- # ── open_browser ─────────────────────────────────────────
1430
- elif action == "open_browser":
1431
- url = _safe_search(data.get("url", "") or "about:blank")
1432
- profile_dir = _ensure_firefox_profile(sess["user_id"], display)
1433
- # قتل فوري وقوي (SIGKILL) بدل pkill اللطيف — يمنع تسجيل حالة
1434
- # "تحطّم" في sessionstore، وهي السبب الجذري لشاشة "Restore Session"
1435
- # التي تحجب المحتوى الفعلي وتُلتقط كلقطة شاشة صالحة (غير سوداء)
1436
- # لكنها ليست الصفحة المطلوبة.
1437
- await run_cmd(
1438
- "pkill -9 -f '[f]irefox' 2>/dev/null; sleep 0.6; echo CLEANED",
1439
- display, timeout=8
1440
- )
1441
- _wipe_firefox_session_data(profile_dir)
1442
- env = {**os.environ, "DISPLAY": display, **FIREFOX_ENV_EXTRA}
1443
- proc = subprocess.Popen(
1444
- [BROWSER, "--profile", profile_dir, *FIREFOX_CLI_FLAGS, url], env=env,
1445
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
1446
- )
1447
- with _display_lock:
1448
- if sess["user_id"] in _user_displays:
1449
- _user_displays[sess["user_id"]]["browser_proc"] = proc
1450
- await send({"type": "ack", "action": "open_browser", "url": url})
1451
- # ── تعطيل الالتقاط التلقائي بعد فتح المتصفح ──
1452
-
1453
- # ── open_tab ─────────────────────────────────────────────
1454
- elif action == "open_tab":
1455
- # إصلاح: زيادة التأخيرات بين كل خطوة فرعية هنا (Ctrl+T → Ctrl+L → كتابة الرابط)
1456
- # المشكلة الأصلية: التأخير القصير (0.4s) بعد Ctrl+T لم يكن كافياً دائماً، خصوصاً
1457
- # على سيرفر محدود الموارد قد يستغرق فايرفوكس فيه وقتاً أطول لإنشاء التبويب الجديد
1458
- # فعلياً وتحويل التركيز (focus) إليه. إذا وصل Ctrl+L والكتابة قبل اكتمال ذلك،
1459
- # فإنها قد تذهب للتبويب القديم بالخطأ، فلا تنتقل الصفحة فعلياً رغم نجاح الأمر
1460
- # ظاهرياً (بدون أي رسالة خطأ)، فيبقى المستخدم يرى نفس الصفحة القديمة للأبد.
1461
- url = _safe_search(data.get("url", "") or "about:blank")
1462
- await xdo(["key", "--clearmodifiers", "ctrl+t"], display)
1463
- await asyncio.sleep(0.8)
1464
- await xdo(["key", "--clearmodifiers", "ctrl+l"], display)
1465
- await asyncio.sleep(0.3)
1466
- await type_smart(url, display)
1467
- await asyncio.sleep(0.3)
1468
- await xdo(["key", "--clearmodifiers", "Return"], display)
1469
- await send({"type": "ack", "action": "open_tab", "url": url})
1470
- # ── تعطيل الالتقاط التلقائي ──
1471
-
1472
- # ── close_tab ────────────────────────────────────────────
1473
- elif action == "close_tab":
1474
- await xdo(["key", "--clearmodifiers", "ctrl+w"], display)
1475
- await send({"type": "ack", "action": "close_tab"})
1476
- # ── تعطيل الالتقاط التلقائي ──
1477
-
1478
- # ── browser_back ─────────────────────────────────────────
1479
- elif action == "browser_back":
1480
- await xdo(["key", "--clearmodifiers", "alt+Left"], display)
1481
- await send({"type": "ack", "action": "browser_back"})
1482
- # ── تعطيل الالتقاط التلقائي ──
1483
-
1484
- # ── browser_forward ──────────────────────────────────────
1485
- elif action == "browser_forward":
1486
- await xdo(["key", "--clearmodifiers", "alt+Right"], display)
1487
- await send({"type": "ack", "action": "browser_forward"})
1488
- # ── تعطيل الالتقاط التلقائي ──
1489
-
1490
- # ── browser_search ───────────────────────────────────────
1491
- elif action == "browser_search":
1492
- url = _safe_search(data.get("url", "") or data.get("query", ""))
1493
- await xdo(["key", "--clearmodifiers", "ctrl+l"], display)
1494
- await asyncio.sleep(0.2)
1495
- await type_smart(url, display)
1496
- await asyncio.sleep(0.15)
1497
- await xdo(["key", "--clearmodifiers", "Return"], display)
1498
- await send({"type": "ack", "action": "browser_search"})
1499
- # ── تعطيل الالتقاط التلقائي ──
1500
-
1501
- # ── screen_info ──────────────────────────────────────────
1502
- elif action == "screen_info":
1503
- w, h = await asyncio.to_thread(_get_screen_size, display)
1504
- mx, my = await asyncio.to_thread(_get_mouse_pos, display)
1505
- await send({
1506
- "type": "screen_info",
1507
- "width": w, "height": h,
1508
- "mouse_x": mx, "mouse_y": my,
1509
- "browser": BROWSER,
1510
- "display": display,
1511
- })
1512
-
1513
- # ── unknown ──────────────────────────────────────────────
1514
- else:
1515
- await send({"type": "error", "msg": f"Unknown action: '{action}'"})
1516
-
1517
-
1518
- # ════════════════════════════════════════════════════════════════
1519
- # ── WebSocket Endpoint ────────────────────────────────────────
1520
- # ════════════════════════════════════════════════════════════════
1521
-
1522
- @app.websocket("/ws")
1523
- async def websocket_endpoint(
1524
- ws: WebSocket,
1525
- user_id: str = Query(default="anonymous")
1526
- ):
1527
- await ws.accept()
1528
-
1529
- # الحصول على session المستخدم أو إنشاء واحدة جديدة
1530
- sess = await get_or_create_user_session(user_id, ws)
1531
-
1532
- # تأكد أن Xvfb يعمل لهذا المستخدم
1533
- await ensure_xvfb_for_session(sess)
1534
-
1535
- display = sess["display"]
1536
-
1537
- async def _heartbeat():
1538
- while True:
1539
- await asyncio.sleep(20)
1540
- try:
1541
- await ws.send_text(json.dumps({"type": "ping", "ts": int(time.time()*1000)}))
1542
- except Exception:
1543
- break
1544
-
1545
- hb_task = asyncio.create_task(_heartbeat())
1546
-
1547
- try:
1548
- w, h = await asyncio.to_thread(_get_screen_size, display)
1549
- await ws.send_text(json.dumps({
1550
- "type": "connected",
1551
- "screen_width": w, "screen_height": h,
1552
- "browser": BROWSER,
1553
- "display": display,
1554
- "user_id": user_id,
1555
- "session_id": id(ws),
1556
- "msg": f"Z Computer Mode v11 | User: {user_id} | Display: {display} | Browser: {BROWSER} | Screen: {w}x{h}",
1557
- }, ensure_ascii=False))
1558
-
1559
- # لقطة شاشة أولية
1560
- result = await asyncio.to_thread(capture_with_grid, display, 0.65, 72)
1561
- if result["data"]:
1562
- await ws.send_text(json.dumps({
1563
- "type": "screenshot",
1564
- "data": result["data"],
1565
- "data_grid": result.get("data_grid", ""),
1566
- "data_grid2": result.get("data_grid2", ""),
1567
- "data_grid3": result.get("data_grid3", ""),
1568
- "ts": int(time.time() * 1000),
1569
- "label": "Initial screen",
1570
- "screen_width": result["width"],
1571
- "screen_height": result["height"],
1572
- "mouse_x": result["mouse_x"],
1573
- "mouse_y": result["mouse_y"],
1574
- "has_grid": True,
1575
- }, ensure_ascii=False))
1576
- sess["last_bg_hash"] = _frame_hash(result["data"])
1577
-
1578
- except Exception as e:
1579
- print(f"[ws:{user_id}] init error: {e}")
1580
-
1581
- try:
1582
- while True:
1583
- raw = await ws.receive_text()
1584
- try:
1585
- msg = json.loads(raw)
1586
- if msg.get("type") == "pong":
1587
- continue
1588
- await handle_action(ws, msg, sess)
1589
- except json.JSONDecodeError:
1590
- pass
1591
- except WebSocketDisconnect:
1592
- pass
1593
- except Exception as e:
1594
- print(f"[ws:{user_id}] error: {e}")
1595
- finally:
1596
- hb_task.cancel()
1597
- await destroy_user_ws(user_id, ws)
1598
-
1599
-
1600
- # ════════════════════════════════════════════════════════════════
1601
- # ── REST Endpoints ────────────────────────────────────────────
1602
- # ════════════════════════════════════════════════════════════════
1603
-
1604
- @app.get("/screenshot")
1605
- async def rest_screenshot(user_id: str = "anonymous"):
1606
- with _display_lock:
1607
- sess = _user_displays.get(user_id)
1608
- display = sess["display"] if sess else f":{DISPLAY_BASE}"
1609
- result = await asyncio.to_thread(capture_with_grid, display, 0.7, 75)
1610
- return JSONResponse({
1611
- "image": result["data"],
1612
- "image_grid": result.get("data_grid", ""),
1613
- "image_grid2": result.get("data_grid2", ""),
1614
- "image_grid3": result.get("data_grid3", ""),
1615
- "ts": int(time.time() * 1000),
1616
- "screen_width": result["width"],
1617
- "screen_height": result["height"],
1618
- "mouse_x": result["mouse_x"],
1619
- "mouse_y": result["mouse_y"],
1620
- "has_grid": True,
1621
- "display": display,
1622
- "user_id": user_id,
1623
- })
1624
-
1625
- @app.post("/terminal")
1626
- async def rest_terminal(body: dict):
1627
- user_id = body.get("user_id", "anonymous")
1628
- with _display_lock:
1629
- sess = _user_displays.get(user_id)
1630
- display = sess["display"] if sess else f":{DISPLAY_BASE}"
1631
- return JSONResponse(await run_cmd_smart(body.get("cmd", ""), display, body.get("timeout", 60)))
1632
-
1633
- @app.get("/health")
1634
- async def health():
1635
- with _display_lock:
1636
- n = len(_user_displays)
1637
- users = [
1638
- {"user_id": uid, "display": s["display"],
1639
- "connected": bool(s.get("active_ws"))}
1640
- for uid, s in _user_displays.items()
1641
- ]
1642
- return {
1643
- "status": "ok",
1644
- "version": "v13-fast-capture-fixed-firefox",
1645
- "browser": BROWSER,
1646
- "active_users": n,
1647
- "users": users,
1648
- }
1649
-
1650
-
1651
- # ════════════════════════════════════════════════════════════════
1652
- # ── Background Tasks ──────────────────────────────────────────
1653
- # ════════════════════════════════════════════════════════════════
1654
-
1655
- async def _cleanup_tmp():
1656
- """ينظّف /tmp كل 5 دقائق. ملاحظة v13: محرك الالتقاط
1657
- الجديد (import-pipe/xlib-direct/xwd) لا يكتب أي ملفات zss_* على الإطلاق (كلشيء
1658
- في الذاكرة عبر pipe)، فهذه الدالة أصبحت غير ضرورية فعلياً للقطات
1659
- الشاشة، لكنها تُرك كشبكة أمان إضافية (مثلاً لو أضيف كود مستقبلاً
1660
- يكتب ملفات مؤقتة بنفس البادئة)."""
1661
- while True:
1662
- await asyncio.sleep(300)
1663
- try:
1664
- subprocess.run(
1665
- ["find", "/tmp", "-name", "zss_*", "-mmin", "+10", "-delete"],
1666
- capture_output=True, timeout=10
1667
- )
1668
- except Exception as e:
1669
- print(f"[cleanup] {e}")
1670
-
1671
-
1672
- async def _cleanup_idle_sessions():
1673
- """
1674
- يُزيل sessions المستخدمين غير النشطين (لا اتصال منذ أكثر من ساعة)
1675
- لتحرير الـ displays والذاكرة.
1676
- """
1677
- while True:
1678
- await asyncio.sleep(1800) # كل 30 دقيقة
1679
- now = time.time()
1680
- to_remove = []
1681
- with _display_lock:
1682
- for uid, sess in list(_user_displays.items()):
1683
- if sess.get("active_ws"):
1684
- continue # لا تحذف sessions النشطة
1685
- if now - sess.get("created", now) > 3600: # أكثر من ساعة
1686
- to_remove.append((uid, sess))
1687
-
1688
- for uid, sess in to_remove:
1689
- print(f"[cleanup] 🗑️ Removing idle session for '{uid}' on {sess['display']}")
1690
- _kill_proc(sess.get("browser_proc"))
1691
- _kill_proc(sess.get("xvfb_proc"))
1692
- _kill_display_processes(sess["display"])
1693
- with _display_lock:
1694
- _user_displays.pop(uid, None)
1695
- try:
1696
- _display_numbers.discard(int(sess["display"].lstrip(":")))
1697
- except Exception:
1698
- pass
1699
-
1700
-
1701
- @app.on_event("startup")
1702
- async def startup():
1703
- asyncio.create_task(_cleanup_tmp())
1704
- asyncio.create_task(_cleanup_idle_sessions())
1705
- print("✅ Z Computer Mode v11 ready — Per-User Isolated Displays")
1706
- print(f" Display range: :{DISPLAY_BASE} → :{DISPLAY_MAX}")
1707
- print(f" Connect: wss://your-space.hf.space/ws?user_id=EMAIL")
1708
-
1709
-
1710
- if __name__ == "__main__":
1711
- port = int(os.environ.get("PORT", 7860))
1712
- uvicorn.run("app:app", host="0.0.0.0", port=port, log_level="info")