From 4cbd9d7450e605c7c7e4ead5a19b9c13083ebd68 Mon Sep 17 00:00:00 2001 From: Mahmudul Alam Date: Mon, 1 Jun 2026 05:22:35 +0600 Subject: [PATCH] Add Linux inspector and installer improvements Introduce a Linux inspector module to check/install desktop dependencies (system and Python packages) and register it in the installer routes. Add /apps endpoint to list AT-SPI visible apps. Switch blocking package checks/installs to asyncio.to_thread and run status checks outside the global install semaphore so they don't queue. Include node version in /status and add system_info dispatch support. --- Framework/install_handler/linux/atspi.py | 7 +- .../install_handler/linux/linux_inspector.py | 350 ++++++++++++++++++ Framework/install_handler/linux/xwd.py | 7 +- Framework/install_handler/route.py | 21 ++ server/installers.py | 59 ++- server/linux.py | 27 ++ server/status.py | 25 +- 7 files changed, 473 insertions(+), 23 deletions(-) create mode 100644 Framework/install_handler/linux/linux_inspector.py diff --git a/Framework/install_handler/linux/atspi.py b/Framework/install_handler/linux/atspi.py index 8229c499..6ad89bd7 100644 --- a/Framework/install_handler/linux/atspi.py +++ b/Framework/install_handler/linux/atspi.py @@ -1,3 +1,4 @@ +import asyncio import os from Framework.install_handler.install_log_config import get_logger from Framework.install_handler.utils import send_response @@ -78,7 +79,7 @@ async def check_status(): return False packages = PACKAGES.get(package_manager, []) - if check_all_packages_installed(package_manager, packages): + if await asyncio.to_thread(check_all_packages_installed, package_manager, packages): await send_response( { "action": "status", @@ -158,8 +159,8 @@ async def install(user_password: str = ""): } ) - success, error_msg = install_packages( - package_manager, packages, user_password, timeout=3600 + success, error_msg = await asyncio.to_thread( + install_packages, package_manager, packages, user_password, 3600 ) if success: diff --git a/Framework/install_handler/linux/linux_inspector.py b/Framework/install_handler/linux/linux_inspector.py new file mode 100644 index 00000000..75fc170d --- /dev/null +++ b/Framework/install_handler/linux/linux_inspector.py @@ -0,0 +1,350 @@ +import os +import re +import subprocess +import sys +import asyncio +from Framework.install_handler.install_log_config import get_logger +from Framework.install_handler.utils import send_response +from .linux_utils import ( + detect_package_manager, + check_all_packages_installed, + install_packages, +) + +logger = get_logger() + +CATEGORY = "Linux" +NAME = "Linux Inspector" + +PACKAGES = { + "apt": [ + "build-essential", + "cmake", + "pkg-config", + "libgirepository1.0-dev", + "libcairo2-dev", + "xdotool", + "x11-apps", + "imagemagick", + "wmctrl", + ], + "dnf": [ + "cmake", + "pkgconf-pkg-config", + "gobject-introspection-devel", + "cairo-devel", + "xdotool", + "ImageMagick", + "wmctrl", + "python3-devel", + "cairo-gobject-devel", + ], + "pacman": [ + "gcc", + "meson", + "cmake", + "pkgconf", + "cairo", + "xdotool", + "gobject-introspection", + "imagemagick", + "wmctrl", + ], +} + +# Final packages installed in the AlmaLinux/RHEL 9 sequence (used for status check) +ALMA_RHEL9_CHECK_PACKAGES = [ + "xdotool", + "ImageMagick", + "wmctrl", + "python3-devel", +] + +PIP_PACKAGES = [ + "python3-pyatspi==1.19.0", + "pygobject==3.50.1", + "python-xlib==0.33", +] + +ACCESSIBILITY_VARS = [ + "export NO_AT_BRIDGE=0", + "export GTK_MODULES=gail:atk-bridge", + "export ACCESSIBILITY_ENABLED=1", +] +ACCESSIBILITY_MARKER = "# zeuz-accessibility" + + +def _is_alma_rhel9() -> bool: + try: + with open("/etc/os-release") as f: + content = f.read() + return bool(re.search(r"AlmaLinux.*9|Red Hat.*9|Rocky.*9", content, re.IGNORECASE)) + except Exception: + return False + + +def _check_pip_packages_installed() -> bool: + for pkg in PIP_PACKAGES: + name = pkg.split("==")[0] + result = subprocess.run( + [sys.executable, "-m", "pip", "show", name], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return False + return True + + +def _install_pip_packages() -> tuple[bool, str]: + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "install"] + PIP_PACKAGES, + capture_output=True, + text=True, + timeout=300, + ) + if result.returncode == 0: + return True, "" + return False, result.stderr + except subprocess.TimeoutExpired: + return False, "pip install timed out" + except Exception as e: + return False, str(e) + + +def _add_accessibility_env_vars(): + for rc_file in [ + os.path.expanduser("~/.bashrc"), + os.path.expanduser("~/.zshrc"), + ]: + if not os.path.isfile(rc_file): + continue + try: + with open(rc_file) as f: + content = f.read() + if ACCESSIBILITY_MARKER in content: + continue + with open(rc_file, "a") as f: + f.write(f"\n{ACCESSIBILITY_MARKER}\n") + for line in ACCESSIBILITY_VARS: + f.write(f"{line}\n") + except Exception: + pass + + +def _enable_gnome_accessibility(): + try: + subprocess.run( + ["gsettings", "set", "org.gnome.desktop.interface", "toolkit-accessibility", "true"], + capture_output=True, + text=True, + timeout=10, + ) + except Exception: + pass + + +def _run_sudo_cmd(cmd: list[str], user_password: str, timeout: int = 3600) -> tuple[bool, str]: + """Run a sudo command, optionally passing password via stdin.""" + if user_password: + full_cmd = ["sudo", "-S"] + cmd + process = subprocess.Popen( + full_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + _, stderr = process.communicate(input=f"{user_password}\n", timeout=timeout) + return process.returncode == 0, stderr + else: + full_cmd = ["sudo"] + cmd + result = subprocess.run(full_cmd, capture_output=True, text=True, timeout=timeout) + return result.returncode == 0, result.stderr + + +def _install_alma_rhel9(user_password: str) -> tuple[bool, str]: + steps = [ + (["dnf", "groupinstall", "Development Tools", "-y"], 600), + (["dnf", "install", "-y", "cairo-devel", "pkg-config"], 300), + (["dnf", "config-manager", "--set-enabled", "crb"], 60), + (["dnf", "install", "-y", "gobject-introspection-devel", "at-spi2-core-devel", "cairo-gobject-devel"], 300), + (["dnf", "install", "-y", "epel-release"], 120), + (["dnf", "install", "-y", "xwd", "xdotool", "ImageMagick", "wmctrl", "python3-devel"], 300), + ] + for cmd, timeout in steps: + success, err = _run_sudo_cmd(cmd, user_password, timeout=timeout) + if not success: + return False, f"Step '{' '.join(cmd)}' failed: {err}" + return True, "" + + +async def check_status(): + """Check if Linux Inspector dependencies are installed.""" + logger.info("Checking Linux Inspector status...") + + session_type = os.environ.get("XDG_SESSION_TYPE", "unknown") + if session_type != "x11": + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "error", + "comment": f"Only X11 is supported. Current session type: {session_type}.", + }, + }) + return False + + package_manager, _ = detect_package_manager() + if not package_manager: + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "error", + "comment": "Unsupported package manager. Only apt, dnf, and pacman are supported.", + }, + }) + return False + + if package_manager == "dnf" and _is_alma_rhel9(): + sys_ok = await asyncio.to_thread(check_all_packages_installed, package_manager, ALMA_RHEL9_CHECK_PACKAGES) + else: + sys_ok = await asyncio.to_thread(check_all_packages_installed, package_manager, PACKAGES[package_manager]) + + pip_ok = _check_pip_packages_installed() + + if sys_ok and pip_ok: + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "installed", + "comment": "Linux Inspector dependencies are installed.", + }, + }) + return True + else: + missing = [] + if not sys_ok: + missing.append("system packages") + if not pip_ok: + missing.append("Python packages") + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "not installed", + "comment": f"Missing: {', '.join(missing)}. Use Install to set up.", + }, + }) + return False + + +async def install(user_password: str = ""): + """Install Linux Inspector dependencies.""" + logger.info("Installing Linux Inspector dependencies...") + + session_type = os.environ.get("XDG_SESSION_TYPE", "unknown") + if session_type != "x11": + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "error", + "comment": f"Only X11 is supported. Current session type: {session_type}.", + }, + }) + return False + + already_installed = await check_status() + if already_installed: + return True + + package_manager, _ = detect_package_manager() + if not package_manager: + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "error", + "comment": "Unsupported package manager. Only apt, dnf, and pacman are supported.", + }, + }) + return False + + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "installing", + "comment": "Installing system packages, please wait...", + }, + }) + + # Install system packages + if package_manager == "dnf" and _is_alma_rhel9(): + success, error_msg = await asyncio.to_thread(_install_alma_rhel9, user_password) + else: + success, error_msg = await asyncio.to_thread( + install_packages, package_manager, PACKAGES[package_manager], user_password + ) + + if not success: + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "error", + "comment": f"System package installation failed: {error_msg}", + }, + }) + return False + + # Install Python packages + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "installing", + "comment": "Installing Python packages, please wait...", + }, + }) + + pip_success, pip_error = await asyncio.to_thread(_install_pip_packages) + if not pip_success: + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "error", + "comment": f"Python package installation failed: {pip_error}", + }, + }) + return False + + # Set up accessibility env vars and GNOME setting + await asyncio.to_thread(_add_accessibility_env_vars) + await asyncio.to_thread(_enable_gnome_accessibility) + + await send_response({ + "action": "status", + "data": { + "category": CATEGORY, + "name": NAME, + "status": "installed", + "comment": "Linux Inspector dependencies installed successfully.", + }, + }) + return True diff --git a/Framework/install_handler/linux/xwd.py b/Framework/install_handler/linux/xwd.py index 561a5e20..d076d0b8 100644 --- a/Framework/install_handler/linux/xwd.py +++ b/Framework/install_handler/linux/xwd.py @@ -1,3 +1,4 @@ +import asyncio import os from Framework.install_handler.utils import send_response from .linux_utils import ( @@ -62,7 +63,7 @@ async def check_status(): return False packages = PACKAGES.get(package_manager, []) - if check_all_packages_installed(package_manager, packages): + if await asyncio.to_thread(check_all_packages_installed, package_manager, packages): await send_response( { "action": "status", @@ -141,8 +142,8 @@ async def install(user_password: str = ""): } ) - success, error_msg = install_packages( - package_manager, packages, user_password, timeout=300 + success, error_msg = await asyncio.to_thread( + install_packages, package_manager, packages, user_password, 300 ) if success: diff --git a/Framework/install_handler/route.py b/Framework/install_handler/route.py index f9e66d9c..e84d544a 100644 --- a/Framework/install_handler/route.py +++ b/Framework/install_handler/route.py @@ -12,6 +12,7 @@ from .ios import xcode, simulator from .macos import xcode as macos_xcode from .windows import inspector +from .linux import linux_inspector from .android import android_emulator, emulator_windows_linux, emulator services = [ @@ -202,6 +203,26 @@ } ], }, + { + "category": "Linux", + "group": { + "check_text": "Check all", + "install_text": "Install all", + }, + "services": [ + { + "name": "Linux Inspector", + "status": "none", + "comment": "System packages and Python libraries required for Linux desktop automation.", + "check_text": "Check status", + "install_text": "Install", + "os": ["linux"], + "status_function": linux_inspector.check_status, + "install_function": linux_inspector.install, + "user_password": True, + } + ], + }, { "category": "Windows", "group": { diff --git a/server/installers.py b/server/installers.py index bc421bc3..9b52acc2 100644 --- a/server/installers.py +++ b/server/installers.py @@ -24,6 +24,7 @@ create_avd_from_system_image, launch_avd, ) +from Framework.install_handler.system_info.system_info import get_formatted_system_info router = APIRouter(prefix="/installer", tags=["installer"]) @@ -39,9 +40,10 @@ ) ANDROID_CATEGORIES = {"Android", "AndroidEmulator"} WEB_CATEGORIES = {"Web"} +NATIVE_CATEGORIES = {"Linux", "Windows", "MacOS"} # Combined categories for patching -PATCH_CATEGORIES = ANDROID_CATEGORIES | WEB_CATEGORIES +PATCH_CATEGORIES = ANDROID_CATEGORIES | WEB_CATEGORIES | NATIVE_CATEGORIES # --- Models --- # @@ -333,21 +335,30 @@ async def _maybe_await(func, *args, **kwargs): async def _run_job(job: Job) -> None: - async with SEM: - JOB_STORE.update(job.id, status="running") - EVENT_BUS.publish(_make_event(job.id, "job.started", None)) - token = _event_context.set(job.id) - try: - result = await _dispatch_job(job) - JOB_STORE.update(job.id, status="succeeded", result=result) - EVENT_BUS.publish(_make_event(job.id, "job.completed", {"result": result})) - except Exception as exc: - JOB_STORE.update(job.id, status="failed", error=str(exc)) - EVENT_BUS.publish( - _make_event(job.id, "job.failed", {"error": str(exc)}) - ) - finally: - _event_context.reset(token) + # Status checks are read-only and fast — skip the concurrency semaphore + # so they don't queue behind running installs. + if job.action == "status": + await _execute_job(job) + else: + async with SEM: + await _execute_job(job) + + +async def _execute_job(job: Job) -> None: + JOB_STORE.update(job.id, status="running") + EVENT_BUS.publish(_make_event(job.id, "job.started", None)) + token = _event_context.set(job.id) + try: + result = await _dispatch_job(job) + JOB_STORE.update(job.id, status="succeeded", result=result) + EVENT_BUS.publish(_make_event(job.id, "job.completed", {"result": result})) + except Exception as exc: + JOB_STORE.update(job.id, status="failed", error=str(exc)) + EVENT_BUS.publish( + _make_event(job.id, "job.failed", {"error": str(exc)}) + ) + finally: + _event_context.reset(token) async def _dispatch_job(job: Job) -> Any: @@ -394,6 +405,9 @@ async def _dispatch_job(job: Job) -> Any: raise RuntimeError("name is required") return await _maybe_await(launch_avd, name) + if action == "system_info": + return await get_formatted_system_info() + raise RuntimeError(f"Unsupported action: {action}") @@ -633,6 +647,19 @@ async def event_stream(): return StreamingResponse(event_stream(), media_type="text/event-stream") +# --- System info --- # + + +@router.get("/system-info", response_model=SystemInfoResponse) +async def system_info(): + data = await get_formatted_system_info() + return SystemInfoResponse( + node_id=install_utils.read_node_id(), + generated_at=time.time(), + data=data, + ) + + # --- Installer log download (node-side; Zeuz UI fetches from node host:port) --- # diff --git a/server/linux.py b/server/linux.py index 63b1baba..db4edfde 100644 --- a/server/linux.py +++ b/server/linux.py @@ -22,6 +22,13 @@ class InspectorResponse(BaseModel): error: str | None = None +class LinuxAppInfo(BaseModel): + """Basic application metadata exposed by /apps.""" + + pid: str + name: str + + @router.get("/inspect") def inspect(app_name: str | None = None): """Get the Linux UI DOM and screenshot.""" @@ -67,6 +74,26 @@ def inspect(app_name: str | None = None): ) +@router.get("/apps", response_model=list[LinuxAppInfo]) +def get_apps(): + """Return available Linux applications visible to AT-SPI.""" + from Framework.Built_In_Automation.Desktop.Linux import BuiltInFunctions + if BuiltInFunctions is None: + return [] + + try: + apps = [] + raw_apps = BuiltInFunctions._get_atspi_apps() + # raw_apps: {pid: name} + for pid, name in raw_apps.items(): + if name: + apps.append(LinuxAppInfo(pid=str(pid), name=name)) + + return sorted(apps, key=lambda app: app.name.lower()) + except Exception: + return [] + + async def upload_linux_ui_dump(): """Continuously upload Linux UI dump if changed.""" from Framework.Built_In_Automation.Desktop.Linux import BuiltInFunctions diff --git a/server/status.py b/server/status.py index d59a239e..bb55ec54 100644 --- a/server/status.py +++ b/server/status.py @@ -1,3 +1,6 @@ +import os +import re +import subprocess from typing import Literal from fastapi import APIRouter from pydantic import BaseModel @@ -5,6 +8,25 @@ from Framework.Utilities import CommonUtil from Framework.node_server_state import STATE + +def _get_version() -> str | None: + # When launched by node_runner, CWD is ZeuZ_Node- + dirname = os.path.basename(os.getcwd()) + m = re.match(r"ZeuZ_Node-(.+)", dirname) + if m: + return m.group(1) + # Fallback: git branch + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True, timeout=3, + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + return None + router = APIRouter(prefix="/status", tags=["status"]) @@ -31,6 +53,7 @@ class StatusResponse(BaseModel): state: Literal["idle", "in_progress"] node_id: str | None = None + version: str | None = None @router.get("") @@ -42,4 +65,4 @@ def status(): node_id = id except Exception: node_id = "unknown" - return StatusResponse(state=STATE.state, node_id=node_id) + return StatusResponse(state=STATE.state, node_id=node_id, version=_get_version())