diff --git a/Framework/Built_In_Automation/Database/BuiltInFunctions.py b/Framework/Built_In_Automation/Database/BuiltInFunctions.py index c5b74a037..1f75d32da 100755 --- a/Framework/Built_In_Automation/Database/BuiltInFunctions.py +++ b/Framework/Built_In_Automation/Database/BuiltInFunctions.py @@ -277,32 +277,29 @@ def db_get_connection(session_name): ) CommonUtil.ExecLog(sModuleInfo, "Connected to Snowflake.", 1) elif "oracle" in db_type: - import cx_Oracle - - # https://cx-oracle.readthedocs.io/en/latest/api_manual/module.html#cx_Oracle.makedsn - if db_sid != 'zeuz_failed': - dsn = cx_Oracle.makedsn( - host=db_host, - port=db_port, - sid=db_sid - ) - elif db_service_name != 'zeuz_failed': - dsn = cx_Oracle.makedsn( - host=db_host, - port=db_port, - service_name=db_service_name - ) + import oracledb + + # Construct the DSN (Data Source Name) using the Easy Connect syntax + dsn = None + if db_service_name and db_service_name != 'zeuz_failed': + # Use Service Name for connection: host:port/service_name + dsn = f"{db_host}:{db_port}/{db_service_name}" + elif db_sid and db_sid != 'zeuz_failed': + # Use SID for connection: host:port:sid + dsn = f"{db_host}:{db_port}:{db_sid}" else: - CommonUtil.ExecLog(sModuleInfo, "Either db_sid or db_service must be provide.", 3) + CommonUtil.ExecLog(sModuleInfo, "Either db_sid or db_service must be provided.", 3) return "zeuz_failed" + + CommonUtil.ExecLog(sModuleInfo, f"Attempting Oracle connection using DSN: {dsn}", 1) # Connect to db - # https://cx-oracle.readthedocs.io/en/latest/api_manual/module.html#cx_Oracle.connect - db_con = cx_Oracle.connect( + db_con = oracledb.connect( user=db_user_id, password=db_password, dsn=dsn, ) + CommonUtil.ExecLog(sModuleInfo, "Connected to Oracle using python-oracledb.", 1) else: import pyodbc diff --git a/Framework/install_handler/android/adb.py b/Framework/install_handler/android/adb.py index 992f98f64..4ec9e448e 100644 --- a/Framework/install_handler/android/adb.py +++ b/Framework/install_handler/android/adb.py @@ -1,7 +1,131 @@ -async def check_status(): - print("[adb] Checking status...") +import subprocess +import asyncio +import platform +import os +from Framework.install_handler.utils import send_response + + +async def check_status() -> bool: + """Check if ADB (Android Debug Bridge) is installed.""" + print("[installer][android-adb] Checking status...") + + # Dynamically refresh ANDROID_HOME and PATH from registry on Windows + system = platform.system() + if system == "Windows": + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as key: + try: + android_home_reg, _ = winreg.QueryValueEx(key, "ANDROID_HOME") + if android_home_reg: + # Expand environment variables before checking if path exists + android_home_expanded = os.path.expandvars(android_home_reg) + if os.path.exists(android_home_expanded): + os.environ['ANDROID_HOME'] = android_home_expanded + # Update PATH with platform-tools (where ADB is located) + platform_tools = os.path.join(android_home_expanded, "platform-tools") + current_path = os.environ.get('PATH', '') + if platform_tools not in current_path: + os.environ['PATH'] = f"{platform_tools};{current_path}" + print(f"[installer][android-adb] Refreshed ANDROID_HOME from registry: {android_home_expanded}") + except FileNotFoundError: + pass + + # Also check ANDROID_SDK_ROOT if ANDROID_HOME not found + if 'ANDROID_HOME' not in os.environ or not os.path.exists(os.environ.get('ANDROID_HOME', '')): + try: + android_sdk_root_reg, _ = winreg.QueryValueEx(key, "ANDROID_SDK_ROOT") + if android_sdk_root_reg: + # Expand environment variables before checking if path exists + android_sdk_root_expanded = os.path.expandvars(android_sdk_root_reg) + if os.path.exists(android_sdk_root_expanded): + os.environ['ANDROID_SDK_ROOT'] = android_sdk_root_expanded + # Update PATH with platform-tools (where ADB is located) + platform_tools = os.path.join(android_sdk_root_expanded, "platform-tools") + current_path = os.environ.get('PATH', '') + if platform_tools not in current_path: + os.environ['PATH'] = f"{platform_tools};{current_path}" + print(f"[installer][android-adb] Refreshed ANDROID_SDK_ROOT from registry: {android_sdk_root_expanded}") + except FileNotFoundError: + pass + except Exception as e: + print(f"[installer][android-adb] Failed to refresh from registry: {e}") + + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + ["adb", "version"], + capture_output=True, + text=True, + check=False + ) + ) + + # If command succeeds (returncode = 0), ADB is installed + if result.returncode == 0: + version_output = (result.stdout or result.stderr).strip() + print(f"[installer][android-adb] Already installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "ADB", + "status": "installed", + "comment": f"ADB is installed (version: {version_output.split()[0] if version_output else 'unknown'})", + } + }) + return True + else: + print("[installer][android-adb] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "ADB", + "status": "not installed", + "comment": "Install ADB to use it.", + } + }) + return False + except Exception as e: + print(f"[installer][android-adb] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "ADB", + "status": "not installed", + "comment": "Unable to check ADB status.", + } + }) + return False + + async def install(): - print("[adb] Installing...") + """Install ADB - checks if already installed, otherwise prompts to install Android SDK.""" + print("[installer][android-adb] Installing...") + + # Check if ADB is already installed + if await check_status(): + print("[installer][android-adb] ADB is already installed") + return + + # ADB is not installed, send response to install Android SDK + print("[installer][android-adb] ADB is not installed. Install Android SDK to get ADB.") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "ADB", + "status": "not installed", + "comment": "Install the Android SDK, it will automatically install ADB.", + } + }) + + + diff --git a/Framework/install_handler/android/android_sdk.py b/Framework/install_handler/android/android_sdk.py new file mode 100644 index 000000000..6ef531b44 --- /dev/null +++ b/Framework/install_handler/android/android_sdk.py @@ -0,0 +1,864 @@ +import os +import platform +import stat +import shutil +import zipfile +import subprocess +from pathlib import Path +import httpx +from Framework.install_handler.utils import send_response +from settings import ZEUZ_NODE_DOWNLOADS_DIR + + +async def check_status() -> bool: + """Check if ANDROID_HOME environment variable is set and valid.""" + print("[installer][android-sdk] Checking status...") + + try: + # Check if ANDROID_HOME is set in current process environment + android_home = os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT') + + + # Dynamically refresh ANDROID_HOME from registry on Windows + system = platform.system() + if system == "Windows": + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as key: + try: + android_home_reg, _ = winreg.QueryValueEx(key, "ANDROID_HOME") + if android_home_reg: + # Expand environment variables before checking if path exists + android_home_expanded = os.path.expandvars(android_home_reg) + if os.path.exists(android_home_expanded): + os.environ['ANDROID_HOME'] = android_home_expanded + android_home = android_home_expanded + print(f"[installer][android-sdk] Refreshed ANDROID_HOME from registry: {android_home_expanded}") + except FileNotFoundError: + pass + + # Also check ANDROID_SDK_ROOT if ANDROID_HOME not found + if not android_home: + try: + android_sdk_root_reg, _ = winreg.QueryValueEx(key, "ANDROID_SDK_ROOT") + if android_sdk_root_reg: + # Expand environment variables before checking if path exists + android_sdk_root_expanded = os.path.expandvars(android_sdk_root_reg) + if os.path.exists(android_sdk_root_expanded): + os.environ['ANDROID_SDK_ROOT'] = android_sdk_root_expanded + android_home = android_sdk_root_expanded + print(f"[installer][android-sdk] Refreshed ANDROID_SDK_ROOT from registry: {android_sdk_root_expanded}") + except FileNotFoundError: + pass + except Exception as e: + print(f"[installer][android-sdk] Failed to refresh from registry: {e}") + + # Expand environment variables in the path on Windows (e.g., %USERPROFILE% -> C:\Users\Username) + # Linux/macOS don't need this as os.environ.get() already returns expanded paths + if android_home and system == "Windows": + android_home = os.path.expandvars(android_home) + + print(f"[installer][android-sdk] ANDROID_HOME value: {android_home}") + if not android_home: + print("[installer][android-sdk] Not installed (ANDROID_HOME not set)") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "not installed", + "comment": "Install Android SDK and set ANDROID_HOME environment variable.", + } + }) + return False + + # Check if the path exists + if not os.path.exists(android_home): + print(f"[installer][android-sdk] Not installed (ANDROID_HOME path does not exist: {android_home})") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "not installed", + "comment": f"ANDROID_HOME is set but path does not exist: {android_home}", + } + }) + return False + + print(f"[installer][android-sdk] Already installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installed", + "comment": f"Android SDK is installed at {android_home}", + } + }) + return True + except Exception as e: + print(f"[installer][android-sdk] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "not installed", + "comment": "Unable to check Android SDK status.", + } + }) + return False + + + + +def _get_sdk_root() -> Path: + # Place SDK fully under ZeuZ downloads directory + sdk_root = ZEUZ_NODE_DOWNLOADS_DIR / "android_sdk" / "sdk" + sdk_root.mkdir(parents=True, exist_ok=True) + return sdk_root + + + + +def _get_cmdline_tools_url() -> str: + version = "10406996_latest" + system = platform.system() + + if system == "Windows": + return f"https://dl.google.com/android/repository/commandlinetools-win-{version}.zip" + elif system == "Linux": + return f"https://dl.google.com/android/repository/commandlinetools-linux-{version}.zip" + elif system == "Darwin": # macOS + return f"https://dl.google.com/android/repository/commandlinetools-mac-{version}.zip" + else: + raise OSError(f"Unsupported platform: {system}") + + + + +async def _download_cmdline_tools(archive_path: Path) -> bool: + url = _get_cmdline_tools_url() + archive_path.parent.mkdir(parents=True, exist_ok=True) + + + print(f"[installer][android-sdk] Downloading Android Command Line Tools to {archive_path}...") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installing", + "comment": "Downloading Android Command Line Tools...", + } + }) + + + try: + async with httpx.AsyncClient(timeout=900.0) as client: + async with client.stream("GET", url) as response: + response.raise_for_status() + total_size = int(response.headers.get("content-length", 0)) + downloaded = 0 + chunk = 8192 + counts = [] + with open(archive_path, "wb") as f: + async for data in response.aiter_bytes(chunk): + f.write(data) + downloaded += len(data) + if total_size > 0: + progress = (downloaded / total_size) * 100 + mb_d = downloaded / (1024 * 1024) + mb_t = total_size / (1024 * 1024) + print(f"\r[installer][android-sdk] Download {progress:.1f}% ({mb_d:.1f}/{mb_t:.1f} MB)", end='', flush=True) + p = round(mb_d/mb_t, 1) + if p not in counts: + counts.append(p) + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installing", + "comment": f"Downloading Android Command Line Tools... {progress:.1f}% ({mb_d:.1f}/{mb_t:.1f} MB)", + } + }) + print() + print(f"[installer][android-sdk] Download complete: {archive_path}") + return True + except Exception as e: + print(f"\n[installer][android-sdk] Download failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "not installed", + "comment": f"Android Command Line Tools download failed: {str(e)}", + } + }) + return False + + + + +def _find_executable(base_path: Path, base_name: str) -> Path | None: + system = platform.system() + if system == "Windows": + exts = [".exe", ".bat", ".cmd", ""] + elif system == "Linux": + exts = ["", ".sh"] + elif system == "Darwin": # iOS/macOS + exts = ["", ".sh"] + + if system not in ("Windows", "Linux", "Darwin"): + raise OSError(f"Unsupported platform: {system}") + + for ext in exts: + p = base_path / (base_name + ext) + if p.is_file(): + return p + return None + + + + +async def _extract_cmdline_tools(archive_path: Path, sdk_root: Path) -> bool: + latest_dir = sdk_root / "cmdline-tools" / "latest" + latest_dir.mkdir(parents=True, exist_ok=True) + + + # If already extracted, clean stale zip and exit success + sdkmanager = _find_executable(latest_dir / "bin", "sdkmanager") + if sdkmanager: + print("[installer][android-sdk] Command Line Tools already extracted") + try: + if archive_path.exists(): + archive_path.unlink() + except Exception: + pass + return True + + + print("[installer][android-sdk] Extracting Android Command Line Tools...") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installing", + "comment": "Extracting Android Command Line Tools...", + } + }) + + + try: + with zipfile.ZipFile(archive_path, 'r') as zip_ref: + zip_ref.extractall(latest_dir) + + + inner = latest_dir / "cmdline-tools" + if inner.is_dir(): + for item in inner.iterdir(): + shutil.move(str(item), latest_dir) + shutil.rmtree(inner, ignore_errors=True) + + + # Make binaries executable on Linux and iOS/macOS + if platform.system() == "Linux": + bin_dir = latest_dir / "bin" + for tool in bin_dir.glob("*"): + if tool.is_file(): + try: + tool.chmod(tool.stat().st_mode | stat.S_IEXEC) + except Exception: + pass + elif platform.system() == "Darwin": # iOS/macOS + bin_dir = latest_dir / "bin" + for tool in bin_dir.glob("*"): + if tool.is_file(): + try: + tool.chmod(tool.stat().st_mode | stat.S_IEXEC) + except Exception: + pass + + + try: + if archive_path.exists(): + archive_path.unlink() + except Exception: + pass + + + print("[installer][android-sdk] Extraction complete") + return True + except Exception as e: + print(f"[installer][android-sdk] Extraction failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "not installed", + "comment": f"Extraction failed: {str(e)}", + } + }) + return False + + + + +async def _set_env_vars(sdk_root: Path) -> None: + print("[installer][android-sdk] Setting environment variables...") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installing", + "comment": "Setting ANDROID_HOME and PATH...", + } + }) + + + env_paths = [ + str(sdk_root / "platform-tools"), + str(sdk_root / "emulator"), + str(sdk_root / "cmdline-tools" / "latest" / "bin"), + ] + + + if platform.system() == "Windows": + try: + import winreg + # Set ANDROID_HOME in user environment variables (no admin needed) + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Environment", + 0, winreg.KEY_ALL_ACCESS) as key: + winreg.SetValueEx(key, "ANDROID_HOME", 0, winreg.REG_EXPAND_SZ, str(sdk_root)) + print("[installer][android-sdk] ANDROID_HOME set in Windows user environment") + + # Update PATH in user environment variables + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Environment", + 0, winreg.KEY_ALL_ACCESS) as key: + try: + current_path, _ = winreg.QueryValueEx(key, "Path") + except FileNotFoundError: + current_path = "" + + parts = current_path.split(";") if current_path else [] + updated = False + for p in env_paths: + if p not in parts: + parts.append(p) + updated = True + if updated: + winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, ";".join(parts)) + print("[installer][android-sdk] Android SDK paths added to Windows user environment") + except Exception as e: + print(f"[installer][android-sdk] Windows env update failed (continuing for user session): {e}") + elif platform.system() == "Linux": + # Linux - update shell configuration files + user_home = Path.home() + shell_configs = [ + user_home / ".bashrc", + user_home / ".zshrc", + user_home / ".profile" + ] + + export_lines = [ + f"export ANDROID_HOME={sdk_root}", + f"export ANDROID_SDK_ROOT={sdk_root}", + f"export PATH={':'.join(env_paths)}:$PATH" + ] + + updated = False + for config_file in shell_configs: + if config_file.exists(): + try: + with open(config_file, 'r') as f: + content = f.read() + needs_update = any(export not in content for export in export_lines) + + if needs_update: + with open(config_file, 'a') as f: + f.write("\n# Android SDK environment variables\n" + "\n".join(export_lines) + "\n") + print(f"[installer][android-sdk] Updated {config_file} with Android SDK paths") + updated = True + except Exception as e: + print(f"[installer][android-sdk] Failed to update {config_file}: {e}") + + if updated: + print("[!] Please restart your terminal or run 'source ~/.bashrc' (or your shell config)") + elif platform.system() == "Darwin": # iOS/macOS + # iOS/macOS - update shell configuration files + user_home = Path.home() + shell_configs = [ + user_home / ".bash_profile", + user_home / ".zshrc", + user_home / ".profile" + ] + + export_lines = [ + f"export ANDROID_HOME={sdk_root}", + f"export ANDROID_SDK_ROOT={sdk_root}", + f"export PATH={':'.join(env_paths)}:$PATH" + ] + + updated = False + for config_file in shell_configs: + if config_file.exists(): + try: + with open(config_file, 'r') as f: + content = f.read() + needs_update = any(export not in content for export in export_lines) + + if needs_update: + with open(config_file, 'a') as f: + f.write("\n# Android SDK environment variables\n" + "\n".join(export_lines) + "\n") + print(f"[installer][android-sdk] Updated {config_file} with Android SDK paths") + updated = True + except Exception as e: + print(f"[installer][android-sdk] Failed to update {config_file}: {e}") + + if updated: + print("[!] Please restart your terminal or run 'source ~/.zshrc' (or your shell config)") + + + # Always set for current session (works on all platforms) + os.environ['ANDROID_HOME'] = str(sdk_root) + os.environ['ANDROID_SDK_ROOT'] = str(sdk_root) + current_path = os.environ.get('PATH', '') + + if platform.system() == "Windows": + sep = ';' + elif platform.system() == "Linux": + sep = ':' + elif platform.system() == "Darwin": # iOS/macOS + sep = ':' + + # Prepend to PATH to ensure sdk tools are found first + for p in reversed(env_paths): + if p not in current_path: + current_path = f"{p}{sep}{current_path}" if current_path else p + os.environ['PATH'] = current_path + + + + +def _find_sdkmanager(sdk_root: Path) -> Path | None: + return _find_executable(sdk_root / "cmdline-tools" / "latest" / "bin", "sdkmanager") + + + + +async def _run_sdkmanager(sdk_root: Path, args: list[str]) -> bool: + try: + sdkmanager = _find_sdkmanager(sdk_root) + if not sdkmanager: + print("[installer][android-sdk] sdkmanager not found") + return False + + import asyncio + import subprocess + + system = platform.system() + output = None # Initialize for later use + + if system == "Windows": + # Windows - use PowerShell to pipe 'y' responses for auto-accepting licenses + # Quote each argument individually to prevent PowerShell from interpreting semicolons + yes_responses = ";".join(["echo y"] * 20) + # Wrap each arg in single quotes to preserve semicolons in package names like "platforms;android-36" + quoted_args = " ".join([f"'{arg}'" for arg in args]) + shell_cmd = f'powershell -Command "{yes_responses} | &\\"{str(sdkmanager)}\\" --sdk_root={sdk_root} {quoted_args}"' + print(f"[installer][android-sdk] Running: sdkmanager {' '.join(args)}") + print(f"[installer][android-sdk] This may take 5-15 minutes to download ~450MB of components...") + + loop = asyncio.get_event_loop() + + # Use Popen and print output in real-time + def run_sdkmanager(): + process = subprocess.Popen( + shell_cmd, + shell=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + # Close stdin - PowerShell piping will provide input + process.stdin.close() + + # Print output in real-time as it comes + output_lines = [] + try: + for line in iter(process.stdout.readline, ''): + if line: + print(line.rstrip()) # Print immediately + output_lines.append(line.strip()) + except Exception as e: + print(f"[installer][android-sdk] Output reading error: {e}") + + process.stdout.close() + returncode = process.wait(timeout=1800) + + # Return last 50 lines for debugging + return returncode, "\n".join(output_lines[-50:]) if output_lines else "" + + returncode, output = await loop.run_in_executor(None, run_sdkmanager) + + class Result: + pass + result = Result() + result.returncode = returncode + elif system == "Linux": + # Linux can execute directly + cmd = [str(sdkmanager), f"--sdk_root={sdk_root}"] + args + print(f"[installer][android-sdk] Running: {' '.join(cmd)}") + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=1800 # 30 minutes timeout + ) + ) + output = (result.stdout or "") + (result.stderr or "") + elif system == "Darwin": + # macOS can execute directly + cmd = [str(sdkmanager), f"--sdk_root={sdk_root}"] + args + print(f"[installer][android-sdk] Running: {' '.join(cmd)}") + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=1800 # 30 minutes timeout + ) + ) + output = (result.stdout or "") + (result.stderr or "") + else: + print(f"[installer][android-sdk] Unsupported platform: {system}") + return False + + if result.returncode != 0: + print(f"[installer][android-sdk] sdkmanager failed (returncode={result.returncode})") + if output: + print(f"[installer][android-sdk] Last output:\n{output}") + return False + + print(f"[installer][android-sdk] sdkmanager completed successfully") + if output: + print(f"[installer][android-sdk] Final output:\n{output[-500:]}") # Last 500 chars + return True + except subprocess.TimeoutExpired: + print("[installer][android-sdk] sdkmanager timed out after 30 minutes") + return False + except Exception as e: + print(f"[installer][android-sdk] sdkmanager error: {e}") + import traceback + traceback.print_exc() + return False + + +async def _accept_licenses(sdk_root: Path) -> bool: + """Accept Android SDK licenses by piping 'yes' responses""" + try: + sdkmanager = _find_sdkmanager(sdk_root) + if not sdkmanager: + print("[installer][android-sdk] sdkmanager not found") + return False + + import asyncio + import subprocess + + cmd = [str(sdkmanager), f"--sdk_root={sdk_root}", "--licenses"] + print(f"[installer][android-sdk] Accepting licenses: {' '.join(cmd)}") + + if platform.system() == "Windows": + # On Windows, use PowerShell to pipe 'y' responses + # Multiple 'y' responses to answer all license prompts + yes_responses = ";".join(["echo y"] * 20) + shell_cmd = f'powershell -Command "{yes_responses} | &\\"{str(sdkmanager)}\\" --sdk_root={sdk_root} --licenses"' + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + shell_cmd, + shell=True, + capture_output=True, + text=True + ) + ) + output = result.stdout + result.stderr + returncode = result.returncode + elif platform.system() == "Linux": + # On Linux, use shell to pipe 'yes' command via subprocess.run in executor + shell_cmd = f"yes | {str(sdkmanager)} --sdk_root={sdk_root} --licenses" + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + shell_cmd, + shell=True, + capture_output=True, + text=True + ) + ) + output = result.stdout + result.stderr + returncode = result.returncode + elif platform.system() == "Darwin": # iOS/macOS + # On iOS/macOS, use shell to pipe 'yes' command via subprocess.run in executor + shell_cmd = f"yes | {str(sdkmanager)} --sdk_root={sdk_root} --licenses" + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + shell_cmd, + shell=True, + capture_output=True, + text=True + ) + ) + output = result.stdout + result.stderr + returncode = result.returncode + + if returncode != 0: + print(f"[installer][android-sdk] License acceptance failed: {output}") + return False + print("[installer][android-sdk] Licenses accepted successfully") + return True + except Exception as e: + print(f"[installer][android-sdk] License acceptance error: {e}") + import traceback + traceback.print_exc() + return False + + + + +def _refresh_java_home() -> bool: + """ + Dynamically refresh JAVA_HOME from Windows Registry to current process. + Returns True if JAVA_HOME is available, False otherwise. + """ + system = platform.system() + + if system == "Windows": + try: + import winreg + # Try to read JAVA_HOME from user registry + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as key: + java_home, _ = winreg.QueryValueEx(key, "JAVA_HOME") + if java_home and os.path.exists(java_home): + os.environ['JAVA_HOME'] = java_home + print(f"[installer][android-sdk] Refreshed JAVA_HOME from registry: {java_home}") + + # Add JAVA_HOME/bin to PATH (so java.exe and javac.exe are accessible) + java_bin = os.path.join(java_home, "bin") + current_path = os.environ.get('PATH', '') + if java_bin not in current_path: + os.environ['PATH'] = f"{java_bin};{current_path}" + print(f"[installer][android-sdk] Added Java bin to PATH: {java_bin}") + return True + except FileNotFoundError: + pass + + # Fallback: Try system registry + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", + 0, winreg.KEY_READ) as key: + java_home, _ = winreg.QueryValueEx(key, "JAVA_HOME") + if java_home and os.path.exists(java_home): + os.environ['JAVA_HOME'] = java_home + print(f"[installer][android-sdk] Refreshed JAVA_HOME from system registry: {java_home}") + + # Add JAVA_HOME/bin to PATH + java_bin = os.path.join(java_home, "bin") + current_path = os.environ.get('PATH', '') + if java_bin not in current_path: + os.environ['PATH'] = f"{java_bin};{current_path}" + return True + except FileNotFoundError: + pass + + print("[installer][android-sdk] WARNING: JAVA_HOME not found in registry") + return False + except Exception as e: + print(f"[installer][android-sdk] Failed to refresh JAVA_HOME: {e}") + return False + elif system == "Linux": + # On Linux, JAVA_HOME should already be in os.environ if set + java_home = os.environ.get('JAVA_HOME') + if java_home: + print(f"[installer][android-sdk] JAVA_HOME={java_home}") + return True + else: + print("[installer][android-sdk] WARNING: JAVA_HOME not set") + return False + elif system == "Darwin": + # On macOS, JAVA_HOME should already be in os.environ if set + java_home = os.environ.get('JAVA_HOME') + if java_home: + print(f"[installer][android-sdk] JAVA_HOME={java_home}") + return True + else: + print("[installer][android-sdk] WARNING: JAVA_HOME not set") + return False + + return False + + +async def install() -> bool: + print("[installer][android-sdk] Installing...") + + # Dynamically refresh JAVA_HOME before installation (critical for sdkmanager) + if not _refresh_java_home(): + print("[installer][android-sdk] ERROR: Java/JDK is required to install Android SDK") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "not installed", + "comment": "Java/JDK is required. Please install Java or JDK first.", + } + }) + return False + + # Dynamically refresh ANDROID_HOME from registry on Windows + if platform.system() == "Windows": + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as key: + try: + android_home_reg, _ = winreg.QueryValueEx(key, "ANDROID_HOME") + if android_home_reg: + # Expand environment variables before setting + android_home_expanded = os.path.expandvars(android_home_reg) + os.environ['ANDROID_HOME'] = android_home_expanded + except FileNotFoundError: + pass + + # Also check ANDROID_SDK_ROOT if ANDROID_HOME not found + if 'ANDROID_HOME' not in os.environ or not os.environ.get('ANDROID_HOME'): + try: + android_sdk_root_reg, _ = winreg.QueryValueEx(key, "ANDROID_SDK_ROOT") + if android_sdk_root_reg: + # Expand environment variables before setting + android_sdk_root_expanded = os.path.expandvars(android_sdk_root_reg) + os.environ['ANDROID_SDK_ROOT'] = android_sdk_root_expanded + except FileNotFoundError: + pass + except Exception: + pass + + # Check if ANDROID_HOME is set + android_home = os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT') + + # Expand environment variables in the path (e.g., %USERPROFILE% -> C:\Users\Username) + if android_home: + android_home = os.path.expandvars(android_home) + + if android_home: + # ANDROID_HOME is set - check if directory exists + if os.path.exists(android_home): + print(f"[installer][android-sdk] SDK already installed at {android_home}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installed", + "comment": f"Android SDK available at {android_home}", + } + }) + return True + else: + print(f"[installer][android-sdk] ANDROID_HOME is set but directory does not exist: {android_home}") + print("[installer][android-sdk] Proceeding with fresh installation...") + else: + print("[installer][android-sdk] ANDROID_HOME not set, proceeding with installation...") + + sdk_root = _get_sdk_root() + + + # Prepare download path under ZeuZ downloads dir + download_dir = ZEUZ_NODE_DOWNLOADS_DIR / "android_sdk" + archive_path = download_dir / "commandlinetools.zip" + + + ok = await _download_cmdline_tools(archive_path) + if not ok: + return False + + + ok = await _extract_cmdline_tools(archive_path, sdk_root) + if not ok: + return False + + + await _set_env_vars(sdk_root) + + + # Accept licenses + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installing", + "comment": "Accepting Android SDK licenses...", + } + }) + if not await _accept_licenses(sdk_root): + print("[installer][android-sdk] License acceptance failed") + # Continue; some environments prompt-less acceptance may not be required + + + # Install core components + core_components = [ + "platform-tools", + "emulator", + # A recent platform and build-tools; adjust if needed + "platforms;android-36", + "build-tools;34.0.0", + ] + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installing", + "comment": "Installing SDK components (platform-tools, emulator, platforms, build-tools)...", + } + }) + if not await _run_sdkmanager(sdk_root, core_components): + print("[installer][android-sdk] Failed installing one or more SDK components") + return False + + + print(f"[installer][android-sdk] Installation successful at {sdk_root}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Android SDK", + "status": "installed", + "comment": f"Android SDK installed at {sdk_root}", + } + }) + return True \ No newline at end of file diff --git a/Framework/install_handler/android/appium.py b/Framework/install_handler/android/appium.py index 990ab9eab..b704b10c6 100644 --- a/Framework/install_handler/android/appium.py +++ b/Framework/install_handler/android/appium.py @@ -1,7 +1,231 @@ -async def check_status(): - print("[appium] Checking status...") +import subprocess +import asyncio +import platform +from Framework.install_handler.utils import send_response +from Framework.nodejs_appium_installer import check_installations, get_appium_path, get_node_dir -async def install(): - print("[appium] Installing...") +async def check_status() -> bool: + """Check if Appium is installed.""" + print("[installer][android-appium] Checking status...") + + try: + # Use the check function from nodejs_appium_installer + node_installed, appium_installed, missing_drivers = check_installations() + + if appium_installed: + # Get the installation location + appium_path = get_appium_path() + node_dir = get_node_dir() + appium_location = str(appium_path.resolve()) if appium_path.exists() else str(node_dir.resolve()) + + # Get version for display + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + [str(appium_path), "--version"], + capture_output=True, + text=True, + check=False + ) + ) + print("appium result: ", result) + version_output = (result.stdout or result.stderr or "").strip() + version_info = f" (version: {version_output})" if version_output else "" + except: + version_info = "" + + print(f"[installer][android-appium] Already installed at {appium_location}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "installed", + "comment": f"Appium is installed at {appium_location}{version_info}", + } + }) + return True + else: + print("[installer][android-appium] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "not installed", + "comment": "Run the ZeuZ Node, it will automatically install Appium", + } + }) + return False + except Exception as e: + print(f"[installer][android-appium] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "not installed", + "comment": "Run the ZeuZ Node, it will automatically install Appium", + } + }) + return False + + + + +async def install() -> bool: + """Install Appium globally via npm.""" + print("[installer][android-appium] Installing...") + + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "installing", + "comment": "Installing Appium via npm...", + } + }) + + try: + system = platform.system() + loop = asyncio.get_event_loop() + + # Platform-specific npm command handling + if system == "Windows": + # Windows: npm is typically npm.cmd, but npm works too + npm_cmd = "npm" + cmd = [npm_cmd, "install", "-g", "appium"] + + print(f"[installer][android-appium] Running on Windows: {' '.join(cmd)}") + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + shell=True, # Windows may need shell=True for npm + timeout=600 # 10 minutes timeout + ) + ) + + elif system == "Linux": + # Linux: standard npm command + npm_cmd = "npm" + cmd = [npm_cmd, "install", "-g", "appium"] + + print(f"[installer][android-appium] Running on Linux: {' '.join(cmd)}") + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600 # 10 minutes timeout + ) + ) + + elif system == "Darwin": + # macOS: standard npm command, may need sudo depending on setup + npm_cmd = "npm" + cmd = [npm_cmd, "install", "-g", "appium"] + + print(f"[installer][android-appium] Running on macOS: {' '.join(cmd)}") + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600 # 10 minutes timeout + ) + ) + + else: + print(f"[installer][android-appium] Unsupported platform: {system}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "not installed", + "comment": f"Installation not supported on platform: {system}", + } + }) + return False + + # Check installation result + output = (result.stdout or "") + (result.stderr or "") + + if result.returncode != 0: + print(f"[installer][android-appium] Installation failed (returncode={result.returncode})") + print(f"[installer][android-appium] Output: {output[:500]}") + + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "not installed", + "comment": f"Installation failed: {output[:200] if output else 'Unknown error'}", + } + }) + return False + + print(f"[installer][android-appium] Installation successful") + if output: + print(f"[installer][android-appium] Output: {output[:300]}") + + # Verify installation by checking status + if await check_status(): + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "installed", + "comment": "Appium installed successfully via npm", + } + }) + return True + else: + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "not installed", + "comment": "Installation completed but Appium is not accessible", + } + }) + return False + + except subprocess.TimeoutExpired: + print("[installer][android-appium] Installation timed out after 10 minutes") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "not installed", + "comment": "Installation timed out after 10 minutes", + } + }) + return False + + except Exception as e: + print(f"[installer][android-appium] Installation error: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Appium", + "status": "not installed", + "comment": f"Installation error: {str(e)[:100]}", + } + }) + return False \ No newline at end of file diff --git a/Framework/install_handler/android/emulator.py b/Framework/install_handler/android/emulator.py new file mode 100644 index 000000000..9cb6e00d6 --- /dev/null +++ b/Framework/install_handler/android/emulator.py @@ -0,0 +1,1780 @@ +import os +import platform +import subprocess +import asyncio +import re +import random +from pathlib import Path +from settings import ZEUZ_NODE_DOWNLOADS_DIR +from Framework.install_handler.utils import send_response, debug + + +def _get_sdk_root() -> Path | None: + """Get the Android SDK root path, following the pattern from android_sdk.py""" + # Dynamically refresh ANDROID_HOME from registry on Windows + system = platform.system() + if system == "Windows": + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as key: + try: + android_home_reg, _ = winreg.QueryValueEx(key, "ANDROID_HOME") + if android_home_reg: + # Expand environment variables before checking if path exists + android_home_expanded = os.path.expandvars(android_home_reg) + if os.path.exists(android_home_expanded): + os.environ['ANDROID_HOME'] = android_home_expanded + + except FileNotFoundError: + pass + + # Also check ANDROID_SDK_ROOT if ANDROID_HOME not found + if 'ANDROID_HOME' not in os.environ or not os.path.exists(os.environ.get('ANDROID_HOME', '')): + try: + android_sdk_root_reg, _ = winreg.QueryValueEx(key, "ANDROID_SDK_ROOT") + if android_sdk_root_reg: + # Expand environment variables before checking if path exists + android_sdk_root_expanded = os.path.expandvars(android_sdk_root_reg) + if os.path.exists(android_sdk_root_expanded): + os.environ['ANDROID_SDK_ROOT'] = android_sdk_root_expanded + if debug: + print(f"[installer][emulator] Refreshed ANDROID_SDK_ROOT from registry: {android_sdk_root_expanded}") + except FileNotFoundError: + pass + except Exception as e: + if debug: + print(f"[installer][emulator] Failed to refresh from registry: {e}") + + # First try environment variable + android_home = os.environ.get('ANDROID_HOME') or os.environ.get('ANDROID_SDK_ROOT') + if android_home: + # Expand environment variables in the path on Windows (e.g., %USERPROFILE% -> C:\Users\Username) + # Linux/macOS don't need this as os.environ.get() already returns expanded paths + if system == "Windows": + android_home = os.path.expandvars(android_home) + if os.path.exists(android_home): + return Path(android_home) + + # Fallback to ZeuZ downloads directory + sdk_root = ZEUZ_NODE_DOWNLOADS_DIR / "android_sdk" / "sdk" + if sdk_root.exists(): + return sdk_root + + # If neither exists, return None (SDK not installed) + return None + + +def _find_executable(base_path: Path, base_name: str) -> Path | None: + """Find an executable file with platform-specific extensions""" + system = platform.system() + if system == "Windows": + exts = [".exe", ".bat", ".cmd", ""] + elif system == "Linux": + exts = ["", ".sh"] + elif system == "Darwin": # iOS/macOS + exts = ["", ".sh"] + else: + return None + + for ext in exts: + p = base_path / (base_name + ext) + if p.is_file(): + return p + return None + + +def _find_avdmanager(sdk_root: Path) -> Path | None: + """Find avdmanager executable""" + return _find_executable(sdk_root / "cmdline-tools" / "latest" / "bin", "avdmanager") + + +def _find_sdkmanager(sdk_root: Path) -> Path | None: + """Find sdkmanager executable""" + return _find_executable(sdk_root / "cmdline-tools" / "latest" / "bin", "sdkmanager") + + +def _is_windows(): + """Check if running on Windows""" + return platform.system() == 'Windows' + + +def _is_linux(): + """Check if running on Linux""" + return platform.system() == 'Linux' + + +def _is_darwin(): + """Check if running on macOS""" + return platform.system() == 'Darwin' + + +def get_emulator_command(): + """ + Returns the correct emulator executable path depending on OS. + Assumes ANDROID_HOME or ANDROID_SDK_ROOT is already set. + """ + # Dynamically refresh ANDROID_HOME from registry on Windows + system = platform.system() + if system == "Windows": + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as key: + try: + android_home_reg, _ = winreg.QueryValueEx(key, "ANDROID_HOME") + if android_home_reg: + # Expand environment variables before checking if path exists + android_home_expanded = os.path.expandvars(android_home_reg) + if os.path.exists(android_home_expanded): + os.environ['ANDROID_HOME'] = android_home_expanded + if debug: + print(f"[installer][emulator] Refreshed ANDROID_HOME from registry: {android_home_expanded}") + except FileNotFoundError: + pass + + # Also check ANDROID_SDK_ROOT if ANDROID_HOME not found + if 'ANDROID_HOME' not in os.environ or not os.path.exists(os.environ.get('ANDROID_HOME', '')): + try: + android_sdk_root_reg, _ = winreg.QueryValueEx(key, "ANDROID_SDK_ROOT") + if android_sdk_root_reg: + # Expand environment variables before checking if path exists + android_sdk_root_expanded = os.path.expandvars(android_sdk_root_reg) + if os.path.exists(android_sdk_root_expanded): + os.environ['ANDROID_SDK_ROOT'] = android_sdk_root_expanded + if debug: + print(f"[installer][emulator] Refreshed ANDROID_SDK_ROOT from registry: {android_sdk_root_expanded}") + except FileNotFoundError: + pass + except Exception as e: + if debug: + print(f"[installer][emulator] Failed to refresh from registry: {e}") + + sdk_root = os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT") + if sdk_root: + # Expand environment variables in the path + sdk_root = os.path.expandvars(sdk_root) + + if debug: + print("Launch avd: ", sdk_root) + if not sdk_root: + raise EnvironmentError("ANDROID_HOME or ANDROID_SDK_ROOT is not set.") + + if system == "Windows": + return os.path.join(sdk_root, "emulator", "emulator.exe") + + elif system == "Darwin": # macOS + return os.path.join(sdk_root, "emulator", "emulator") + + elif system == "Linux": + return os.path.join(sdk_root, "emulator", "emulator") + + else: + raise RuntimeError(f"Unsupported OS: {system}") + + +async def get_available_avds() -> list[dict]: + """ + List available Android Virtual Devices (AVDs) by running avdmanager list avd. + Returns a list of dictionaries with name and comment fields. + """ + try: + sdk_root = _get_sdk_root() + + # Check if Android SDK is installed + if sdk_root is None: + if debug: + print("[installer][emulator] Android SDK not found. ANDROID_HOME or ANDROID_SDK_ROOT not set.") + return [] + + avdmanager = _find_avdmanager(sdk_root) + + if not avdmanager: + if debug: + print("[installer][emulator] avdmanager not found") + return [] + + # Run avdmanager list avd command using async executor + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + [str(avdmanager), "list", "avd"], + capture_output=True, + text=True, + timeout=30 + ) + ) + + if result.returncode != 0: + if debug: + print(f"[installer][emulator] avdmanager list avd failed: {result.stderr}") + return [] + + output = result.stdout + + # Parse the output preserving original formatting + avds = [] + current_avd = {} + comment_lines = [] + lines = output.split('\n') + + for line in lines: + stripped_line = line.strip() + + # Skip empty lines and header + if not stripped_line: + # Preserve empty lines if we're collecting comments (they're part of formatting) + if current_avd.get("name") and comment_lines: + # Only add empty line if it's not trailing (there are non-empty lines after) + pass # We'll skip empty lines to avoid trailing ones + continue + + if stripped_line.startswith("Available Android Virtual Devices"): + continue + + # Skip separator lines (e.g., "---------") + if stripped_line.startswith("-") and all(c == "-" for c in stripped_line): + continue + + # Parse Name + if stripped_line.startswith("Name:"): + # Save previous AVD if exists + if current_avd.get("name"): + # Join all comment lines preserving original format + current_avd["comment"] = "\n".join(comment_lines).rstrip() + avds.append(current_avd) + comment_lines = [] + + # Start new AVD + name = stripped_line.replace("Name:", "").strip() + + current_avd = { + "name": name, + "status": "installed", + "comment": "", + "install_text": "", + "os": ["windows", "linux", "darwin"], + "check_text": "Launch", + "status_function": lambda avd=name: launch_avd(avd), + "user_password": "no", + } + continue + + # For all other lines (Path, Target, Based on, Tag/ABI, Sdcard) + # Preserve the original line format (including indentation) + if current_avd.get("name"): + comment_lines.append(line) + + # Don't forget the last AVD + if current_avd.get("name"): + current_avd["comment"] = "\n".join(comment_lines).rstrip() + avds.append(current_avd) + + return avds + + except subprocess.TimeoutExpired: + if debug: + print("[installer][emulator] avdmanager list avd timed out") + return [] + except Exception as e: + if debug: + print(f"[installer][emulator] Error listing AVDs: {e}") + import traceback + traceback.print_exc() + return [] + + +async def launch_avd(avd_name: str) -> bool: + """ + Launch AVD using emulator command determined by OS. + Non-blocking - the emulator starts in the background. + Sends response to server on success or failure. + """ + try: + emulator_path = get_emulator_command() + + # Launch emulator in background using Popen (non-blocking) + # Popen returns immediately, so we can call it directly without blocking + process = subprocess.Popen( + [emulator_path, "-avd", avd_name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True # Detach from parent process + ) + + # Popen returns immediately - the process runs in background + if debug: + print(f"[installer][emulator] Launching AVD: {avd_name}... (PID: {process.pid})") + + # Send success response to server + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "name": avd_name, + "status": "installed", + "comment": f"Emulator {avd_name} is launching (PID: {process.pid})", + } + }) + return True + + except FileNotFoundError: + error_msg = f"Emulator executable not found" + if debug: + print(f"[installer][emulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "name": avd_name, + "status": "not installed", + "comment": f"Failed to launch {avd_name}: {error_msg}", + } + }) + return False + except Exception as e: + error_msg = f"Failed to launch AVD {avd_name}: {e}" + if debug: + print(f"[installer][emulator] {error_msg}") + import traceback + traceback.print_exc() + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "name": avd_name, + "status": "not installed", + "comment": error_msg, + } + }) + return False + +async def get_filtered_avd_services(): + """ + Get available AVDs and filter them by OS, returning a formatted category dictionary. + Uses the same filtering logic as generate_services_list in utils.py. + + Returns: + Dictionary with category "AndroidEmulator" and filtered services, or None if no AVDs match + """ + current_os = platform.system().lower() + + try: + avds = await get_available_avds() + + if not avds: + return None + + # Filter AVDs by OS (same logic as generate_services_list) + filtered_services = [] + for avd in avds: + # Check if AVD supports current OS + if "os" in avd and current_os not in avd["os"]: + continue + + # Create filtered service with only the fields needed (matching generate_services_list format) + filtered_service = { + "name": avd.get("name", ""), + "status": avd.get("status", "installed"), + "comment": avd.get("comment", ""), + "install_text": avd.get("install_text", ""), + "check_text": "Launch", + "os": avd.get("os", []), + "user_password": avd.get("user_password", "no") + } + filtered_services.append(filtered_service) + + # Return None if no services match, otherwise return category dict + if not filtered_services: + return None + + return { + "category": "AndroidEmulator", + "services": filtered_services + } + + except Exception as e: + if debug: + print(f"[installer][emulator] Error getting filtered AVD services: {e}") + import traceback + traceback.print_exc() + return None + +def _run_sdkmanager_list(sdkmanager: Path, sdk_root: Path) -> str: + """Run sdkmanager --list with shell piping to filter system-images (OS-agnostic, synchronous)""" + try: + system = platform.system() + + if system == "Windows": + # Windows: Use PowerShell Select-String + command = f'& "{sdkmanager}" --sdk_root="{sdk_root}" --list | Select-String "system-images"' + result = subprocess.run( + ["powershell", "-Command", command], + capture_output=True, + text=True, + timeout=60 + ) + elif system in ["Linux", "Darwin"]: + # Linux/macOS: Use grep + command = f'"{sdkmanager}" --sdk_root="{sdk_root}" --list | grep "system-images"' + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=60 + ) + else: + # Fallback: run without filtering + result = subprocess.run( + [str(sdkmanager), f"--sdk_root={sdk_root}", "--list"], + capture_output=True, + text=True, + timeout=60 + ) + if result.returncode == 0: + # Filter manually + lines = result.stdout.split('\n') + filtered = [line for line in lines if 'system-images' in line] + return '\n'.join(filtered) + else: + if debug: + print(f"[installer][emulator] sdkmanager --list failed: {result.stderr}") + return "" + + if result.returncode == 0: + return result.stdout + else: + if debug: + print(f"[installer][emulator] sdkmanager --list failed: {result.stderr}") + return "" + except subprocess.TimeoutExpired: + if debug: + print("[installer][emulator] sdkmanager --list timed out") + return "" + except Exception as e: + if debug: + print(f"[installer][emulator] Error running sdkmanager --list: {e}") + return "" + + +def _parse_system_image_details(output: str) -> list[dict]: + """ + Parse system image details from sdkmanager --list output. + Expected format: system-images;android-36.1;google_apis_playstore;x86_64 | 3 | Google Play Intel x86_64 Atom System Image + Returns list of dicts with package, version, and description. + """ + system_images = [] + lines = output.split('\n') + + for line in lines: + stripped = line.strip() + if not stripped or not stripped.startswith('system-images;'): + continue + + # Parse the line: package | version | description + # Split by | and clean up + parts = [p.strip() for p in stripped.split('|')] + + if len(parts) >= 1: + package = parts[0].strip() + + # Extract additional info if available + version = parts[1].strip() if len(parts) > 1 else "" + description = parts[2].strip() if len(parts) > 2 else "" + + system_images.append({ + "package": package, + "version": version, + "description": description, + "status" : "not installed", + "comment" : "" + }) + + return system_images + + +async def get_available_system_images() -> list[dict]: + """ + Get available system images by running sdkmanager --list and parsing system-images. + Returns a list of dictionaries with package, version, and description. + Example: [{"package": "system-images;android-34;google_apis;x86_64", "version": "3", "description": "Google APIs Intel x86_64 Atom System Image"}] + """ + try: + sdk_root = _get_sdk_root() + + # Check if Android SDK is installed + if sdk_root is None: + if debug: + print("[installer][emulator] Android SDK not found. ANDROID_HOME or ANDROID_SDK_ROOT not set.") + await send_response({ + "action": "status", + "data": { + "category": "Android Emulator", + "name": "System Images", + "status": "not installed", + "comment": "No system images found. Please make sure you have installed the ANDROID SDK components.", + } + }) + return [] + + sdkmanager = _find_sdkmanager(sdk_root) + + if not sdkmanager: + if debug: + print("[installer][emulator] sdkmanager not found") + return [] + + # Check platform support + if not (_is_windows() or _is_linux() or _is_darwin()): + if debug: + print(f"[installer][emulator] Unsupported platform: {platform.system()}") + return [] + + # Run sdkmanager --list using async executor + loop = asyncio.get_event_loop() + output = await loop.run_in_executor( + None, + _run_sdkmanager_list, + sdkmanager, + sdk_root + ) + + if not output: + if debug: + print("[installer][emulator] sdkmanager --list returned empty output") + return [] + + # Parse system image details from output + system_images = _parse_system_image_details(output) + + # Remove duplicates based on package name and sort + seen = set() + unique_images = [] + for img in system_images: + if img["package"] not in seen: + seen.add(img["package"]) + unique_images.append(img) + + # Sort by package name + system_images = sorted(unique_images, key=lambda x: x["package"]) + + if debug: + print(f"[installer][emulator] Found {len(system_images)} available system images") + return system_images + + except subprocess.TimeoutExpired: + if debug: + print("[installer][emulator] sdkmanager --list timed out") + return [] + except Exception as e: + if debug: + print(f"[installer][emulator] Error getting available system images: {e}") + import traceback + traceback.print_exc() + return [] + + +def _parse_device_list(output: str) -> list[dict]: + """ + Parse device list from avdmanager list device output. + package = device id + version = device name + description = OEM + As perviously, we were sending avaailable system images list, this is done to + keep the response send to the server same as before. Minimal changes required in the server. + """ + devices = [] + lines = output.split('\n') + current_device = {} + + for line in lines: + stripped = line.strip() + + # Skip empty lines and header + if not stripped or stripped.startswith("Available devices definitions:"): + continue + + # Skip separator lines + if stripped.startswith("-") and all(c == "-" for c in stripped): + # Save previous device if exists + if current_device.get("package"): + # Add status and comment fields before appending + current_device["status"] = "Not installed" + current_device["comment"] = "" + devices.append(current_device) + current_device = {} + continue + + # Parse device ID: id: 0 or "desktop_large" + if stripped.startswith("id:"): + # Extract the quoted part (device ID) -> package + # Format: id: 2 or "desktop_large" + match = re.search(r'"([^"]+)"', stripped) + if match: + current_device["package"] = match.group(1) + else: + # Fallback: try to extract numeric ID + match = re.search(r'id:\s*(\d+)', stripped) + if match: + current_device["package"] = match.group(1) + continue + + # Parse Name: Name: Large Desktop -> version + if stripped.startswith("Name:"): + name = stripped.replace("Name:", "").strip() + current_device["version"] = name + continue + + # Parse OEM: OEM : Google -> description + if stripped.startswith("OEM"): + oem = stripped.split(":", 1)[1].strip() if ":" in stripped else "" + current_device["description"] = oem + continue + + # Don't forget the last device + if current_device.get("package"): + # Add status and comment fields before appending + current_device["status"] = "Not installed" + current_device["comment"] = "" + devices.append(current_device) + + return devices + + +async def get_available_devices() -> list[dict]: + """ + Get available devices by running avdmanager list device. + Returns a list of dictionaries with package, version, and description (matching system images format). + """ + try: + sdk_root = _get_sdk_root() + + # Check if Android SDK is installed + if sdk_root is None: + if debug: + print("[installer][emulator] Android SDK not found. ANDROID_HOME or ANDROID_SDK_ROOT not set.") + await send_response({ + "action": "status", + "data": { + "category": "Android Emulator", + "name": "System Images", + "status": "Not Found", + "comment": "No devices found. Please make sure you have installed the ANDROID SDK components.", + } + }) + return [] + + avdmanager = _find_avdmanager(sdk_root) + + if not avdmanager: + if debug: + print("[installer][emulator] avdmanager not found") + return [] + + # Run avdmanager list device using async executor + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + [str(avdmanager), "list", "device"], + capture_output=True, + text=True, + timeout=60 + ) + ) + + if result.returncode != 0: + if debug: + print(f"[installer][emulator] avdmanager list device failed: {result.stderr}") + return [] + + # Parse device details from output + devices = _parse_device_list(result.stdout) + + # Filter out devices that already have AVDs created + existing_avds = await get_available_avds() + existing_avd_names = {avd["name"] for avd in existing_avds} + + filtered_devices = [] + for device in devices: + device_name = device.get("version", "") # version field contains device name + if device_name: + # Sanitize device name to match how AVD names are created + sanitized_name = _sanitize_avd_name(device_name) + # Check if an AVD with this name already exists + if sanitized_name not in existing_avd_names: + filtered_devices.append(device) + elif debug: + print(f"[installer][emulator] Filtering out device '{device_name}' (AVD '{sanitized_name}' already exists)") + else: + # If no device name, include it (shouldn't happen, but safe fallback) + filtered_devices.append(device) + + if debug: + print(f"[installer][emulator] Found {len(devices)} available devices, {len(filtered_devices)} not yet installed") + return filtered_devices + + except subprocess.TimeoutExpired: + if debug: + print("[installer][emulator] avdmanager list device timed out") + return [] + except Exception as e: + if debug: + print(f"[installer][emulator] Error getting available devices: {e}") + import traceback + traceback.print_exc() + return [] + +async def check_emulator_list(): + """ + Sends response to server with list of installed emulators for Android. + """ + avd_list = await get_filtered_avd_services() + if avd_list: + await send_response( + { + "action": "services_update", + "data": { + 'category': 'AndroidEmulator', + "services": avd_list['services'], + }, + } + ) + return True + return False + +async def android_emulator_install(): + """ + Get available devices when install button is clicked. + Returns list of available devices for emulator installation. + """ + if debug: + print("[installer][emulator] Getting available devices...") + + try: + # Check if Android SDK is installed first + sdk_root = _get_sdk_root() + if sdk_root is None: + if debug: + print("[installer][emulator] Android SDK not found") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "name": "System Images", + "status": "Not Found", + "comment": "Download and install Android SDK first", + "installables": [] + } + }) + return False + + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "name": "Devices", + "status": "Fetching", + "comment": "Fetching available devices...", + "installables": [] + } + }) + + # Get available devices + devices = await get_available_devices() + if debug: + print(f"[installer][emulator] Available devices: {devices}") + + + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + ##"name": "System Images", + #"status": "Found", + # "comment": f"Available devices ({len(devices)} total)", + "installables": devices, # Send the full list with details + } + }) + return True + + except Exception as e: + if debug: + print(f"[installer][emulator] Error getting devices: {e}") + import traceback + traceback.print_exc() + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "name": "Devices", + "status": "not installed", + "comment": f"Error getting devices: {str(e)}", + "installables": [] + } + }) + return False + + +# List of 20 four-letter words for AVD name generation +_AVD_NAME_WORDS = [ + "blue", "fast", "cool", "wave", "star", "moon", "fire", "wind", "rock", "tree", + "lake", "snow", "rain", "gold", "dark", "light", "bold", "soft", "wild", "calm" +] + + +def _extract_android_version(system_image_name: str) -> str: + """ + Extract Android version from system image name. + Example: system-images;android-36-ext18;google_apis;arm64-v8 -> android-36 + """ + # Split by semicolon and get the second part (android-XX-...) + parts = system_image_name.split(';') + if len(parts) < 2: + raise ValueError(f"Invalid system image name format: {system_image_name}") + + android_part = parts[1] # e.g., "android-36-ext18" + + # Extract just the version part (android-XX) + # Match "android-" followed by digits + match = re.match(r'android-(\d+)', android_part) + if not match: + raise ValueError(f"Could not extract Android version from: {android_part}") + + return f"android-{match.group(1)}" + + +def _get_existing_avd_names() -> list[str]: + """Get list of existing AVD names""" + try: + sdk_root = _get_sdk_root() + if sdk_root is None: + return [] + + avdmanager = _find_avdmanager(sdk_root) + if not avdmanager: + return [] + + result = subprocess.run( + [str(avdmanager), "list", "avd"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + return [] + + # Parse AVD names from output + avd_names = [] + for line in result.stdout.split('\n'): + if line.strip().startswith('Name:'): + name = line.strip().replace('Name:', '').strip() + if name: + avd_names.append(name) + + return avd_names + except Exception: + return [] + + +def _generate_avd_name(android_version: str, existing_avds: list[str]) -> str: + """ + Generate a unique AVD name by combining Android version with two random words. + Format: android-{version}-{word1}-{word2} + """ + max_attempts = 100 # Prevent infinite loop + + for _ in range(max_attempts): + # Pick two random words + word1, word2 = random.sample(_AVD_NAME_WORDS, 2) + avd_name = f"{android_version}-{word1}-{word2}" + + # Check if AVD name already exists + if avd_name not in existing_avds: + return avd_name + + # Fallback: add random number if all combinations are taken + word1, word2 = random.sample(_AVD_NAME_WORDS, 2) + random_num = random.randint(1000, 9999) + return f"{android_version}-{word1}-{word2}-{random_num}" + + +def _run_sdkmanager_install_windows(sdkmanager: Path, sdk_root: Path, system_image: str, loop=None, device_id: str = None) -> tuple[bool, str]: + """Install system image on Windows with real-time output""" + try: + # Use PowerShell to handle the command properly and auto-accept licenses + # This approach pipes 'y' responses to automatically accept licenses + yes_responses = ";".join(["echo y"] * 20) + quoted_image = f"'{system_image}'" + shell_cmd = f'powershell -Command "{yes_responses} | &\\"{str(sdkmanager)}\\" --sdk_root={sdk_root} {quoted_image}"' + + if debug: + print(f"[installer][emulator] Running: sdkmanager --sdk_root={sdk_root} {system_image}") + print(f"[installer][emulator] This may take 10-30 minutes to download system image...") + + process = subprocess.Popen( + shell_cmd, + shell=True, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + # Close stdin - PowerShell piping will provide input + process.stdin.close() + + # Print output in real-time as it comes, showing progress on single line + output_lines = [] + last_progress = "" + progress_count = [] + try: + for line in iter(process.stdout.readline, ''): + if line: + stripped = line.strip() + output_lines.append(stripped) + + # Extract progress percentage from lines like "[====] 25% Loading..." + progress_match = re.search(r'\[.*?\]\s*(\d+)%\s*(.+)', stripped) + if progress_match: + percent = int(progress_match.group(1)) + status = progress_match.group(2).strip() + current_progress = f"{percent}% {status}" + if current_progress != last_progress: + print(f"\r[installer][emulator] Download progress: {current_progress}", end='', flush=True) + last_progress = current_progress + + if loop and device_id: + rounded_percent = round(percent / 10) * 10 + if rounded_percent not in progress_count: + progress_count.append(rounded_percent) + asyncio.run_coroutine_threadsafe( + send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installing", + "comment": f"Downloading system image... {percent}% {status}", + } + }), + loop + ) + elif stripped and not stripped.startswith('[') and '%' not in stripped: + # Print important non-progress messages on new line + print(f"\n[installer][emulator] {stripped}") + elif stripped.endswith('%'): + # Handle lines that end with just percentage + percent_match = re.search(r'(\d+)%', stripped) + if percent_match: + percent = int(percent_match.group(1)) + print(f"\r[installer][emulator] Download progress: {stripped}", end='', flush=True) + + if loop and device_id: + rounded_percent = round(percent / 10) * 10 + if rounded_percent not in progress_count: + progress_count.append(rounded_percent) + asyncio.run_coroutine_threadsafe( + send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installing", + "comment": f"Downloading system image... {percent}%", + } + }), + loop + ) + except Exception as e: + print(f"\n[installer][emulator] Output reading error: {e}") + finally: + print() # New line after progress completes + + process.stdout.close() + returncode = process.wait(timeout=1800) # 30 minutes for large system image downloads + + output = "\n".join(output_lines) + if returncode == 0: + return True, output + else: + return False, output + except subprocess.TimeoutExpired: + return False, "Installation timed out after 30 minutes" + except Exception as e: + return False, str(e) + + +def _run_sdkmanager_install_linux(sdkmanager: Path, sdk_root: Path, system_image: str, loop=None, device_id: str = None) -> tuple[bool, str]: + """Install system image on Linux with real-time output""" + try: + if debug: + print(f"[installer][emulator] Running: sdkmanager --sdk_root={sdk_root} {system_image}") + print(f"[installer][emulator] This may take 10-30 minutes to download system image...") + + process = subprocess.Popen( + [str(sdkmanager), f"--sdk_root={sdk_root}", system_image], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + + # Print output in real-time as it comes, showing progress on single line + output_lines = [] + last_progress = "" + progress_count = [] + try: + for line in iter(process.stdout.readline, ''): + if line: + stripped = line.strip() + output_lines.append(stripped) + + # Extract progress percentage from lines like "[====] 25% Loading..." + progress_match = re.search(r'\[.*?\]\s*(\d+)%\s*(.+)', stripped) + if progress_match: + percent = int(progress_match.group(1)) + status = progress_match.group(2).strip() + current_progress = f"{percent}% {status}" + if current_progress != last_progress: + print(f"\r[installer][emulator] Download progress: {current_progress}", end='', flush=True) + last_progress = current_progress + + if loop and device_id: + rounded_percent = round(percent / 10) * 10 + if rounded_percent not in progress_count: + progress_count.append(rounded_percent) + asyncio.run_coroutine_threadsafe( + send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installing", + "comment": f"Downloading system image... {percent}% {status}", + } + }), + loop + ) + elif stripped and not stripped.startswith('[') and '%' not in stripped: + # Print important non-progress messages on new line + print(f"\n[installer][emulator] {stripped}") + elif stripped.endswith('%'): + # Handle lines that end with just percentage + percent_match = re.search(r'(\d+)%', stripped) + if percent_match: + percent = int(percent_match.group(1)) + print(f"\r[installer][emulator] Download progress: {stripped}", end='', flush=True) + + if loop and device_id: + rounded_percent = round(percent / 10) * 10 + if rounded_percent not in progress_count: + progress_count.append(rounded_percent) + asyncio.run_coroutine_threadsafe( + send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installing", + "comment": f"Downloading system image... {percent}%", + } + }), + loop + ) + except Exception as e: + print(f"\n[installer][emulator] Output reading error: {e}") + finally: + print() # New line after progress completes + + process.stdout.close() + returncode = process.wait(timeout=1800) # 30 minutes for large system image downloads + + output = "\n".join(output_lines) + if returncode == 0: + return True, output + else: + return False, output + except subprocess.TimeoutExpired: + return False, "Installation timed out after 30 minutes" + except Exception as e: + return False, str(e) + + +def _run_sdkmanager_install_darwin(sdkmanager: Path, sdk_root: Path, system_image: str, loop=None, device_id: str = None) -> tuple[bool, str]: + """Install system image on macOS with real-time output""" + try: + if debug: + print(f"[installer][emulator] Running: sdkmanager --sdk_root={sdk_root} {system_image}") + print(f"[installer][emulator] This may take 10-30 minutes to download system image...") + + process = subprocess.Popen( + [str(sdkmanager), f"--sdk_root={sdk_root}", system_image], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + + # Print output in real-time as it comes, showing progress on single line + output_lines = [] + last_progress = "" + progress_count = [] + try: + for line in iter(process.stdout.readline, ''): + if line: + stripped = line.strip() + output_lines.append(stripped) + + # Extract progress percentage from lines like "[====] 25% Loading..." + progress_match = re.search(r'\[.*?\]\s*(\d+)%\s*(.+)', stripped) + if progress_match: + percent = int(progress_match.group(1)) + status = progress_match.group(2).strip() + current_progress = f"{percent}% {status}" + if current_progress != last_progress: + print(f"\r[installer][emulator] Download progress: {current_progress}", end='', flush=True) + last_progress = current_progress + + if loop and device_id: + rounded_percent = round(percent / 10) * 10 + if rounded_percent not in progress_count: + progress_count.append(rounded_percent) + asyncio.run_coroutine_threadsafe( + send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installing", + "comment": f"Downloading system image... {percent}% {status}", + } + }), + loop + ) + elif stripped and not stripped.startswith('[') and '%' not in stripped: + # Print important non-progress messages on new line + print(f"\n[installer][emulator] {stripped}") + elif stripped.endswith('%'): + # Handle lines that end with just percentage + percent_match = re.search(r'(\d+)%', stripped) + if percent_match: + percent = int(percent_match.group(1)) + print(f"\r[installer][emulator] Download progress: {stripped}", end='', flush=True) + + if loop and device_id: + rounded_percent = round(percent / 10) * 10 + if rounded_percent not in progress_count: + progress_count.append(rounded_percent) + asyncio.run_coroutine_threadsafe( + send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installing", + "comment": f"Downloading system image... {percent}%", + } + }), + loop + ) + except Exception as e: + print(f"\n[installer][emulator] Output reading error: {e}") + finally: + print() # New line after progress completes + + process.stdout.close() + returncode = process.wait(timeout=1800) # 30 minutes for large system image downloads + + output = "\n".join(output_lines) + if returncode == 0: + return True, output + else: + return False, output + except subprocess.TimeoutExpired: + return False, "Installation timed out after 30 minutes" + except Exception as e: + return False, str(e) + + +def _run_avdmanager_create_windows(avdmanager: Path, sdk_root: Path, avd_name: str, system_image: str, device_id: str) -> tuple[bool, str]: + """Create AVD on Windows with real-time output""" + try: + # Create AVD: avdmanager create avd -n {avd_name} -k {system_image} -d {device_id} + # Answer "no" to custom hardware profile prompt + process = subprocess.Popen( + [str(avdmanager), "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + + # Send "no" to custom hardware profile prompt + process.stdin.write("no\n") + process.stdin.close() + + # Print output in real-time as it comes, showing progress on single line + output_lines = [] + last_progress = "" + try: + for line in iter(process.stdout.readline, ''): + if line: + stripped = line.strip() + output_lines.append(stripped) + + # Extract progress percentage from lines like "[====] 25% Loading..." + progress_match = re.search(r'\[.*?\]\s*(\d+)%\s*(.+)', stripped) + if progress_match: + percent = progress_match.group(1) + status = progress_match.group(2).strip() + current_progress = f"{percent}% {status}" + if current_progress != last_progress: + print(f"\r[installer][emulator] Download progress: {current_progress}", end='', flush=True) + last_progress = current_progress + elif stripped and not stripped.startswith('[') and '%' not in stripped: + # Print important non-progress messages on new line + print(f"\n[installer][emulator] {stripped}") + elif stripped.endswith('%'): + # Handle lines that end with just percentage + print(f"\r[installer][emulator] Download progress: {stripped}", end='', flush=True) + except Exception as e: + print(f"\n[installer][emulator] Output reading error: {e}") + finally: + print() # New line after progress completes + + process.stdout.close() + returncode = process.wait(timeout=120) + + output = "\n".join(output_lines) + if returncode == 0: + return True, output + else: + return False, output + except subprocess.TimeoutExpired: + return False, "AVD creation timed out" + except Exception as e: + return False, str(e) + + +def _run_avdmanager_create_linux(avdmanager: Path, sdk_root: Path, avd_name: str, system_image: str, device_id: str) -> tuple[bool, str]: + """Create AVD on Linux with real-time output""" + try: + # Create AVD: avdmanager create avd -n {avd_name} -k {system_image} -d {device_id} + # Answer "no" to custom hardware profile prompt + process = subprocess.Popen( + [str(avdmanager), "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + + # Send "no" to custom hardware profile prompt + process.stdin.write("no\n") + process.stdin.close() + + # Print output in real-time as it comes, showing progress on single line + output_lines = [] + last_progress = "" + try: + for line in iter(process.stdout.readline, ''): + if line: + stripped = line.strip() + output_lines.append(stripped) + + # Extract progress percentage from lines like "[====] 25% Loading..." + progress_match = re.search(r'\[.*?\]\s*(\d+)%\s*(.+)', stripped) + if progress_match: + percent = progress_match.group(1) + status = progress_match.group(2).strip() + current_progress = f"{percent}% {status}" + if current_progress != last_progress: + print(f"\r[installer][emulator] Download progress: {current_progress}", end='', flush=True) + last_progress = current_progress + elif stripped and not stripped.startswith('[') and '%' not in stripped: + # Print important non-progress messages on new line + print(f"\n[installer][emulator] {stripped}") + elif stripped.endswith('%'): + # Handle lines that end with just percentage + print(f"\r[installer][emulator] Download progress: {stripped}", end='', flush=True) + except Exception as e: + print(f"\n[installer][emulator] Output reading error: {e}") + finally: + print() # New line after progress completes + + process.stdout.close() + returncode = process.wait(timeout=120) + + output = "\n".join(output_lines) + if returncode == 0: + return True, output + else: + return False, output + except subprocess.TimeoutExpired: + return False, "AVD creation timed out" + except Exception as e: + return False, str(e) + + +def _run_avdmanager_create_darwin(avdmanager: Path, sdk_root: Path, avd_name: str, system_image: str, device_id: str) -> tuple[bool, str]: + """Create AVD on macOS with real-time output""" + try: + # Create AVD: avdmanager create avd -n {avd_name} -k {system_image} -d {device_id} + # Answer "no" to custom hardware profile prompt + process = subprocess.Popen( + [str(avdmanager), "create", "avd", "-n", avd_name, "-k", system_image, "-d", device_id], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 # Line buffered + ) + + # Send "no" to custom hardware profile prompt + process.stdin.write("no\n") + process.stdin.close() + + # Print output in real-time as it comes, showing progress on single line + output_lines = [] + last_progress = "" + try: + for line in iter(process.stdout.readline, ''): + if line: + stripped = line.strip() + output_lines.append(stripped) + + # Extract progress percentage from lines like "[====] 25% Loading..." + progress_match = re.search(r'\[.*?\]\s*(\d+)%\s*(.+)', stripped) + if progress_match: + percent = progress_match.group(1) + status = progress_match.group(2).strip() + current_progress = f"{percent}% {status}" + if current_progress != last_progress: + print(f"\r[installer][emulator] Download progress: {current_progress}", end='', flush=True) + last_progress = current_progress + elif stripped and not stripped.startswith('[') and '%' not in stripped: + # Print important non-progress messages on new line + print(f"\n[installer][emulator] {stripped}") + elif stripped.endswith('%'): + # Handle lines that end with just percentage + print(f"\r[installer][emulator] Download progress: {stripped}", end='', flush=True) + except Exception as e: + print(f"\n[installer][emulator] Output reading error: {e}") + finally: + print() # New line after progress completes + + process.stdout.close() + returncode = process.wait(timeout=120) + + output = "\n".join(output_lines) + if returncode == 0: + return True, output + else: + return False, output + except subprocess.TimeoutExpired: + return False, "AVD creation timed out" + except Exception as e: + return False, str(e) + + +def _sanitize_avd_name(device_name: str) -> str: + """ + Sanitize device name to create a valid AVD name. + AVD names can only contain: a-z A-Z 0-9 . _ - + + Args: + device_name: Original device name (e.g., "Pixel 6") + + Returns: + Sanitized AVD name (e.g., "Pixel-6") + """ + # Replace spaces with hyphens + sanitized = device_name.replace(" ", "-") + + # Remove any characters that are not allowed (a-z A-Z 0-9 . _ -) + sanitized = re.sub(r'[^a-zA-Z0-9._-]', '', sanitized) + + # Ensure it's not empty + if not sanitized: + sanitized = "AVD" + + return sanitized + + +def _get_highest_api_system_image(system_images: list[dict]) -> str | None: + """ + Get the system image with the highest API level. + API level is the primary priority. Uses variant/arch as tiebreaker when API levels are equal. + + Args: + system_images: List of system image dictionaries with package, version, description + + Returns: + System image package name (e.g., "system-images;android-36;google_apis;x86_64") or None + """ + if not system_images: + return None + + # Extract API levels - API level is the priority + candidates = [] + for img in system_images: + package = img.get("package", "") + if not package.startswith("system-images;"): + continue + + # Parse: system-images;android-XX;variant;arch + parts = package.split(";") + if len(parts) < 2: + continue + + # Extract API level from android-XX + android_part = parts[1] + match = re.match(r'android-(\d+)', android_part) + if not match: + continue + + api_level = int(match.group(1)) + variant = parts[2] if len(parts) > 2 else "" + arch = parts[3] if len(parts) > 3 else "" + + # Variant/arch preference for tiebreaking (only used when API levels are equal) + variant_priority = 0 + if variant == "google_apis" and arch == "x86_64": + variant_priority = 3 # Best variant/arch combo + elif variant == "google_apis_playstore" and arch == "x86_64": + variant_priority = 2 # Second best + elif variant == "google_apis": + variant_priority = 1 + elif variant == "google_apis_playstore": + variant_priority = 1 + else: + variant_priority = 0 + + candidates.append({ + "package": package, + "api_level": api_level, + "variant_priority": variant_priority + }) + + if not candidates: + return None + + # Sort by API level (descending - highest first), then by variant priority (tiebreaker) + candidates.sort(key=lambda x: (x["api_level"], x["variant_priority"]), reverse=True) + + # Return the highest API level (variant priority only matters if API levels are equal) + return candidates[0]["package"] + + +async def create_avd_from_system_image(device_param: str) -> bool: + """ + Create AVD from device ID and device name, using highest API level system image. + + Args: + device_param: Format "install device;device_id;device_name" + Example: "install device;desktop_large;Large Desktop" + + Returns: + bool: True if successful, False otherwise + """ + try: + # Parse device parameter: "install device;device_id;device_name" + parts = device_param.split(";") + if len(parts) < 3: + error_msg = f"Invalid device parameter format. Expected 'install device;device_id;device_name', got: {device_param}" + print(f"[installer][emulator] {error_msg}") + device_id = parts[1].strip() if len(parts) > 1 else "unknown" + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "Not Found", + "comment": error_msg, + } + }) + return False + + device_id = parts[1].strip() + device_name = parts[2].strip() + + # Sanitize device name for AVD (AVD names can only contain: a-z A-Z 0-9 . _ -) + avd_name = _sanitize_avd_name(device_name) + + print(f"[installer][emulator] Creating AVD '{avd_name}' (from device name '{device_name}') with device ID '{device_id}'") + + # Check if Android SDK is installed + sdk_root = _get_sdk_root() + if sdk_root is None: + print("[installer][emulator] Android SDK not found. ANDROID_HOME or ANDROID_SDK_ROOT not set.") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "not installed", + "comment": "Download and install Android SDK first", + } + }) + return False + + # Find required tools + sdkmanager = _find_sdkmanager(sdk_root) + avdmanager = _find_avdmanager(sdk_root) + + if not sdkmanager: + if debug: + print("[installer][emulator] sdkmanager not found") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "Not Found", + "comment": "sdkmanager not found. Please check Android SDK installation.", + } + }) + return False + + if not avdmanager: + if debug: + print("[installer][emulator] avdmanager not found") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "Not Found", + "comment": "avdmanager not found. Please check Android SDK installation.", + } + }) + return False + + # Step 0: Get available system images and select highest API level + print(f"[installer][emulator] Getting available system images with Android Version 16") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installing", + "comment": "Finding system image with Android Version 16", + } + }) + + system_images = await get_available_system_images() + if not system_images: + error_msg = "No system images found. Please install Android SDK components first." + print(f"[installer][emulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "not installed", + "comment": error_msg, + } + }) + return False + + # Get highest API level system image (prefer google_apis;x86_64) + system_image_name = _get_highest_api_system_image(system_images) + if not system_image_name: + error_msg = "Could not find a suitable system image." + print(f"[installer][emulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "not installed", + "comment": error_msg, + } + }) + return False + + print(f"[installer][emulator] Selected system image: {system_image_name}") + print(f"[installer][emulator] Creating AVD '{device_name}' with device ID '{device_id}' and system image '{system_image_name}'") + + # Step 1: Install system image + print(f"[installer][emulator] Installing system image: {system_image_name}") + + loop = asyncio.get_event_loop() + if _is_windows(): + success, output = await loop.run_in_executor( + None, + _run_sdkmanager_install_windows, + sdkmanager, + sdk_root, + system_image_name, + loop, + device_id + ) + elif _is_linux(): + success, output = await loop.run_in_executor( + None, + _run_sdkmanager_install_linux, + sdkmanager, + sdk_root, + system_image_name, + loop, + device_id + ) + elif _is_darwin(): + success, output = await loop.run_in_executor( + None, + _run_sdkmanager_install_darwin, + sdkmanager, + sdk_root, + system_image_name, + loop, + device_id + ) + else: + # Fallback to Linux for unknown platforms + success, output = await loop.run_in_executor( + None, + _run_sdkmanager_install_linux, + sdkmanager, + sdk_root, + system_image_name, + loop, + device_id + ) + + if not success: + error_msg = f"Failed to install Android Version 16: {output}" + print(f"[installer][emulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "Not Found", + "comment": error_msg, + } + }) + return False + + print(f"[installer][emulator] System image installed successfully") + + # Step 2: Create AVD with device_id and device_name + print(f"[installer][emulator] Creating AVD: {avd_name} with device ID: {device_id}") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installing", + "comment": f"Creating AVD '{avd_name}'...", + } + }) + + if _is_windows(): + success, output = await loop.run_in_executor( + None, + _run_avdmanager_create_windows, + avdmanager, + sdk_root, + avd_name, + system_image_name, + device_id + ) + elif _is_linux(): + success, output = await loop.run_in_executor( + None, + _run_avdmanager_create_linux, + avdmanager, + sdk_root, + avd_name, + system_image_name, + device_id + ) + elif _is_darwin(): + success, output = await loop.run_in_executor( + None, + _run_avdmanager_create_darwin, + avdmanager, + sdk_root, + avd_name, + system_image_name, + device_id + ) + else: + # Fallback to Linux for unknown platforms + success, output = await loop.run_in_executor( + None, + _run_avdmanager_create_linux, + avdmanager, + sdk_root, + avd_name, + system_image_name, + device_id + ) + + if not success: + error_msg = f"Failed to create AVD: {output}" + print(f"[installer][emulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "not installed", + "comment": error_msg, + } + }) + return False + + print(f"[installer][emulator] AVD '{avd_name}' created successfully") + + # Note: AVD list will be automatically refreshed when services_list is requested + # in long_poll_handler.py, so no manual refresh is needed here + + # Send success response + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "installed", + "comment": f"Installation of AVD '{avd_name}' completed", + } + }) + + return True + + except ValueError as e: + error_msg = f"Invalid device parameter: {e}" + print(f"[installer][emulator] {error_msg}") + device_name = device_param.split(";")[2].strip() if len(device_param.split(";")) > 2 else device_param + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "not installed", + "comment": error_msg, + } + }) + return False + except Exception as e: + error_msg = f"Error creating AVD: {e}" + print(f"[installer][emulator] {error_msg}") + import traceback + traceback.print_exc() + device_name = device_param.split(";")[2].strip() if len(device_param.split(";")) > 2 else device_param + await send_response({ + "action": "status", + "data": { + "category": "AndroidEmulator", + "package": device_id, + "status": "not installed", + "comment": error_msg, + } + }) + return False \ No newline at end of file diff --git a/Framework/install_handler/android/java.py b/Framework/install_handler/android/java.py index 7c93e85fa..209604e30 100644 --- a/Framework/install_handler/android/java.py +++ b/Framework/install_handler/android/java.py @@ -1,7 +1,165 @@ -async def check_status(): - print("[java] Checking status...") +import subprocess +import re +import asyncio +import platform +import os +from Framework.install_handler.utils import send_response +from Framework.install_handler.android.jdk import install as install_jdk + + +async def check_status() -> bool: + """Check if Java 21 is installed.""" + print("[installer][android-java] Checking status...") + + # Dynamically refresh JAVA_HOME and PATH from registry on Windows + system = platform.system() + if system == "Windows": + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as key: + try: + java_home_reg, _ = winreg.QueryValueEx(key, "JAVA_HOME") + if java_home_reg and os.path.exists(java_home_reg): + os.environ['JAVA_HOME'] = java_home_reg + # Update PATH with Java bin + java_bin = os.path.join(java_home_reg, "bin") + current_path = os.environ.get('PATH', '') + if java_bin not in current_path: + os.environ['PATH'] = f"{java_bin};{current_path}" + print(f"[installer][android-java] Refreshed JAVA_HOME from registry: {java_home_reg}") + except FileNotFoundError: + pass + except Exception as e: + print(f"[installer][android-java] Failed to refresh from registry: {e}") + + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + ["java", "-version"], + capture_output=True, + text=True, + check=False + ) + ) + + if result.returncode != 0: + print("[installer][android-java] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Java", + "status": "not installed", + "comment": "Install Java 21 to use it.", + } + }) + return False + + # java -version prints to stderr typically + version_text = (result.stderr or result.stdout).strip() + if not version_text: + print("[installer][android-java] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Java", + "status": "not installed", + "comment": "Install Java 21 to use it.", + } + }) + return False + + # Extract version number from output like "openjdk version \"21.0.1\"" or "java version \"1.8.0_291\"" + version_match = re.search(r'version\s+"?(\d+)\.(\d+)', version_text) + if version_match: + major_version = int(version_match.group(1)) + minor_version = int(version_match.group(2)) + + # Check if it's Java 21 + if major_version == 21: + print(f"[installer][android-java] Already installed (version: {major_version}.{minor_version})") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Java", + "status": "installed", + "comment": f"Java is installed (version: {major_version}.{minor_version})", + } + }) + return True + # Handle old versioning like "1.8.0" where major=1, minor=8 + elif major_version == 1 and minor_version >= 8: + print(f"[installer][android-java] Wrong version installed (found: {major_version}.{minor_version})") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Java", + "status": "not installed", + "comment": f"Install Java 21 to use it (found version: {major_version}.{minor_version}).", + } + }) + return False + + print(f"[installer][android-java] Not installed (found version: {version_text})") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Java", + "status": "not installed", + "comment": f"Install Java 21 to use it (found version: {version_text[:50]}).", + } + }) + return False + except Exception as e: + print(f"[installer][android-java] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Java", + "status": "not installed", + "comment": "Unable to check Java status.", + } + }) + return False -async def install(): - print("[java] Installing...") + +async def install(): + """Install Java by calling JDK installation function""" + print("[installer][android-java] Installing...") + + # Call JDK installation function + success = await install_jdk() + + if success: + print("[installer][android-java] Java installation successful") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Java", + "status": "installed", + "comment": "Java is installed", + } + }) + return True + else: + print("[installer][android-java] Java installation failed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Java", + "status": "not installed", + "comment": "Failed to install Java", + } + }) + return False \ No newline at end of file diff --git a/Framework/install_handler/android/jdk.py b/Framework/install_handler/android/jdk.py new file mode 100644 index 000000000..e26f285f2 --- /dev/null +++ b/Framework/install_handler/android/jdk.py @@ -0,0 +1,688 @@ +import subprocess +import re +import httpx +import asyncio +import os +import platform +import shutil +import tempfile +import zipfile +import tarfile +import stat +from pathlib import Path +from Framework.install_handler.utils import send_response +from settings import ZEUZ_NODE_DOWNLOADS_DIR + + +async def _get_jdk_download_url(): + """Get the appropriate JDK 21 LTS download URL based on platform""" + system = platform.system() + + if system == "Windows": + return "https://download.oracle.com/java/21/latest/jdk-21_windows-x64_bin.zip" + elif system == "Linux": + return "https://download.oracle.com/java/21/latest/jdk-21_linux-x64_bin.tar.gz" + elif system == "Darwin": + # macOS - use ARM64 for Apple Silicon or x64 for Intel + import subprocess + try: + # Check if running on Apple Silicon + result = subprocess.run(["uname", "-m"], capture_output=True, text=True) + arch = result.stdout.strip() + if arch == "arm64": + return "https://download.oracle.com/java/21/latest/jdk-21_macos-aarch64_bin.tar.gz" + else: + return "https://download.oracle.com/java/21/latest/jdk-21_macos-x64_bin.tar.gz" + except: + # Default to x64 if detection fails + return "https://download.oracle.com/java/21/latest/jdk-21_macos-x64_bin.tar.gz" + else: + raise OSError(f"Unsupported platform: {system}") + + +async def _download_jdk(): + """Download JDK 21 LTS with progress reporting""" + print("[installer][android-jdk] Downloading JDK 21 LTS...") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "installing", + "comment": "Downloading JDK 21...", + } + }) + + jdk_url = await _get_jdk_download_url() + + download_dir = ZEUZ_NODE_DOWNLOADS_DIR / "jdk" + system = platform.system() + + if system == "Windows": + jdk_archive = download_dir / "jdk21.zip" + elif system == "Linux": + jdk_archive = download_dir / "jdk21.tar.gz" + elif system == "Darwin": + jdk_archive = download_dir / "jdk21.tar.gz" + else: + raise OSError(f"Unsupported platform: {system}") + + try: + jdk_archive.parent.mkdir(parents=True, exist_ok=True) + + async with httpx.AsyncClient(timeout=900.0) as client: + async with client.stream("GET", jdk_url) as response: + response.raise_for_status() + + total_size = int(response.headers.get("content-length", 0)) + chunk_size = 8192 + downloaded = 0 + + count = [] + with open(jdk_archive, "wb") as f: + async for chunk in response.aiter_bytes(chunk_size): + f.write(chunk) + downloaded += len(chunk) + + if total_size > 0: + progress = (downloaded / total_size) * 100 + bar_length = 50 + filled_length = int(bar_length * downloaded // total_size) + bar = '█' * filled_length + '-' * (bar_length - filled_length) + + mb_downloaded = downloaded / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + + print(f"\r[installer][android-jdk] |{bar}| {progress:.1f}% ({mb_downloaded:.1f}/{mb_total:.1f} MB)", end='', flush=True) + + p = round(mb_downloaded/mb_total, 1) + if p not in count: + count.append(p) + asyncio.create_task(send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "installing", + "comment": f"Downloading JDK 21... {progress:.1f}% ({mb_downloaded:.1f}/{mb_total:.1f} MB)", + } + })) + + print() + print(f"[installer][android-jdk] JDK download complete: {jdk_archive}") + return jdk_archive + except Exception as e: + print(f"\n[installer][android-jdk] JDK download failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": f"JDK download failed: {str(e)}", + } + }) + return None + + +async def _extract_jdk(jdk_archive): + """Extract JDK to the appropriate location""" + if not jdk_archive or not jdk_archive.exists(): + return None + + print("[installer][android-jdk] Extracting JDK...") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "installing", + "comment": "Extracting JDK...", + } + }) + + system = platform.system() + + # Extract to ZEUZ downloads directory + jdk_dir = ZEUZ_NODE_DOWNLOADS_DIR / "jdk" / "jdk-21" + if jdk_dir.exists(): + shutil.rmtree(jdk_dir) + jdk_dir.mkdir(parents=True, exist_ok=True) + + if system == "Windows": + print(f"[installer][android-jdk] Extracting JDK to {jdk_dir}") + elif system == "Linux": + print(f"[installer][android-jdk] Extracting JDK to {jdk_dir}") + elif system == "Darwin": + print(f"[installer][android-jdk] Extracting JDK to {jdk_dir}") + + try: + if system == "Windows": + with zipfile.ZipFile(jdk_archive, 'r') as zip_ref: + zip_ref.extractall(jdk_dir) + elif system == "Linux": + with tarfile.open(jdk_archive, 'r:gz') as tar_ref: + tar_ref.extractall(jdk_dir) + elif system == "Darwin": + with tarfile.open(jdk_archive, 'r:gz') as tar_ref: + tar_ref.extractall(jdk_dir) + else: + raise OSError(f"Unsupported platform: {system}") + + # Find the actual JDK directory (it might be nested) + jdk_home = None + for item in jdk_dir.iterdir(): + if item.is_dir() and "jdk" in item.name.lower(): + jdk_home = item + break + + if not jdk_home: + print("[installer][android-jdk] Could not find JDK directory after extraction") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": "Could not find JDK directory after extraction.", + } + }) + return None + + print(f"[installer][android-jdk] JDK extracted to {jdk_home}") + return jdk_home + except Exception as e: + print(f"[installer][android-jdk] JDK extraction failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": f"JDK extraction failed: {str(e)}", + } + }) + return None + + +async def _set_java_env_vars(jdk_home): + """Set JAVA_HOME and add Java to PATH""" + if not jdk_home or not jdk_home.exists(): + return False + + print("[installer][android-jdk] Setting Java environment variables...") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "installing", + "comment": "Setting Java environment variables...", + } + }) + + system = platform.system() + + if system == "Windows": + try: + import winreg + # Set JAVA_HOME in user environment variables + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Environment", + 0, winreg.KEY_ALL_ACCESS) as key: + winreg.SetValueEx(key, "JAVA_HOME", 0, winreg.REG_EXPAND_SZ, str(jdk_home)) + print("[installer][android-jdk] JAVA_HOME set in Windows user environment") + + # Update PATH in user environment variables + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Environment", + 0, winreg.KEY_ALL_ACCESS) as key: + try: + current_path, _ = winreg.QueryValueEx(key, "Path") + except FileNotFoundError: + # Path doesn't exist in user environment, create it + current_path = "" + + path_parts = current_path.split(";") if current_path else [] + + java_bin = str(jdk_home / "bin") + if java_bin not in path_parts: + path_parts.append(java_bin) + new_path = ";".join(path_parts) + winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, new_path) + print("[installer][android-jdk] Java added to PATH in Windows user environment") + + # CRITICAL: Update current process environment so subprocess can find Java immediately + os.environ['JAVA_HOME'] = str(jdk_home) + current_process_path = os.environ.get('PATH', '') + java_bin = str(jdk_home / "bin") + if java_bin not in current_process_path: + os.environ['PATH'] = f"{java_bin};{current_process_path}" + print("[installer][android-jdk] Java added to current process PATH") + except Exception as e: + print(f"[installer][android-jdk] Failed to update Windows user environment: {e}") + return False + + elif system == "Linux": + # Linux - determine if system-wide or user installation + is_system_wide = str(jdk_home).startswith('/opt/') + + # Use current user's home directory + user_home = Path.home() + print("[installer][android-jdk] Setting Java environment variables for current user") + + if is_system_wide: + print("[installer][android-jdk] System-wide Java installation detected") + else: + print("[installer][android-jdk] User-specific Java installation detected") + + shell_configs = [ + user_home / ".bashrc", + user_home / ".zshrc", + user_home / ".profile" + ] + + export_lines = [ + f"export JAVA_HOME={jdk_home}", + f"export PATH=$JAVA_HOME/bin:$PATH" + ] + + # Set environment variables in current session + os.environ['JAVA_HOME'] = str(jdk_home) + current_path = os.environ.get('PATH', '') + java_bin_path = str(jdk_home / "bin") + if java_bin_path not in current_path: + os.environ['PATH'] = f"{java_bin_path}:{current_path}" + + updated = False + for config_file in shell_configs: + if config_file.exists(): + try: + with open(config_file, 'r+') as f: + content = f.read() + needs_update = any(export not in content for export in export_lines) + + if needs_update: + f.write("\n# Java environment variables\n" + "\n".join(export_lines) + "\n") + print(f"[installer][android-jdk] Updated {config_file} with Java paths") + updated = True + except Exception as e: + print(f"[installer][android-jdk] Failed to update {config_file}: {e}") + + if updated: + print("[!] Please restart your terminal or run 'source ~/.bashrc' (or your shell config)") + + elif system == "Darwin": + # macOS - user installation + user_home = Path.home() + print("[installer][android-jdk] Setting Java environment variables for current user") + + shell_configs = [ + user_home / ".bash_profile", + user_home / ".zshrc", + user_home / ".profile" + ] + + export_lines = [ + f"export JAVA_HOME={jdk_home}", + f"export PATH=$JAVA_HOME/bin:$PATH" + ] + + # Set environment variables in current session + os.environ['JAVA_HOME'] = str(jdk_home) + current_path = os.environ.get('PATH', '') + java_bin_path = str(jdk_home / "bin") + if java_bin_path not in current_path: + os.environ['PATH'] = f"{java_bin_path}:{current_path}" + + updated = False + for config_file in shell_configs: + if config_file.exists(): + try: + with open(config_file, 'r+') as f: + content = f.read() + needs_update = any(export not in content for export in export_lines) + + if needs_update: + f.write("\n# Java environment variables\n" + "\n".join(export_lines) + "\n") + print(f"[installer][android-jdk] Updated {config_file} with Java paths") + updated = True + except Exception as e: + print(f"[installer][android-jdk] Failed to update {config_file}: {e}") + + if updated: + print("[!] Please restart your terminal or run 'source ~/.zshrc' (or your shell config)") + else: + print(f"[installer][android-jdk] Unsupported platform: {system}") + return False + + return True + + +async def _verify_java_installation(jdk_home): + """Verify that Java is properly installed and working""" + print("[installer][android-jdk] Verifying Java installation...") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "installing", + "comment": "Verifying Java installation...", + } + }) + + system = platform.system() + + # Check if java executable exists + if system == "Windows": + java_exe = jdk_home / "bin" / "java.exe" + elif system == "Linux": + java_exe = jdk_home / "bin" / "java" + elif system == "Darwin": + java_exe = jdk_home / "bin" / "java" + else: + print(f"[installer][android-jdk] Unsupported platform: {system}") + return False + + if not java_exe.exists(): + print(f"[installer][android-jdk] Java executable not found at {java_exe}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": f"Java executable not found at {java_exe}", + } + }) + return False + + # Make executable on Linux and macOS + if system == "Linux": + try: + java_exe.chmod(java_exe.stat().st_mode | stat.S_IEXEC) + except Exception as e: + print(f"[installer][android-jdk] Failed to make Java executable: {e}") + return False + elif system == "Darwin": + try: + java_exe.chmod(java_exe.stat().st_mode | stat.S_IEXEC) + except Exception as e: + print(f"[installer][android-jdk] Failed to make Java executable: {e}") + return False + + # Test Java version (async) + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + [str(java_exe), "-version"], + capture_output=True, + text=True + ) + ) + if "version \"21" not in result.stderr: + print("[installer][android-jdk] Java version check failed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": "Java version check failed.", + } + }) + return False + print("[installer][android-jdk] Java version verified") + + # Test Java compiler + if system == "Windows": + javac_exe = jdk_home / "bin" / "javac.exe" + elif system == "Linux": + javac_exe = jdk_home / "bin" / "javac" + elif system == "Darwin": + javac_exe = jdk_home / "bin" / "javac" + + if not javac_exe.exists(): + print(f"[installer][android-jdk] Java compiler not found at {javac_exe}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": f"Java compiler not found at {javac_exe}", + } + }) + return False + + if system == "Linux": + javac_exe.chmod(javac_exe.stat().st_mode | stat.S_IEXEC) + elif system == "Darwin": + javac_exe.chmod(javac_exe.stat().st_mode | stat.S_IEXEC) + + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + [str(javac_exe), "-version"], + capture_output=True, + text=True + ) + ) + if "javac 21" not in (result.stdout or result.stderr): + print("[installer][android-jdk] Java compiler version check failed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": "Java compiler version check failed.", + } + }) + return False + print("[installer][android-jdk] Java compiler verified") + + return True + except Exception as e: + print(f"[installer][android-jdk] Java verification failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": f"Java verification failed: {str(e)}", + } + }) + return False + + +async def check_status() -> bool: + """Check if JDK 21 is installed.""" + print("[installer][android-jdk] Checking status...") + + # Dynamically refresh JAVA_HOME and PATH from registry on Windows + system = platform.system() + if system == "Windows": + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment", 0, winreg.KEY_READ) as key: + try: + java_home_reg, _ = winreg.QueryValueEx(key, "JAVA_HOME") + if java_home_reg and os.path.exists(java_home_reg): + os.environ['JAVA_HOME'] = java_home_reg + # Update PATH with Java bin + java_bin = os.path.join(java_home_reg, "bin") + current_path = os.environ.get('PATH', '') + if java_bin not in current_path: + os.environ['PATH'] = f"{java_bin};{current_path}" + print(f"[installer][android-jdk] Refreshed JAVA_HOME from registry: {java_home_reg}") + except FileNotFoundError: + pass + except Exception as e: + print(f"[installer][android-jdk] Failed to refresh from registry: {e}") + + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: subprocess.run( + ["javac", "-version"], + capture_output=True, + text=True, + check=False + ) + ) + + if result.returncode != 0: + print("[installer][android-jdk] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": "Install JDK 21 to use it.", + } + }) + return False + + # javac -version prints to stderr typically + version_text = (result.stderr or result.stdout).strip() + if not version_text: + print("[installer][android-jdk] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": "Install JDK 21 to use it.", + } + }) + return False + + # Extract version number from output like "javac 21.0.1" or "javac 1.8.0_291" + version_match = re.search(r'javac\s+(\d+)\.(\d+)', version_text) + if version_match: + major_version = int(version_match.group(1)) + minor_version = int(version_match.group(2)) + + # Check if it's JDK 21 + if major_version == 21: + print(f"[installer][android-jdk] Already installed (version: {major_version}.{minor_version})") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "installed", + "comment": f"JDK is installed (version: {major_version}.{minor_version})", + } + }) + return True + # Handle old versioning like "1.8.0" where major=1, minor=8 + elif major_version == 1 and minor_version >= 8: + print(f"[installer][android-jdk] Wrong version installed (found: {major_version}.{minor_version})") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": f"Install JDK 21 to use it (found version: {major_version}.{minor_version}).", + } + }) + return False + + print(f"[installer][android-jdk] Not installed (found version: {version_text})") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": f"Install JDK 21 to use it (found version: {version_text[:50]}).", + } + }) + return False + except (FileNotFoundError, OSError): + # javac command not found - JDK is not installed + print("[installer][android-jdk] Not installed (javac not found)") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": "Install JDK 21 to use it.", + } + }) + return False + except Exception as e: + print(f"[installer][android-jdk] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "not installed", + "comment": "Unable to check JDK status.", + } + }) + return False + + + + +async def install() -> bool: + """Main function to setup JDK 21 LTS""" + print("[installer][android-jdk] Installing...") + + # Check if JDK 21 is already installed + if await check_status(): + print("[installer][android-jdk] JDK 21 is already installed") + return True + + jdk_home = None + + # If JDK is not installed, download and install it + if not jdk_home: + # Download and extract JDK + jdk_archive = await _download_jdk() + if not jdk_archive: + return False + + jdk_home = await _extract_jdk(jdk_archive) + if not jdk_home: + return False + + # Clean up archive + try: + jdk_archive.unlink() + except: + pass + + # Verify installation + if not await _verify_java_installation(jdk_home): + print("[installer][android-jdk] Java installation verification failed") + return False + + # Set environment variables + if not await _set_java_env_vars(jdk_home): + return False + + print("[installer][android-jdk] JDK 21 LTS setup complete") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "JDK", + "status": "installed", + "comment": f"JDK is installed at {jdk_home}", + } + }) + return True \ No newline at end of file diff --git a/Framework/install_handler/android/node_js_22.py b/Framework/install_handler/android/node_js_22.py index d99a3f6d0..209c3d861 100644 --- a/Framework/install_handler/android/node_js_22.py +++ b/Framework/install_handler/android/node_js_22.py @@ -1,7 +1,77 @@ -async def check_status(): - print("[node_js_22] Checking status...") +import subprocess +import platform +from Framework.install_handler.utils import send_response +from Framework.nodejs_appium_installer import get_node_dir, check_installations + + +async def check_status() -> bool: + """Check if Node.js 22 is installed.""" + print("[installer][android-nodejs22] Checking status...") + + try: + # Use the check function from nodejs_appium_installer + node_installed, appium_installed, missing_drivers = check_installations() + + if node_installed: + # Get the installation location + node_dir = get_node_dir() + node_location = str(node_dir.resolve()) + + # Get version for display + node_bin = node_dir / ("node.exe" if platform.system() == "Windows" else "bin/node") + try: + result = subprocess.run( + [str(node_bin), "--version"], + capture_output=True, + text=True, + check=False + ) + version_text = (result.stdout or result.stderr or "").strip() + version_info = f" (version: {version_text})" if version_text else "" + except: + version_info = "" + + print(f"[installer][android-nodejs22] Already installed at {node_location}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Node js 22", + "status": "installed", + "comment": f"Node.js is installed at {node_location}{version_info}", + } + }) + return True + else: + print("[installer][android-nodejs22] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Node js 22", + "status": "not installed", + "comment": "Run the ZeuZ Node, it will automatically install Node.js", + } + }) + return False + except Exception as e: + print(f"[installer][android-nodejs22] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Android", + "name": "Node js 22", + "status": "not installed", + "comment": "Run the ZeuZ Node, it will automatically install Node.js", + } + }) + return False + + async def install(): - print("[node_js_22] Installing...") + print("[node_js_22] Installing...") + + diff --git a/Framework/install_handler/database/mariadb.py b/Framework/install_handler/database/mariadb.py index 7cf477661..a377318cc 100644 --- a/Framework/install_handler/database/mariadb.py +++ b/Framework/install_handler/database/mariadb.py @@ -1,7 +1,102 @@ +from Framework.install_handler.utils import send_response +from Framework.install_handler.installer_tools import InstallerTools + +tools = InstallerTools() + async def check_status(): - print("[mariadb] Checking status...") + """Checks if mariadb Python library is installed.""" + + if await tools.check_python_module_available("mariadb"): + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MariaDB", + "status": "installed", + "comment": "MariaDB connector is installed.", + } + }) + return True + else: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MariaDB", + "status": "not installed", + "comment": "Install mariadb to connect to MariaDB databases.", + } + }) + return False + + + +async def install(user_password: str = ""): + is_already_installed = await check_status() + + if not is_already_installed: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MariaDB", + "status": "installing", + "comment": "Downloading and installing, please wait...", + } + }) + # MariaDB dependencies installation required if on Linux (sudo password required) + if tools.os_name == 'Linux': + install_libmariadb, msg = await tools.install_linux_packages( + packages=['libmariadb3', 'libmariadb-dev'], + password=user_password + ) + else: + # If not Linux, bypass dependency installation as it's not required + install_libmariadb, msg = True, "" -async def install(): - print("[mariadb] Installing...") + # If dependency installation was successful (or bypassed) + if install_libmariadb: + # Install Python MariaDB connector + install_mariadb, msg = await tools.add_python_package('mariadb') + if install_mariadb: + # If MariaDB connector installation is successful + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MariaDB", + "status": "installed", + "comment": "MariaDB connector has been installed successfully.", + } + }) + return True + else: + # If MariaDB connector installation failed + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MariaDB", + "status": "error", + "comment": msg, + } + }) + return False + else: + # If MariaDB dependency installation failed + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MariaDB", + "status": "error", + "comment": msg, + } + }) + return False + + else: + # If already installed, bypass entire installation procedure + return True diff --git a/Framework/install_handler/database/mysql.py b/Framework/install_handler/database/mysql.py index edea40c7c..e59032ffd 100644 --- a/Framework/install_handler/database/mysql.py +++ b/Framework/install_handler/database/mysql.py @@ -1,7 +1,73 @@ +from Framework.install_handler.utils import send_response +from Framework.install_handler.installer_tools import InstallerTools + +tools = InstallerTools() + async def check_status(): - print("[mysql] Checking status...") + """Checks if mysql-connector-python is installed.""" + + if await tools.check_python_module_available("mysql.connector"): + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MySQL", + "status": "installed", + "comment": "MySQL connector is installed.", + } + }) + return True + else: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MySQL", + "status": "not installed", + "comment": "Install mysql-connector-python to connect to MySQL databases.", + } + }) + return False + async def install(): - print("[mysql] Installing...") + is_already_installed = await check_status() + + if not is_already_installed: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MySQL", + "status": "installing", + "comment": "Downloading and installing, please wait...", + } + }) + install_mysql, msg = await tools.add_python_package('mysql-connector-python') + + if install_mysql: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MySQL", + "status": "installed", + "comment": "MySQL connector has been installed successfully.", + } + }) + return True + else: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "MySQL", + "status": "error", + "comment": msg, + } + }) + return False + else: + return True diff --git a/Framework/install_handler/database/oracle.py b/Framework/install_handler/database/oracle.py index 9a85ccd7a..70c63d56e 100644 --- a/Framework/install_handler/database/oracle.py +++ b/Framework/install_handler/database/oracle.py @@ -1,7 +1,80 @@ +from Framework.install_handler.utils import send_response +from Framework.install_handler.installer_tools import InstallerTools + +tools = InstallerTools() + async def check_status(): - print("[oracle] Checking status...") + """Checks if oracledb Python library is installed.""" + + if await tools.check_python_module_available("oracledb"): + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "Oracle", + "status": "installed", + "comment": "Oracle connector is installed.", + } + }) + return True + else: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "Oracle", + "status": "not installed", + "comment": "Install oracledb to connect to Oracle databases.", + } + }) + return False + async def install(): - print("[oracle] Installing...") + is_already_installed = await check_status() + + if not is_already_installed: + + module_name = 'oracledb' + + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "Oracle", + "status": "installing", + "comment": "Downloading and installing, please wait...", + } + }) + + # NOTE: cx_Oracle is deprecated and gives install error on Windows + if module_name == "cx_Oracle": + tools.logger.warning("cx_Oracle is deprecated, recommended to use oracledb instead.") + + install_oracle, msg = await tools.add_python_package(module_name) + if install_oracle: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "Oracle", + "status": "installed", + "comment": "Oracle connector has been installed successfully.", + } + }) + return True + else: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "Oracle", + "status": "error", + "comment": msg, + } + }) + return False + else: + return True diff --git a/Framework/install_handler/database/postgresql.py b/Framework/install_handler/database/postgresql.py index ce52e6c22..fdd262c8f 100644 --- a/Framework/install_handler/database/postgresql.py +++ b/Framework/install_handler/database/postgresql.py @@ -1,7 +1,77 @@ +from Framework.install_handler.utils import send_response +from Framework.install_handler.installer_tools import InstallerTools + +tools = InstallerTools() + async def check_status(): - print("[postgresql] Checking status...") + """Checks whether any of psycopg or psycopg2 is installed.""" + + is_psycopg_installed = await tools.check_python_module_available("psycopg") + is_psycopg2_installed = await tools.check_python_module_available("psycopg2") + + if is_psycopg_installed or is_psycopg2_installed: + psycopg_version = "psycopg" if is_psycopg_installed else "psycopg2" + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "PostgreSQL", + "status": "installed", + "comment": f"PostgreSQL connector ({psycopg_version}) is installed.", + } + }) + return True + else: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "PostgreSQL", + "status": "not installed", + "comment": "Install psycopg to connect to PostgreSQL databases.", + } + }) + return False + async def install(): - print("[postgresql] Installing...") + is_already_installed = await check_status() + + if not is_already_installed: + print("[database][postgresql] Installing...") + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "PostgreSQL", + "status": "installing", + "comment": "Downloading and installing, please wait...", + } + }) + install_psycopg, msg = await tools.add_python_package('psycopg') + if install_psycopg: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "PostgreSQL", + "status": "installed", + "comment": "PostgreSQL connector (psycopg) has been installed successfully.", + } + }) + return True + else: + await send_response({ + "action": "status", + "data": { + "category": "Database", + "name": "PostgreSQL", + "status": "error", + "comment": msg, + } + }) + return False + else: + return True diff --git a/Framework/install_handler/installer_tools.py b/Framework/install_handler/installer_tools.py new file mode 100644 index 000000000..78f1cbc56 --- /dev/null +++ b/Framework/install_handler/installer_tools.py @@ -0,0 +1,215 @@ +import asyncio +import sys +import platform +import shutil +from typing import Tuple, List +import logging +from subprocess import CalledProcessError + + + + +class InstallerTools: + """ + Collection of Python and system package installation-related utility functions + to be used in installation scripts. + """ + def __init__(self) -> None: + + # OS name + self.os_name = platform.system() + + # Setup logger + self.logger = logging.getLogger(__name__) + self.logger.setLevel(logging.DEBUG) + self.formatter = logging.Formatter( + "[node_installer - {levelname}] {asctime} - {funcName} - {message}", + style="{", + datefmt="%Y-%m-%d %H:%M", + ) + if not self.logger.handlers: + self.console_handler = logging.StreamHandler() + self.logger.addHandler(self.console_handler) + self.console_handler.setFormatter(self.formatter) + # self.file_handler = logging.FileHandler("installer_utils.log", mode="a", encoding="utf-8") + # self.logger.addHandler(self.file_handler) + + + async def check_python_module_available(self, module_name: str) -> bool: + """ + Check if a module is available for import + + Args: + module_name (str): The name used to import the package + + Returns: + bool: True if package is available, False otherwise + """ + + try: + __import__(module_name) + self.logger.info(f"{module_name} is available.") + return True + except ImportError: + self.logger.info(f"{module_name} is not available.") + return False + + + async def add_python_package(self, package_name: str) -> Tuple[bool, str]: + """ + Install a package using `uv add` + + Args: + package_name (str): The name of the package to install + + Returns: + bool: True if installation successful, False otherwise + str: Error message if installation failed, otherwise empty string + """ + + # Installation command + cmd = [sys.executable, "-m", "uv", "add", package_name] + self.logger.debug(f"{cmd = }") + message = "" + + try: + self.logger.info(f"Installing {package_name}") + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + # Wait for the process to complete and capture output + stdout, stderr = await process.communicate() + + if process.returncode != 0: + raise CalledProcessError( + process.returncode, + cmd, + output=stdout, + stderr=stderr + ) + self.logger.info(f"Successfully installed {package_name}.") + + if stdout: + self.logger.debug(f"[stdout]\n{stdout.decode()}") + return True, message + + except CalledProcessError as e: + self.logger.exception(f"Failed to install {package_name}") + if e.stderr: + message = e.stderr.decode() + self.logger.debug(f"[stderr]\n{message}") + return False, message + + except FileNotFoundError: + message = "Error: 'uv' command not found or Python interpreter path is incorrect." + self.logger.exception(f"{message}") + return False, message + + + async def install_linux_packages(self, packages: List, password: str = ""): + """ + Install Linux packages using apt. + + Args: + packages (list): List of package names to install + password (str, optional): Sudo password if required + + Returns: + tuple: (bool, str) - (success_status, error_message) + """ + self.logger.debug(f"{packages=}") + + if not packages: + self.logger.warning("Empty package list received, no packages installed.") + return True, "No packages to install" + + try: + # Installation command + package_manager = self._get_linux_package_manager() + # -p '' to suppress the "[sudo] password for user:" prompt from stdout + cmd = ["sudo", "-S", "-p", "", package_manager, "install", "-y"] + packages + + # Prepare the password input. newline to simulate the user pressing 'Enter'. + if password: + sudo_input = password + "\n" + else: + self.logger.warning("No password received. Attempting passwordless sudo...") + sudo_input = "\n" + + self.logger.info(f"Installing packages...") + self.logger.debug(f"{cmd = }") + + process = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate(input=sudo_input.encode()) + + # Decode output + stdout_str = stdout.decode('utf-8') if stdout else "" + stderr_str = stderr.decode('utf-8') if stderr else "" + + # Check if installation was successful + if process.returncode == 0: + self.logger.info(f"Successfully installed: {', '.join(packages)}") + return True, "" + else: + error_msg = f"{stderr_str}\n{stdout_str}".strip() + self.logger.error(f"Installation failed: {error_msg}") + return False, error_msg + + except FileNotFoundError: + error_msg = f"Error: sudo or {package_manager} command not found." + self.logger.exception(error_msg) + return False, error_msg + + except Exception as e: + self.logger.exception("Unexpected exception") + return False, f"Unexpected error: {str(e)}" + + + class ValidationError(Exception): + """Input validation exception""" + pass + + + def _sanitize_packages_list(self, packages: List) -> List: + """Raises ValidationError exception if a ';' is detected in the packages list""" + packages_str = " ".join(packages) + self.logger.debug(f"{packages_str = }") + if ";" in packages_str: + raise self.ValidationError("Command chaining with ';' is not allowed.") + else: + return packages + + + def _get_linux_package_manager(self) -> str: + """Detect Linux package manager""" + + if self.os_name == 'Linux': + if shutil.which("apt-get"): + return "apt-get" + elif shutil.which("dnf"): + return "dnf" + elif shutil.which("yum"): + return "yum" + elif shutil.which("pacman"): + return "pacman" + elif shutil.which("zypper"): + return "zypper" + elif shutil.which("apk"): + return "apk" + elif shutil.which("emerge"): + return "emerge" + elif shutil.which("nix"): + return "nix" + + self.logger.warning("Checking for Linux package manager on non-Linux platform") + return "" + \ No newline at end of file diff --git a/Framework/install_handler/ios/simulator.py b/Framework/install_handler/ios/simulator.py new file mode 100644 index 000000000..f3b5164f9 --- /dev/null +++ b/Framework/install_handler/ios/simulator.py @@ -0,0 +1,1109 @@ +import asyncio +import platform +import os +import shutil +import subprocess +import json +import re +import tempfile +from pathlib import Path +from Framework.install_handler.utils import send_response + +async def _send_status(status: str, comment: str): + """Helper to send status responses.""" + await send_response( + { + "action": "status", + "data": { + "category": "iOS", + "name": "Simulator", + "status": status, + "comment": comment, + }, + } + ) + +async def _send_status_emulator(udid: str, status: str, comment: str): + """Helper to send status responses for emulator category.""" + await send_response( + { + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": status, + "comment": comment, + }, + } + ) + +async def _create_default_device() -> bool: + """ + Attempts to create a default iPhone simulator using the newest available + iOS runtime and the newest available iPhone device type. + """ + await _send_status("installing", "No devices found. Creating a new default simulator...") + + try: + # 1. Get available Runtimes (JSON is safer to parse) + # We need to find the installed iOS runtime ID (e.g. com.apple.CoreSimulator.SimRuntime.iOS-18-0) + runtime_proc = subprocess.run( + ["xcrun", "simctl", "list", "runtimes", "-j"], + capture_output=True, + text=True + ) + if runtime_proc.returncode != 0: + raise Exception("Failed to list runtimes.") + + runtimes_data = json.loads(runtime_proc.stdout) + available_runtimes = runtimes_data.get("runtimes", []) + + # Filter for iOS runtimes and sort to find the latest version + ios_runtimes = [ + r for r in available_runtimes + if r.get("platform") == "iOS" or "iOS" in r.get("name", "") + ] + + if not ios_runtimes: + raise Exception("No iOS Runtimes found after installation.") + + # Sort by version (assuming name contains version or using buildversion) + # We'll just pick the first one which is usually the latest in the list, + # or we can try to sort by the 'version' key if available. + target_runtime = ios_runtimes[-1] # Usually the list is ordered, taking the last one is a safe bet for 'newest' + runtime_id = target_runtime["identifier"] + runtime_name = target_runtime["name"] + + # 2. Get available Device Types + type_proc = subprocess.run( + ["xcrun", "simctl", "list", "devicetypes", "-j"], + capture_output=True, + text=True + ) + types_data = json.loads(type_proc.stdout) + + # Find a modern iPhone (e.g., iPhone 16, 15, or just the last "iPhone" in the list) + device_types = [ + t for t in types_data.get("devicetypes", []) + if "iPhone" in t.get("name", "") + ] + + if not device_types: + raise Exception("No iPhone device types found.") + + # Pick the last one (usually the newest model) + target_device_type = device_types[-1] + device_type_id = target_device_type["identifier"] + device_name = target_device_type["name"] + + # 3. Create the device + new_device_name = f"{device_name} (Default)" + await _send_status("installing", f"Creating {new_device_name} with {runtime_name}...") + + create_proc = subprocess.run( + ["xcrun", "simctl", "create", new_device_name, device_type_id, runtime_id], + capture_output=True, + text=True + ) + + if create_proc.returncode == 0: + new_udid = create_proc.stdout.strip() + await _send_status("installed", f"Created new device: {new_device_name} ({new_udid})") + return True + else: + raise Exception(f"Failed to create device: {create_proc.stderr}") + + except Exception as e: + await _send_status("error", f"Auto-creation of device failed: {e}") + return False + +async def check_status() -> bool: + """Check if iOS Simulator is installed and available.""" + print("[simulator] Checking status...") + + if platform.system().lower() != "darwin": + await _send_status("error", "Unsupported OS. iOS Simulator is only available on macOS.") + return False + + if not os.path.exists("/Applications/Xcode.app"): + await _send_status("not installed", "Xcode must be installed before using iOS Simulator.") + return False + + if not shutil.which("xcrun"): + await _send_status("not installed", "xcrun not found. Xcode command line tools missing.") + return False + + try: + # Check for available devices + result = subprocess.run( + ["xcrun", "simctl", "list", "devices", "available", "iOS"], + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + await _send_status("error", f"simctl error: {result.stderr.strip()}") + return False + + output = result.stdout + # Basic check: verify lines with UDIDs exist + device_lines = [line for line in output.splitlines() if "(" in line and ")" in line] + + if len(device_lines) > 0: + await _send_status("installed", f"iOS Simulator available with {len(device_lines)} devices.") + return True + else: + await _send_status("not installed", "No iOS Simulator devices found.") + return False + + except Exception as e: + await _send_status("error", f"Error checking iOS Simulator: {e}") + return False + +async def _install_command_line_tools() -> bool: + """Install Xcode command line tools if not present.""" + try: + result = subprocess.run(["xcode-select", "-p"], capture_output=True, text=True) + if result.returncode == 0: + return True + + await _send_status("installing", "Installing Xcode command line tools...") + subprocess.run(["xcode-select", "--install"], capture_output=True, text=True) + + # Poll for completion (simplified for brevity) + for _ in range(60): # wait up to 10 mins + await asyncio.sleep(10) + if subprocess.run(["xcode-select", "-p"], capture_output=True).returncode == 0: + return True + + await _send_status("error", "Timed out waiting for command line tools.") + return False + except Exception as e: + await _send_status("error", f"Error installing tools: {e}") + return False + +async def _install_simulator_runtime(user_password: str) -> bool: + """Install iOS Simulator runtime if missing.""" + try: + # Ensure xcode-select is pointing to Xcode app + cmd = ["xcode-select", "--switch", "/Applications/Xcode.app/Contents/Developer"] + if user_password: + # Use sudo -S for password piping if provided + subprocess.run( + f"echo '{user_password}' | sudo -S {' '.join(cmd)}", + shell=True, capture_output=True + ) + else: + subprocess.run(cmd, capture_output=True) + + # Check existing runtimes + await _send_status("installing", "Checking iOS Simulator runtimes...") + + # Download iOS platform using xcodebuild (Your provided block) + await _send_status("installing", "Downloading iOS Simulator runtime (this may take a while)...") + + with subprocess.Popen( + ["xcodebuild", "-downloadPlatform", "iOS"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) as process: + for line in process.stdout: + await _send_status("installing", line.strip()) + + if process.returncode != 0: + # Only fail if it's a real error. Sometimes it fails if already installed. + # We proceed to verification regardless. + pass + + await asyncio.sleep(5) + + # Verify installation + verify_result = subprocess.run( + ["xcrun", "simctl", "list", "devices", "available", "iOS"], + capture_output=True, + text=True, + timeout=30, + ) + + device_count = 0 + if verify_result.returncode == 0: + device_lines = [line for line in verify_result.stdout.splitlines() if "(" in line and ")" in line] + device_count = len(device_lines) + + # LOGIC CHANGE: If installed but no devices, create one. + if device_count > 0: + await _send_status("installed", f"iOS Simulator runtime ready with {device_count} devices.") + return True + else: + # Runtime might be there, but no devices created yet. + # Attempt to create a default device. + return await _create_default_device() + + except Exception as e: + await _send_status("error", f"Error installing simulator runtime: {e}") + return False + +async def get_available_simulators() -> list[dict]: + """ + List available iOS Simulators by running xcrun simctl list devices. + Returns a list of dictionaries with name, udid, and state fields. + """ + try: + if platform.system().lower() != "darwin": + return [] + + if not shutil.which("xcrun"): + return [] + + result = subprocess.run( + ["xcrun", "simctl", "list", "devices", "available", "-j"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + return [] + + data = json.loads(result.stdout) + simulators = [] + + for runtime_key, devices in data.get("devices", {}).items(): + if "iOS" not in runtime_key: + continue + + # Extract iOS version from runtime key (e.g., "com.apple.CoreSimulator.SimRuntime.iOS-18-2" -> "iOS 18.2") + version_match = re.search(r'iOS-(\d+)-(\d+)', runtime_key) + runtime_version = f"iOS {version_match.group(1)}.{version_match.group(2)}" if version_match else runtime_key + + for device in devices: + if device.get("isAvailable"): + simulators.append({ + "name": device.get("name"), + "udid": device.get("udid"), + "state": device.get("state"), + "runtime": runtime_version, + "comment": f"{device.get('name')} - {runtime_version} ({device.get('state')})" + }) + + return simulators + + except Exception as e: + print(f"[simulator] Error listing simulators: {e}") + return [] + + +async def get_available_device_types() -> list[dict]: + """ + Get available iOS device types by running xcrun simctl list devicetypes. + Returns a list of dictionaries with package (device type ID), version (device name), and description. + Format matches Android's device list for consistency. + """ + try: + if platform.system().lower() != "darwin": + return [] + + if not shutil.which("xcrun"): + return [] + + result = subprocess.run( + ["xcrun", "simctl", "list", "devicetypes", "-j"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + return [] + + data = json.loads(result.stdout) + device_types = [] + + for device_type in data.get("devicetypes", []): + name = device_type.get("name", "") + identifier = device_type.get("identifier", "") + + # Filter for iPhone and iPad devices only + if "iPhone" in name or "iPad" in name: + device_types.append({ + "package": identifier, # device type ID + "version": name, # device name + "description": "Apple" # OEM + }) + + # Remove device types that already have a simulator created (don't show installed ones) + try: + simulators = await get_available_simulators() + installed_names = set() + for sim in simulators: + sim_name = sim.get("name", "") + # Normalize by removing trailing parentheses (eg. "iPhone 16 (1)", "iPhone 16 (Default)") + base_name = re.sub(r"\s*\(.*\)$", "", sim_name).strip() + if base_name: + installed_names.add(base_name) + + # Compare using case-insensitive matching for robustness + installed_names_lower = {n.lower() for n in installed_names} + filtered_device_types = [dt for dt in device_types if dt.get("version", "").strip().lower() not in installed_names_lower] + device_types = filtered_device_types + except Exception: + # If any error happens while checking simulators, fall back to showing all device types + pass + + return device_types + + except Exception as e: + print(f"[simulator] Error listing device types: {e}") + return [] + + +async def get_available_runtimes() -> list[dict]: + """ + Get available iOS runtimes by running xcrun simctl list runtimes. + Returns a list of dictionaries with runtime ID, version, and availability. + """ + try: + if platform.system().lower() != "darwin": + return [] + + if not shutil.which("xcrun"): + return [] + + result = subprocess.run( + ["xcrun", "simctl", "list", "runtimes", "-j"], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + return [] + + data = json.loads(result.stdout) + runtimes = [] + + for runtime in data.get("runtimes", []): + platform_name = runtime.get("platform", "") + name = runtime.get("name", "") + + # Filter for iOS runtimes only + if platform_name == "iOS" or "iOS" in name: + runtimes.append({ + "identifier": runtime.get("identifier", ""), + "name": name, + "version": runtime.get("version", ""), + "isAvailable": runtime.get("isAvailable", False) + }) + + return runtimes + + except Exception as e: + print(f"[simulator] Error listing runtimes: {e}") + return [] + + +async def check_simulator_list(): + """ + Sends response to server with list of installed simulators for iOS Simulator. + """ + simulator_list = await get_filtered_simulator_services() + if simulator_list: + await send_response( + { + "action": "services_update", + "data": { + 'category': 'iOSSimulator', + "services": simulator_list['services'], + }, + } + ) + return True + return False + +async def ios_simulator_install(): + """ + Get available device types when install button is clicked. + Returns list of available iOS device types for simulator creation. + """ + print("[simulator] Getting available device types...") + + try: + # Check if macOS + if platform.system().lower() != "darwin": + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": "Device Types", + "status": "Not Found", + "comment": "iOS Simulator is only available on macOS", + "installables": [] + } + }) + return False + + # Check if Xcode is installed + if not os.path.exists("/Applications/Xcode.app"): + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": "Device Types", + "status": "Not Found", + "comment": "Xcode must be installed first", + "installables": [] + } + }) + return False + + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": "Device Types", + "status": "Fetching", + "comment": "Fetching available device types...", + "installables": [] + } + }) + + # Get available device types + device_types = await get_available_device_types() + print(f"[simulator] Available device types: {device_types}") + + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "installables": device_types, + } + }) + return True + + except Exception as e: + print(f"[simulator] Error getting device types: {e}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": "Device Types", + "status": "error", + "comment": f"Error: {e}", + "installables": [] + } + }) + return False + + +async def get_filtered_simulator_services(): + """ + Get available iOS simulators and return a formatted category dictionary. + Similar to Android's get_filtered_avd_services. + + Returns: + Dictionary with category "iOSSimulator" and filtered services, or None if no simulators + """ + current_os = platform.system().lower() + + # iOS simulators only available on macOS + if current_os != "darwin": + return None + + try: + simulators = await get_available_simulators() + + if not simulators: + return None + + # Format simulators as services + services_list = [] + for sim in simulators: + services_list.append({ + "name": sim["udid"], # Use UDID as the identifier + "status": "installed", + "comment": sim["comment"], + "install_text": "delete", + "install_function": "delete_simulator", + "check_text": "Launch", + "os": ["darwin"], + "user_password": "no", + }) + + return { + "group": { + "check_text": "", + "install_text": "", + }, + "category": "iOSSimulator", + "services": services_list, + } + + except Exception as e: + print(f"[simulator] Error getting filtered simulators: {e}") + return None + + +async def delete_simulator(udid: str) -> bool: + """ + Delete iOS Simulator by UDID using simctl delete. + Sends response to server on success or failure. + + Args: + udid: The UDID of the simulator to delete + + Returns: + bool: True if successful, False otherwise + """ + try: + # Get simulator info first to show proper name in messages + simulators = await get_available_simulators() + simulator_info = next((s for s in simulators if s["udid"] == udid), None) + + if not simulator_info: + error_msg = f"Simulator {udid} not found" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "not installed", + "comment": error_msg, + } + }) + return False + + simulator_name = simulator_info["name"] + print(f"[simulator] Deleting simulator: {simulator_name} ({udid})") + + # Send deleting status + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "uninstalling", + "comment": f"Deleting simulator {simulator_name}...", + } + }) + + # Delete the simulator + result = subprocess.run( + ["xcrun", "simctl", "delete", udid], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + error_msg = f"Failed to delete simulator: {result.stderr.strip()}" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "error", + "comment": error_msg, + } + }) + return False + + print(f"[simulator] Simulator deleted successfully: {simulator_name} ({udid})") + + # Send success response + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "not installed", + "comment": f"Simulator '{simulator_name}' deleted successfully", + } + }) + return True + + except subprocess.TimeoutExpired: + error_msg = "Simulator deletion timed out" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "error", + "comment": error_msg, + } + }) + return False + except Exception as e: + error_msg = f"Error deleting simulator: {e}" + print(f"[simulator] {error_msg}") + import traceback + traceback.print_exc() + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "error", + "comment": error_msg, + } + }) + return False + + +async def launch_simulator(udid: str) -> bool: + """ + Launch iOS Simulator using simctl boot and open Simulator app. + Checks for WebDriverAgent installation and installs if needed. + Launches WebDriverAgent app automatically. + Non-blocking - the simulator starts in the background. + Sends response to server on success or failure. + """ + try: + # Get simulator info first + simulators = await get_available_simulators() + simulator_info = next((s for s in simulators if s["udid"] == udid), None) + + if not simulator_info: + error_msg = f"Simulator {udid} not found" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "not installed", + "comment": error_msg, + } + }) + return False + + simulator_name = simulator_info['name'] + is_already_booted = simulator_info["state"] == "Booted" + + # Boot simulator if not already booted + if not is_already_booted: + await _send_status_emulator(udid, "installed", f"Launching simulator {simulator_name}...") + # Open Simulator app + subprocess.Popen( + ["open", "-a", "Simulator"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + + # Small delay to let Simulator app launch + await asyncio.sleep(1) + + # Boot the specific device + result = subprocess.run( + ["xcrun", "simctl", "boot", udid], + capture_output=True, + text=True + ) + + if result.returncode != 0: + error_msg = f"Failed to boot simulator: {result.stderr.strip()}" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "error", + "comment": error_msg, + } + }) + return False + + await _send_status_emulator(udid, "installed", f"Booting simulator: {simulator_name}...") + + # Wait for boot to complete + await asyncio.sleep(3) + else: + await _send_status_emulator(udid, "installed", f"Simulator {simulator_name} already running") + + # Check if WebDriverAgent is installed on this simulator + await _send_status_emulator(udid, "installed", f"Checking WebDriverAgent installation on {simulator_name}...") + check_wda = subprocess.run( + ["xcrun", "simctl", "get_app_container", udid, "com.facebook.WebDriverAgentRunner.xctrunner"], + capture_output=True, + text=True + ) + + wda_installed = check_wda.returncode == 0 and check_wda.stdout.strip() + + if not wda_installed: + await _send_status_emulator(udid, "installing", f"WebDriverAgent not found on {simulator_name}, installing...") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "installing", + "comment": f"Installing WebDriverAgent on {simulator_name}...", + } + }) + + # Get WebDriverAgent path (same as webdriver.py uses) + home = Path.home() + webdriver_path = home / ".zeuz" / "WebDriverAgent" + + # Check if WebDriverAgent repo exists + if not (webdriver_path / "WebDriverAgent.xcodeproj").exists(): + await _send_status_emulator(udid, "installing", "Cloning WebDriverAgent repository...") + if webdriver_path.exists(): + shutil.rmtree(webdriver_path) + webdriver_path.parent.mkdir(parents=True, exist_ok=True) + + clone_result = subprocess.run( + ["git", "clone", "--depth", "1", "https://github.com/appium/WebDriverAgent.git", str(webdriver_path)], + capture_output=True, + text=True + ) + + if clone_result.returncode != 0: + error_msg = f"Failed to clone WebDriverAgent: {clone_result.stderr}" + await _send_status_emulator(udid, "installed", error_msg) + # Continue with launching simulator even if WDA install fails + + # Build and install WebDriverAgent for this simulator + if (webdriver_path / "WebDriverAgent.xcodeproj").exists(): + # Check if app is already built (in standard location) + standard_build_path = webdriver_path / "Build" / "Products" / "Debug-iphonesimulator" / "WebDriverAgentRunner-Runner.app" + app_path_to_install = None + + if standard_build_path.exists(): + await _send_status_emulator(udid, "installed", f"Found pre-built WebDriverAgent at {standard_build_path}") + app_path_to_install = standard_build_path + else: + # Need to build + await _send_status_emulator(udid, "installing", f"Building WebDriverAgent for {simulator_name}...") + + with tempfile.TemporaryDirectory() as derived_data_path: + build_cmd = [ + "xcodebuild", + "-project", "WebDriverAgent.xcodeproj", + "-scheme", "WebDriverAgentRunner", + "-destination", f"platform=iOS Simulator,id={udid}", + "-derivedDataPath", derived_data_path, + "-allowProvisioningUpdates", + "build-for-testing" + ] + + build_result = subprocess.run( + build_cmd, + cwd=str(webdriver_path), + capture_output=True, + text=True, + timeout=1200 + ) + + if build_result.returncode == 0: + app_path = Path(derived_data_path) / "Build" / "Products" / "Debug-iphonesimulator" / "WebDriverAgentRunner-Runner.app" + + if app_path.exists(): + # Copy to standard location before temp directory is deleted + standard_build_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(app_path, standard_build_path, dirs_exist_ok=True) + print(f"[simulator] Copied built app to {standard_build_path}") + app_path_to_install = standard_build_path + else: + await _send_status_emulator(udid, "installed", f"WebDriverAgent app not found at {app_path}") + else: + await _send_status_emulator(udid, "installed", f"WebDriverAgent build failed: {build_result.stderr[-500:]}") + + # Install the app if we have it + if app_path_to_install: + await _send_status_emulator(udid, "installing", f"Installing WebDriverAgent on {simulator_name}...") + install_result = subprocess.run( + ["xcrun", "simctl", "install", udid, str(app_path_to_install)], + capture_output=True, + text=True + ) + + if install_result.returncode == 0: + print(f"[simulator] WebDriverAgent installed successfully on {simulator_name}") + wda_installed = True + else: + print(f"[simulator] Failed to install WebDriverAgent: {install_result.stderr}") + else: + print(f"[simulator] WebDriverAgent already installed on {simulator_name}") + + # Launch WebDriverAgent if installed + if wda_installed: + await _send_status_emulator(udid, "installed", f"Launching WebDriverAgent on {simulator_name}...") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "installed", + "comment": f"Launching WebDriverAgent on {simulator_name}...", + } + }) + + # Launch WebDriverAgent app + launch_wda = subprocess.run( + ["xcrun", "simctl", "launch", udid, "com.facebook.WebDriverAgentRunner.xctrunner"], + capture_output=True, + text=True + ) + + if launch_wda.returncode == 0: + print(f"[simulator] WebDriverAgent launched successfully on {simulator_name}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "installed", + "comment": f"Simulator {simulator_name} running with WebDriverAgent", + } + }) + else: + print(f"[simulator] Failed to launch WebDriverAgent: {launch_wda.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "installed", + "comment": f"Simulator {simulator_name} running (WebDriverAgent launch failed)", + } + }) + else: + # Simulator is running but WDA not available + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "installed", + "comment": f"Simulator {simulator_name} is running", + } + }) + + return True + + except subprocess.TimeoutExpired: + error_msg = "Operation timed out" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "error", + "comment": error_msg, + } + }) + return False + except Exception as e: + error_msg = f"Failed to launch simulator: {e}" + print(f"[simulator] {error_msg}") + import traceback + traceback.print_exc() + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": udid, + "status": "error", + "comment": error_msg, + } + }) + return False + + +async def create_simulator_from_device_type(device_param: str) -> bool: + """ + Create iOS Simulator from device type ID and device name. + Uses the highest available iOS runtime. + + Args: + device_param: Format "install device;device_type_id;device_name" + Example: "install device;com.apple.CoreSimulator.SimDeviceType.iPhone-16;iPhone 16" + + Returns: + bool: True if successful, False otherwise + """ + try: + # Parse device parameter + parts = device_param.split(";") + if len(parts) < 3: + error_msg = f"Invalid device parameter format. Expected 'install device;device_type_id;device_name', got: {device_param}" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "name": "Unknown", + "status": "error", + "comment": error_msg, + } + }) + return False + + device_type_id = parts[1].strip() + device_name = parts[2].strip() + + print(f"[simulator] Creating simulator '{device_name}' with device type '{device_type_id}'") + + # Get available runtimes + runtimes = await get_available_runtimes() + if not runtimes: + error_msg = "No iOS runtimes found. Please install Xcode and iOS runtime first." + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "package": device_type_id, + "status": "not installed", + "comment": error_msg, + } + }) + return False + + # Get the latest available runtime + available_runtimes = [r for r in runtimes if r.get("isAvailable", False)] + if not available_runtimes: + error_msg = "No available iOS runtimes found." + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "package": device_type_id, + "status": "not installed", + "comment": error_msg, + } + }) + return False + + # Use the last runtime (usually the newest) + runtime = available_runtimes[-1] + runtime_id = runtime["identifier"] + runtime_name = runtime["name"] + + print(f"[simulator] Using runtime: {runtime_name} ({runtime_id})") + + # Generate a unique simulator name + existing_sims = await get_available_simulators() + existing_names = [s["name"] for s in existing_sims] + + # Create unique name + simulator_name = device_name + counter = 1 + while simulator_name in existing_names: + simulator_name = f"{device_name} ({counter})" + counter += 1 + + # Send installing status + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "package": device_type_id, + "status": "installing", + "comment": f"Creating {simulator_name} with {runtime_name}...", + } + }) + + # Create the simulator + result = subprocess.run( + ["xcrun", "simctl", "create", simulator_name, device_type_id, runtime_id], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + error_msg = f"Failed to create simulator: {result.stderr.strip()}" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "package": device_type_id, + "status": "not installed", + "comment": error_msg, + } + }) + return False + + new_udid = result.stdout.strip() + print(f"[simulator] Simulator created successfully: {simulator_name} ({new_udid})") + + # Send success response + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "package": device_type_id, + "status": "installed", + "comment": f"Simulator '{simulator_name}' created successfully ({new_udid})", + } + }) + + return True + + except subprocess.TimeoutExpired: + error_msg = "Simulator creation timed out" + print(f"[simulator] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "package": device_type_id, + "status": "error", + "comment": error_msg, + } + }) + return False + except Exception as e: + error_msg = f"Error creating simulator: {e}" + print(f"[simulator] {error_msg}") + import traceback + traceback.print_exc() + await send_response({ + "action": "status", + "data": { + "category": "iOSSimulator", + "package": device_type_id, + "status": "error", + "comment": error_msg, + } + }) + return False + + +async def install(user_password: str = "") -> bool: + """Main install entry point.""" + print("[simulator] Installing...") + + if platform.system().lower() != "darwin": + await _send_status("error", "iOS Simulator is only available on macOS.") + return False + + if await check_status(): + return True + + if not os.path.exists("/Applications/Xcode.app"): + await _send_status("error", "Xcode must be installed first.") + return False + + await _send_status("installing", "Setting up iOS Simulator...") + + if not await _install_command_line_tools(): + return False + + if not await _install_simulator_runtime(user_password): + return False + + return await check_status() \ No newline at end of file diff --git a/Framework/install_handler/ios/webdriver.py b/Framework/install_handler/ios/webdriver.py new file mode 100644 index 000000000..6e5d0801e --- /dev/null +++ b/Framework/install_handler/ios/webdriver.py @@ -0,0 +1,250 @@ +import platform +import os +import shutil +import subprocess +import json +import tempfile +import asyncio +from pathlib import Path +from Framework.install_handler.utils import send_response + +async def _send_status(status: str, comment: str): + """Helper to send status responses.""" + print(f"[{status}] {comment}") + await send_response( + { + "action": "status", + "data": { + "category": "iOS", + "name": "WebDriver", + "status": status, + "comment": comment, + }, + } + ) + +def _get_webdriver_path() -> Path: + home = Path.home() + return home / ".zeuz" / "WebDriverAgent" + +async def _check_xcode_installed() -> bool: + if not os.path.exists("/Applications/Xcode.app"): + return False + return shutil.which("xcodebuild") is not None + +async def _get_best_simulator() -> tuple[str, str] | None: + """Finds the best candidate simulator (preferring iPhones).""" + try: + result = subprocess.run( + ["xcrun", "simctl", "list", "devices", "available", "-j"], + capture_output=True, text=True + ) + + if result.returncode != 0: + return None + + data = json.loads(result.stdout) + candidates = [] + + for runtime_key, devices in data.get("devices", {}).items(): + if "iOS" not in runtime_key: + continue + for device in devices: + if device.get("isAvailable"): + name = device.get("name") + uuid = device.get("udid") + score = 2 if "iPhone" in name else 1 + candidates.append((score, name, uuid)) + + candidates.sort(key=lambda x: x[0], reverse=True) + if candidates: + return candidates[0][1], candidates[0][2] + return None + except Exception as e: + print(f"Error listing simulators: {e}") + return None + +async def _boot_simulator_if_needed(device_uuid: str) -> bool: + """Boots the simulator ONLY if it is currently shutdown.""" + try: + # 1. Check current state + list_res = subprocess.run( + ["xcrun", "simctl", "list", "devices", "-j"], + capture_output=True, text=True + ) + if list_res.returncode == 0: + data = json.loads(list_res.stdout) + for runtime, devices in data.get("devices", {}).items(): + for device in devices: + if device.get("udid") == device_uuid: + if device.get("state") == "Booted": + # Already booted + return True + + # 2. Boot if needed + await _send_status("installing", "Booting simulator to perform check/install...") + subprocess.run(["open", "-a", "Simulator"], capture_output=True) + + # Give the app a moment to launch + await asyncio.sleep(2) + + subprocess.run(["xcrun", "simctl", "boot", device_uuid], capture_output=True) + + # 3. Wait for 'Booted' status + res = subprocess.run( + ["xcrun", "simctl", "bootstatus", device_uuid], + capture_output=True, timeout=120 + ) + return res.returncode == 0 + except Exception as e: + print(f"Boot exception: {e}") + # We return True here to attempt the next step anyway, + # as sometimes bootstatus fails even if the device works. + return True + +async def check_status() -> bool: + """Checks if WebDriverAgent is installed (Ensures Simulator is ON).""" + print("[webdriver] Checking status...") + + if platform.system().lower() != "darwin": + await _send_status("error", "Unsupported OS.") + return False + + if not await _check_xcode_installed(): + await _send_status("not installed", "Xcode missing.") + return False + + webdriver_path = _get_webdriver_path() + if not (webdriver_path / "WebDriverAgent.xcodeproj").exists(): + await _send_status("not installed", "Repo not cloned.") + return False + + sim_info = await _get_best_simulator() + if not sim_info: + await _send_status("not installed", "No iOS Simulator devices found.") + return False + + name, uuid = sim_info + + if not await _boot_simulator_if_needed(uuid): + await _send_status("error", f"Failed to boot {name} for verification.") + return False + + # Check if app container exists + cmd = ["xcrun", "simctl", "get_app_container", uuid, "com.facebook.WebDriverAgentRunner.xctrunner"] + check_res = subprocess.run(cmd, capture_output=True, text=True) + + if check_res.returncode == 0 and check_res.stdout.strip(): + await _send_status("installed", f"WebDriverAgent found on {name} ({uuid}).") + return True + else: + await _send_status("not installed", f"WebDriverAgent not found on {name} ({uuid}).") + return False + +async def _build_and_install_webdriver(webdriver_path: Path) -> bool: + try: + sim_info = await _get_best_simulator() + if not sim_info: return False + name, uuid = sim_info + + # Ensure booted (check_status might have done this, but good to double check) + if not await _boot_simulator_if_needed(uuid): + await _send_status("error", "Failed to boot simulator.") + return False + + await _send_status("installing", f"Building WebDriverAgent for {name}...") + + # Create a temp directory to store build artifacts + with tempfile.TemporaryDirectory() as derived_data_path: + cmd = [ + "xcodebuild", + "-project", "WebDriverAgent.xcodeproj", + "-scheme", "WebDriverAgentRunner", + "-destination", f"platform=iOS Simulator,id={uuid}", + "-derivedDataPath", derived_data_path, + "-allowProvisioningUpdates", + "build-for-testing" + ] + + result = subprocess.run( + cmd, cwd=str(webdriver_path), + capture_output=True, text=True, timeout=1200 + ) + + if result.returncode != 0: + err = result.stderr[-500:] if result.stderr else "Unknown error" + await _send_status("error", f"Build failed: {err}") + return False + + # Explicitly Install the Built App + await _send_status("installing", "Installing WebDriverAgentRunner-Runner.app...") + + app_path = Path(derived_data_path) / "Build" / "Products" / "Debug-iphonesimulator" / "WebDriverAgentRunner-Runner.app" + + if not app_path.exists(): + await _send_status("error", f"Could not find built app at {app_path}") + return False + + install_res = subprocess.run( + ["xcrun", "simctl", "install", uuid, str(app_path)], + capture_output=True, text=True + ) + + if install_res.returncode != 0: + await _send_status("error", f"Install failed: {install_res.stderr.strip()}") + return False + + await _send_status("installing", "App installed successfully via simctl.") + return True + + except Exception as e: + await _send_status("error", f"Build/Install exception: {e}") + return False + +async def _clone_repository(webdriver_path: Path) -> bool: + try: + if webdriver_path.exists(): + shutil.rmtree(webdriver_path) + webdriver_path.parent.mkdir(parents=True, exist_ok=True) + + await _send_status("installing", "Cloning WebDriverAgent...") + subprocess.run( + ["git", "clone", "--depth", "1", "https://github.com/appium/WebDriverAgent.git", str(webdriver_path)], + check=True, capture_output=True + ) + return True + except Exception as e: + await _send_status("error", f"Clone failed: {e}") + return False + +async def _bootstrap_webdriver(webdriver_path: Path): + script = webdriver_path / "Scripts" / "bootstrap.sh" + if script.exists(): + try: + await _send_status("installing", "Running bootstrap...") + subprocess.run(["bash", str(script)], cwd=str(webdriver_path), capture_output=True) + except: pass + +async def install() -> bool: + print("[webdriver] Starting installation...") + + if await check_status(): + return True + + if not await _check_xcode_installed(): + await _send_status("error", "Xcode required.") + return False + + webdriver_path = _get_webdriver_path() + + if not await _clone_repository(webdriver_path): return False + await _bootstrap_webdriver(webdriver_path) + + if not await _build_and_install_webdriver(webdriver_path): return False + + if await check_status(): + await _send_status("installed", "WebDriverAgent installed successfully.") + return True + else: + await _send_status("error", "Installation completed but verification failed.") + return False \ No newline at end of file diff --git a/Framework/install_handler/ios/xcode.py b/Framework/install_handler/ios/xcode.py index 96b9ac388..08ebddee1 100644 --- a/Framework/install_handler/ios/xcode.py +++ b/Framework/install_handler/ios/xcode.py @@ -1,7 +1,15 @@ -async def check_status(): +from Framework.install_handler.macos.common import xcode_check_status, xcode_install + + +async def check_status() -> bool: + """Check if Xcode is installed and license is accepted.""" print("[xcode] Checking status...") + return await xcode_check_status("iOS") + -async def install(): +async def install(user_password: str = "") -> bool: + """Install Xcode via App Store and accept license.""" print("[xcode] Installing...") + return await xcode_install("iOS", user_password) diff --git a/Framework/install_handler/linux/atspi.py b/Framework/install_handler/linux/atspi.py new file mode 100644 index 000000000..9df1493d8 --- /dev/null +++ b/Framework/install_handler/linux/atspi.py @@ -0,0 +1,189 @@ +import os +from Framework.install_handler.utils import send_response +from .linux_utils import ( + detect_package_manager, + check_all_packages_installed, + install_packages, +) + + +# Package definitions for different package managers +PACKAGES = { + "apt": [ + "build-essential", + "cmake", + "pkg-config", + "libgirepository1.0-dev", + "libcairo2-dev", + "xdotool", + ], + "dnf": [ + "cmake", + "pkgconf-pkg-config", + "gobject-introspection-devel", + "cairo-devel", + "python3-devel", + "cairo-gobject-devel", + "xdotool", + ], + "pacman": [ + "gcc", + "meson", + "cmake", + "pkgconf", + "cairo", + "xdotool", + "gobject-introspection", + ], +} + + +async def check_status(): + """Checks if AT-SPI development packages are installed.""" + print("Checking AT-SPI development packages status...") + + # Check if session type is X11 + session_type = os.environ.get("XDG_SESSION_TYPE", "unknown") + if session_type != "x11": + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "AT-SPI Packages", + "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": "Linux", + "name": "AT-SPI Packages", + "status": "error", + "comment": "Unsupported package manager. Only apt, dnf, and pacman are supported.", + }, + } + ) + return False + + packages = PACKAGES.get(package_manager, []) + if check_all_packages_installed(package_manager, packages): + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "AT-SPI Packages", + "status": "installed", + "comment": "AT-SPI development packages are installed.", + }, + } + ) + return True + else: + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "AT-SPI Packages", + "status": "not installed", + "comment": f"Install AT-SPI packages using {package_manager}.", + }, + } + ) + return False + + +async def install(user_password: str = ""): + """Install AT-SPI development packages using the system package manager.""" + print("Installing AT-SPI development packages...") + + # Check if session type is X11 + session_type = os.environ.get("XDG_SESSION_TYPE", "unknown") + if session_type != "x11": + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "AT-SPI Packages", + "status": "error", + "comment": f"Only X11 is supported. Current session type: {session_type}.", + }, + } + ) + return False + + is_already_installed = await check_status() + + if not is_already_installed: + package_manager, _ = detect_package_manager() + + if not package_manager: + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "AT-SPI Packages", + "status": "error", + "comment": "Unsupported package manager. Only apt, dnf, and pacman are supported.", + }, + } + ) + return False + + packages = PACKAGES.get(package_manager, []) + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "AT-SPI Packages", + "status": "installing", + "comment": f"Installing packages using {package_manager}, please wait...", + }, + } + ) + + success, error_msg = install_packages( + package_manager, packages, user_password, timeout=3600 + ) + + if success: + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "AT-SPI Packages", + "status": "installed", + "comment": "AT-SPI packages have been installed successfully.", + }, + } + ) + return True + else: + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "AT-SPI Packages", + "status": "error", + "comment": f"Installation failed. Error: {error_msg}", + }, + } + ) + return False + else: + return True diff --git a/Framework/install_handler/linux/linux_utils.py b/Framework/install_handler/linux/linux_utils.py new file mode 100644 index 000000000..603e36a17 --- /dev/null +++ b/Framework/install_handler/linux/linux_utils.py @@ -0,0 +1,160 @@ +"""Shared utilities for Linux package management.""" + +import subprocess +import shutil + + +def detect_package_manager(): + """Detect the available package manager on the system. + + Returns: + tuple: (package_manager_name, None) where package_manager_name is one of "apt", "dnf", "pacman", or None + """ + if shutil.which("apt-get"): + return "apt", None + elif shutil.which("dnf"): + return "dnf", None + elif shutil.which("pacman"): + return "pacman", None + else: + return None, None + + +def check_package_installed(package_manager, package): + """Check if a package is installed using the appropriate package manager. + + Args: + package_manager: One of "apt", "dnf", or "pacman" + package: Package name to check + + Returns: + bool: True if package is installed, False otherwise + """ + try: + if package_manager == "apt": + result = subprocess.run( + ["dpkg", "-s", package], capture_output=True, text=True, timeout=30 + ) + return result.returncode == 0 + elif package_manager == "dnf": + result = subprocess.run( + ["rpm", "-q", package], capture_output=True, text=True, timeout=30 + ) + return result.returncode == 0 + elif package_manager == "pacman": + result = subprocess.run( + ["pacman", "-Q", package], capture_output=True, text=True, timeout=30 + ) + return result.returncode == 0 + except Exception: + pass + return False + + +def check_all_packages_installed(package_manager, packages): + """Check if all packages in the list are installed. + + Args: + package_manager: One of "apt", "dnf", or "pacman" + packages: List of package names to check + + Returns: + bool: True if all packages are installed, False otherwise + """ + for package in packages: + if not check_package_installed(package_manager, package): + return False + return True + + +def install_packages(package_manager, packages, user_password="", timeout=3600): + """Install packages using the system package manager. + + Args: + package_manager: One of "apt", "dnf", or "pacman" + packages: List of package names to install + user_password: Optional sudo password for authentication + timeout: Timeout in seconds for the installation (default: 3600 = 1 hour) + + Returns: + tuple: (success: bool, error_message: str) + """ + try: + # Build the install command based on package manager + if package_manager == "apt": + if user_password: + cmd = ["sudo", "-S", "apt-get", "install", "-y"] + packages + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + _, stderr = process.communicate( + input=f"{user_password}\n", timeout=timeout + ) + success = process.returncode == 0 + else: + cmd = ["sudo", "apt-get", "install", "-y"] + packages + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + ) + success = result.returncode == 0 + stderr = result.stderr + + elif package_manager == "dnf": + if user_password: + cmd = ["sudo", "-S", "dnf", "install", "-y"] + packages + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + _, stderr = process.communicate( + input=f"{user_password}\n", timeout=timeout + ) + success = process.returncode == 0 + else: + cmd = ["sudo", "dnf", "install", "-y"] + packages + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + ) + success = result.returncode == 0 + stderr = result.stderr + + elif package_manager == "pacman": + if user_password: + cmd = ["sudo", "-S", "pacman", "-S", "--noconfirm"] + packages + process = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + _, stderr = process.communicate( + input=f"{user_password}\n", timeout=timeout + ) + success = process.returncode == 0 + else: + cmd = ["sudo", "pacman", "-S", "--noconfirm"] + packages + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout + ) + success = result.returncode == 0 + stderr = result.stderr + else: + return False, "Unknown package manager" + + if success: + return True, "" + else: + return False, stderr + + except subprocess.TimeoutExpired: + return False, "Installation timed out" + except Exception as e: + return False, str(e) diff --git a/Framework/install_handler/linux/xwd.py b/Framework/install_handler/linux/xwd.py new file mode 100644 index 000000000..561a5e20e --- /dev/null +++ b/Framework/install_handler/linux/xwd.py @@ -0,0 +1,175 @@ +import os +from Framework.install_handler.utils import send_response +from .linux_utils import ( + detect_package_manager, + check_all_packages_installed, + install_packages, +) + + +# Package definitions for different package managers +PACKAGES = { + "apt": [ + "x11-apps", # provides xwd + "imagemagick", # provides convert, import + "wmctrl", + ], + "dnf": [ + "xorg-x11-utils", # provides xwd on some distros + "ImageMagick", + "wmctrl", + ], + "pacman": [ + "imagemagick", + "wmctrl", + ], +} + + +async def check_status(): + """Checks if Screen Capture Utilities are installed.""" + + # Check if session type is X11 + session_type = os.environ.get("XDG_SESSION_TYPE", "unknown") + if session_type != "x11": + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "Screen Capture Utilities", + "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": "Linux", + "name": "Screen Capture Utilities", + "status": "error", + "comment": "Unsupported package manager. Only apt, dnf, and pacman are supported.", + }, + } + ) + return False + + packages = PACKAGES.get(package_manager, []) + if check_all_packages_installed(package_manager, packages): + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "Screen Capture Utilities", + "status": "installed", + "comment": "Screen Capture Utilities are installed.", + }, + } + ) + return True + else: + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "Screen Capture Utilities", + "status": "not installed", + "comment": f"Install Screen Capture Utilities using {package_manager}.", + }, + } + ) + return False + + +async def install(user_password: str = ""): + """Install Screen Capture Utilities using the system package manager.""" + + # Check if session type is X11 + session_type = os.environ.get("XDG_SESSION_TYPE", "unknown") + if session_type != "x11": + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "Screen Capture Utilities", + "status": "error", + "comment": f"Only X11 is supported. Current session type: {session_type}.", + }, + } + ) + return False + + is_already_installed = await check_status() + + if not is_already_installed: + package_manager, _ = detect_package_manager() + + if not package_manager: + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "Screen Capture Utilities", + "status": "error", + "comment": "Unsupported package manager. Only apt, dnf, and pacman are supported.", + }, + } + ) + return False + + packages = PACKAGES.get(package_manager, []) + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "Screen Capture Utilities", + "status": "installing", + "comment": f"Installing packages using {package_manager}, please wait...", + }, + } + ) + + success, error_msg = install_packages( + package_manager, packages, user_password, timeout=300 + ) + + if success: + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "Screen Capture Utilities", + "status": "installed", + "comment": "Screen Capture Utilities have been installed successfully.", + }, + } + ) + return True + else: + await send_response( + { + "action": "status", + "data": { + "category": "Linux", + "name": "Screen Capture Utilities", + "status": "error", + "comment": f"Installation failed. Error: {error_msg}", + }, + } + ) + return False + else: + return True diff --git a/Framework/install_handler/long_poll_handler.py b/Framework/install_handler/long_poll_handler.py index baae345d3..cc6738b4a 100644 --- a/Framework/install_handler/long_poll_handler.py +++ b/Framework/install_handler/long_poll_handler.py @@ -1,21 +1,39 @@ import asyncio -import json import traceback import random -import platform import httpx +import inspect from colorama import Fore from Framework.install_handler.route import Response, services -from Framework.install_handler.utils import send_response, debug, read_node_id -from pydantic import BaseModel +from Framework.install_handler.utils import ( + debug, + current_os, + send_response, + read_node_id, + generate_services_list, +) from Framework.Utilities import RequestFormatter, ConfigModule from Framework.node_server_state import STATE +from Framework.install_handler.android.emulator import ( + check_emulator_list, + create_avd_from_system_image, + get_filtered_avd_services, + launch_avd, +) +from Framework.install_handler.ios.simulator import ( + check_simulator_list, + create_simulator_from_device_type, + delete_simulator, + get_filtered_simulator_services, + launch_simulator, +) +from Framework.install_handler.system_info.system_info import get_formatted_system_info if debug: print(f"[installer] Debug mode enabled") -class InstallHandler: +class InstallHandler: def __init__(self): self.cancel_ = False self.running = False @@ -23,91 +41,314 @@ def __init__(self): async def on_message(self, message: Response) -> None: try: - if debug: print(f"[installer] Received message:\n {message.model_dump_json(indent=4)}") + if debug: + print( + f"[installer] Received message:\n {message.model_dump_json(indent=4)}" + ) if message.value is None: return action = message.value.action if action == "services_list": - current_os = platform.system().lower() - if debug: print(f"[installer] Current OS: {current_os}") - - filtered_services = [] - for category in services: - filtered_category = { - "category": category["category"], - "services": [] + services_list = generate_services_list(services) + await send_response( + { + "action": "services_list", + "data": { + "system_info": None, + "services": services_list, + }, } - for service in category["services"]: - if current_os not in service["os"]: - if debug: print(f"[installer] Skipping {service['name']} - not compatible with {current_os}") - continue - - filtered_service = { - "name": service["name"], - "status": service["status"], - "comment": service["comment"], - "install_text": service["install_text"], - "os": service["os"] - } - filtered_category["services"].append(filtered_service) - - if filtered_category["services"]: - filtered_services.append(filtered_category) - - await send_response({ - "action": "services_list", - "data": filtered_services - }) + ) + + # Send Android Emulator list and iOS Simulator list to server + await check_emulator_list() + await check_simulator_list() + + elif action == "system_info": + if debug: + print(f"[installer] Received system_info request") + try: + system_info_response = await get_formatted_system_info() + # Send the response to server + await send_response( + {"action": "system_info", "data": system_info_response} + ) + if debug: + print(f"[installer] System info sent successfully") + except Exception as e: + print(f"[installer] Error getting/sending system info: {e}") + traceback.print_exc() elif action in ["install", "status"]: - if debug: print(f"[installer] Installing {message}") + if debug: + print(f"[installer] Installing {message}") + + # Extract user_password only for install actions (not for status) + user_password = "" + if action == "install" and message.value.item: + user_password = ( + getattr(message.value.item, "user_password", "") or "" + ) + + category = [ + i for i in services if i["category"] == message.value.item.category + ][0] + + # Handle AndroidEmulator category + if category["category"] == "AndroidEmulator": + service_name = message.value.item.name + + # Case 1: No service name or empty - get system images list + if not service_name: + if ( + action == "install" + and "install_function" in category + and category["install_function"] + ): + func = category["install_function"] + await func() + return + else: + print( + f"[installer] No install_function found for AndroidEmulator category" + ) + return + + # Case 2: Service name is a system image (starts with "system-images;") + if service_name.startswith("install device;"): + if action == "install": + res = await create_avd_from_system_image(service_name) + if res: + avd_list = await get_filtered_avd_services() + if avd_list: + await send_response( + { + "action": "services_update", + "data": { + 'category': 'AndroidEmulator', + "services": avd_list['services'], + }, + } + ) + + func = category["install_function"] + await func() + return + else: + print( + f"[installer] Status check not supported for system images" + ) + return + + # Case 3: This is a request to launch an existing AVD + else: + try: + await launch_avd(service_name) + except Exception as e: + print( + f"[installer] Error launching AVD '{service_name}': {e}" + ) + traceback.print_exc() + return + return - category = [i for i in services if i["category"] == message.value.item.category][0] - service = [i for i in category["services"] if i["name"] == message.value.item.name][0] + # Handle iOSSimulator category + if category["category"] == "iOSSimulator": + print(f"[installer] iOSSimulator category: {category}") + print(f"[installer] iOSSimulator services: {category['services']}") + print( + f"[installer] Requested service name: {message.value.item.name}" + ) + service_name = message.value.item.name + + # Case 1: No service name or empty - get device types list + if not service_name: + if action == "install" and "install_function" in category and category["install_function"]: + func = category["install_function"] + await func() + return + else: + print(f"[installer] No install_function found for iOSSimulator category") + return + + # Case 2: Service name is a device type (starts with "install device;") + if service_name.startswith("install device;"): + if action == "install": + res = await create_simulator_from_device_type(service_name) + if res: + simulator_list = await get_filtered_simulator_services() + if simulator_list: + await send_response( + { + "action": "services_update", + "data": { + 'category': 'iOSSimulator', + "services": simulator_list['services'], + }, + } + ) + func = category["install_function"] + await func() + return + else: + print( + f"[installer] Status check not supported for device types" + ) + return + # Case 3: This is a request to delete an existing simulator + if action == "install" or action == "delete": + await delete_simulator(service_name) + return + # Case 4: This is a request to launch an existing simulator (UDID format) + else: + try: + await launch_simulator(service_name) + except Exception as e: + print( + f"[installer] Error launching simulator '{service_name}': {e}" + ) + traceback.print_exc() + return + return + + # Normal service-level install for other categories + service = [ + i + for i in category["services"] + if i["name"] == message.value.item.name + ][0] if action == "install": func = service["install_function"] + if func is None: + print( + f"[installer] Function not found for {message.value.item.name}" + ) + return + # Check if function accepts parameters + sig = inspect.signature(func) + if len(sig.parameters) > 0: + # Function accepts parameters, pass user_password + await func(user_password) + else: + # Function doesn't accept parameters, call without (backward compatibility) + await func() elif action == "status": func = service["status_function"] - - if func is None: - print(f"[installer] Function not found for {message.value.item.name}") - return - await func() + if func is None: + print( + f"[installer] Function not found for {message.value.item.name}" + ) + return + await func() + elif action == "group_status": + await send_response( + { + "action": "group_status", + "data": { + "category": message.value.item.category, + "check_text": "Checking", + }, + } + ) + category = [ + i for i in services if i["category"] == message.value.item.category + ][0] + if message.value.item.category == "AndroidEmulator" and current_os in category["os"]: + await category["status_function"]() + + if message.value.item.category == "iOSSimulator" and current_os in category["os"]: + await category["status_function"]() + + else: + services_list = category["services"] + functions = [ + i["status_function"] for i in services_list if i["status_function"] and current_os in i["os"] + ] + for func in functions: + await func() + await send_response( + { + "action": "group_status", + "data": { + "category": message.value.item.category, + "check_text": "Check all", + }, + } + ) + elif action == "group_install": + await send_response( + { + "action": "group_install", + "data": { + "category": message.value.item.category, + "install_text": "Installing", + }, + } + ) + services_list = [ + i for i in services if i["category"] == message.value.item.category + ][0]["services"] + functions = [ + i["install_function"] + for i in services_list + if i["install_function"] + ] + for func in functions: + await func() + await send_response( + { + "action": "group_install", + "data": { + "category": message.value.item.category, + "install_text": "Install all", + }, + } + ) except Exception as e: traceback.print_exc() async def cancel_run(self) -> None: self.cancel_ = True if self.running: - if debug: print("[installer] Cancelling install listener...") + if debug: + print("[installer] Cancelling install listener...") else: - if debug: print("[installer] Not running.") + if debug: + print("[installer] Not running.") async def run(self) -> None: self.cancel_ = False if self.running: - if debug: print("[installer] Already running.") + if debug: + print("[installer] Already running.") return - if debug: print(f"[installer] Started running") - async with httpx.AsyncClient(timeout=httpx.Timeout(70.0), verify=False) as client: + if debug: + print(f"[installer] Started running") + async with httpx.AsyncClient( + timeout=httpx.Timeout(70.0), verify=False + ) as client: self.client = client while not self.cancel_: if STATE.reconnect_with_credentials is not None: - if debug: print("[installer] Reconnection requested, stopping...") + if debug: + print("[installer] Reconnection requested, stopping...") break - + self.running = True - try: - if debug: print("[installer] Active") + try: + if debug: + print("[installer] Active") api_key = ConfigModule.get_config_value("Authentication", "api-key") - url = RequestFormatter.form_uri(f"d/nodes/install/node/listen?node_id={read_node_id()}") - + url = RequestFormatter.form_uri( + f"d/nodes/install/node/listen?node_id={read_node_id()}" + ) + resp = await client.get(url, headers={"X-API-KEY": api_key}) if resp.status_code == httpx.codes.NO_CONTENT: continue if not resp.is_success: - if debug: + if debug: print( "[installer] facing difficulty communicating with the server, status code:", resp.status_code, @@ -130,11 +371,14 @@ async def run(self) -> None: except httpx.ReadTimeout: pass except httpx.ConnectError: - if debug: print("[installer] Connection error, retrying...") + if debug: + print("[installer] Connection error, retrying...") await asyncio.sleep(random.randint(3, 5)) except Exception: - if debug: traceback.print_exc() - if debug: print("[installer] RETRYING...") + if debug: + traceback.print_exc() + if debug: + print("[installer] RETRYING...") await asyncio.sleep(random.randint(1, 3)) self.running = False diff --git a/Framework/install_handler/macos/common.py b/Framework/install_handler/macos/common.py new file mode 100644 index 000000000..29e593d29 --- /dev/null +++ b/Framework/install_handler/macos/common.py @@ -0,0 +1,171 @@ +import asyncio +import platform +import os +import shutil +import subprocess +from Framework.install_handler.utils import send_response + + +async def _send_status(category, status: str, comment: str): + """Helper to send status responses.""" + await send_response( + { + "action": "status", + "data": { + "category": category, + "name": "Xcode", + "status": status, + "comment": comment, + }, + } + ) + + +async def xcode_check_status(category) -> bool: + """Check if Xcode is installed and license is accepted.""" + print("[xcode] Checking status...") + + if platform.system().lower() != "darwin": + await _send_status( + category, "error", "Unsupported OS. Xcode is only available on macOS." + ) + return False + + xcodebuild_path = shutil.which("xcodebuild") + if not xcodebuild_path or not os.path.exists("/Applications/Xcode.app"): + await _send_status(category, "not installed", "Xcode is not installed.") + return False + + try: + # Check version + result = subprocess.run( + [xcodebuild_path, "-version"], capture_output=True, text=True, timeout=30 + ) + if result.returncode != 0: + await _send_status( + category, "error", f"xcodebuild error: {result.stderr.strip()}" + ) + return False + + version = result.stdout.splitlines()[0].strip() if result.stdout else "unknown" + + # Check license + license_result = subprocess.run( + [xcodebuild_path, "-checkFirstLaunchStatus"], + capture_output=True, + text=True, + timeout=15, + ) + + if license_result.returncode == 0: + await _send_status(category, "installed", f"{version}") + return True + else: + await _send_status( + category, + "not installed", + f"{version} installed but license not accepted.", + ) + return False + + except subprocess.TimeoutExpired: + await _send_status(category, "error", "xcodebuild timed out.") + return False + except Exception as e: + await _send_status(category, "error", f"Error checking Xcode: {e}") + return False + + +async def _accept_license(category, user_password: str) -> bool: + """Attempt to accept Xcode license using sudo.""" + xcodebuild_path = shutil.which("xcodebuild") or "xcodebuild" + + if not user_password: + await _send_status( + category, "error", "Password required to accept Xcode license." + ) + return False + + try: + result = subprocess.run( + ["sudo", "-S", xcodebuild_path, "-license", "accept"], + input=user_password + "\n", + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode == 0: + await _send_status( + category, "installed", "Xcode license accepted successfully." + ) + return True + else: + await _send_status( + category, "error", f"Failed to accept license: {result.stderr.strip()}" + ) + return False + except Exception as e: + await _send_status(category, "error", f"Error accepting license: {e}") + return False + + +async def xcode_install(category, user_password: str = "") -> bool: + """Install Xcode via App Store and accept license.""" + print("[xcode] Installing...") + + if platform.system().lower() != "darwin": + await _send_status( + category, "error", "Unsupported OS. Xcode is only available on macOS." + ) + return False + + if await xcode_check_status(category): + return True + + # Open App Store + await _send_status( + category, "installing", "Opening App Store. Please install Xcode and wait..." + ) + try: + subprocess.run( + ["open", "itms-apps://itunes.apple.com/app/id497799835"], timeout=10 + ) + except Exception: + pass + + # Poll for installation (2 hours timeout) + timeout_seconds = 2 * 60 * 60 + interval = 10 + elapsed = 0 + + while elapsed < timeout_seconds: + await asyncio.sleep(interval) + elapsed += interval + + # Check if Xcode app exists + xcodebuild_path = shutil.which("xcodebuild") + if os.path.exists("/Applications/Xcode.app") and xcodebuild_path: + # Check license status + try: + license_result = subprocess.run( + [xcodebuild_path, "-checkFirstLaunchStatus"], + capture_output=True, + text=True, + timeout=15, + ) + + if license_result.returncode == 0: + await _send_status( + category, "installed", "Xcode installed successfully." + ) + return True + else: + # License not accepted, attempt to accept + return await _accept_license(category, user_password) + except Exception: + # Try to accept license anyway + return await _accept_license(category, user_password) + + await _send_status(category, "error", "Timed out waiting for Xcode installation.") + return False diff --git a/Framework/install_handler/macos/xcode.py b/Framework/install_handler/macos/xcode.py new file mode 100644 index 000000000..a940e0834 --- /dev/null +++ b/Framework/install_handler/macos/xcode.py @@ -0,0 +1,16 @@ +from .common import xcode_check_status, xcode_install + + +async def check_status() -> bool: + """Check if Xcode is installed and license is accepted.""" + print("[xcode] Checking status...") + + return await xcode_check_status("MacOS") + + + +async def install(user_password: str = "") -> bool: + """Install Xcode via App Store and accept license.""" + print("[xcode] Installing...") + + return await xcode_install("MacOS", user_password) diff --git a/Framework/install_handler/route.py b/Framework/install_handler/route.py index 203c16ffd..905296189 100644 --- a/Framework/install_handler/route.py +++ b/Framework/install_handler/route.py @@ -1,159 +1,308 @@ from pydantic import BaseModel, ConfigDict -from typing import Literal +from typing import Literal, Optional -from .web import chrome_for_testing -from .android import adb, node_js_22, appium, java, android_emulator -from .ios import xcode +from .web import chrome_for_testing, edge, mozilla +from .android import ( + adb, + node_js_22, + appium, + java, + android_sdk, +) +from .ios import xcode, simulator +from .macos import xcode as macos_xcode from .database import postgresql, mysql, mariadb, oracle from .windows import inspector +from .android import emulator services = [ { - "category": "Web", - "services": [ - { - "name": "Chrome For Testing", - "status": "none", - "comment": "Chrome for Testing is required to run web automation in Chrome browser. ZZZ", - "install_text": "install", - "os": ["windows", "linux", "darwin"], - "status_function": chrome_for_testing.check_status, - "install_function": chrome_for_testing.install - } - ] - }, - { + "group": { + "check_text": "Check all", + "install_text": "Install all", + }, "category": "Android", "services": [ - { - "name": "ADB", - "status": "none", - "comment": "ADB is a tool for managing Android devices.", - "install_text": "install", - "os": ["windows", "linux", "darwin"], - "status_function": adb.check_status, - "install_function": adb.install - }, { "name": "Node js 22", "status": "none", "comment": "Node js 22 is a tool for managing Node js 22 devices.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["windows", "linux", "darwin"], "status_function": node_js_22.check_status, - "install_function": node_js_22.install + "install_function": node_js_22.check_status, # on purpose. Node 22 is installed when node starts. + "user_password": False, }, { "name": "Appium", "status": "none", "comment": "Appium is a tool for managing Appium devices.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["windows", "linux", "darwin"], "status_function": appium.check_status, - "install_function": appium.install + "install_function": appium.check_status, # on purpose. Appium is installed when node starts. + "user_password": False, }, { "name": "Java", "status": "none", "comment": "Java is a tool for managing Java devices.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["windows", "linux", "darwin"], "status_function": java.check_status, - "install_function": java.install + "install_function": java.install, # install jdk here also. jdk.install will install java also. + "user_password": False, }, { - "name": "Android Emulator", + "name": "Android SDK", "status": "none", - "comment": "Android Emulator is a tool for managing Android Emulator devices.", - "install_text": "install", + "comment": "Android SDK is a tool for managing Android SDK devices.", + "check_text":"Check status", + "install_text": "Install", "os": ["windows", "linux", "darwin"], - "status_function": android_emulator.check_status, - "install_function": android_emulator.install - } - ] + "status_function": android_sdk.check_status, + "install_function": android_sdk.install, + "user_password": False, + }, + { + "name": "ADB", + "status": "none", + "comment": "ADB is a tool for managing Android devices.", + "check_text":"Check status", + "install_text": "", + "os": ["windows", "linux", "darwin"], + "status_function": adb.check_status, + "install_function": adb.install, + "user_password": False, + }, + ], + }, + { + "group": { + "check_text": "Check all", + "install_text": "", + }, + "category": "AndroidEmulator", + "name": "System Images", + "check_text":"Check status", + "install_text": "Install", + "os": ["windows", "linux", "darwin"], + "status_function": emulator.check_emulator_list, + "install_function": emulator.android_emulator_install, + "installables": [], + "services": [], }, { "category": "iOS", + "group": { + "check_text": "Check all", + "install_text": "", + }, "services": [ { "name": "Xcode", "status": "none", "comment": "Xcode is a tool for managing Xcode devices.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["darwin"], "status_function": xcode.check_status, - "install_function": xcode.install + "install_function": xcode.install, + "user_password": "yes", + }, + { + "name": "Simulator", + "status": "none", + "comment": "Simulator is a tool for managing Simulator devices.", + "check_text":"Check status", + "install_text": "Install", + "os": ["darwin"], + "status_function": simulator.check_status, + "install_function": simulator.install, + "user_password": "yes", + }, + ], + }, + { + "group": { + "check_text": "Check all", + "install_text": "", + }, + "category": "iOSSimulator", + "name": "Device Types", + "check_text":"Check status", + "install_text": "Install", + "os": ["darwin"], + "status_function": simulator.check_simulator_list, + "install_function": simulator.ios_simulator_install, + "installables": [], + "services": [], + }, + { + "category": "Web", + "group": { + "check_text": "Check all", + "install_text": "Install all", + }, + "services": [ + { + "name": "Chrome For Testing", + "status": "none", + "comment": "Chrome for Testing is required to run web automation in Chrome browser.", + "check_text":"Check status", + "install_text": "Install", + "os": ["windows", "linux", "darwin"], + "status_function": chrome_for_testing.check_status, + "install_function": chrome_for_testing.install, + "user_password": False, + }, + { + "name": "Mozilla", + "status": "none", + "comment": "Mozilla Firefox is required to run web automation in Mozilla Firefox browser.", + "check_text":"Check status", + "install_text": "Install", + "os": ["windows", "linux", "darwin"], + "status_function": mozilla.check_status, + "install_function": mozilla.install, + "user_password": False, + }, + { + "name": "Edge", + "status": "none", + "comment": "Microsoft Edge is required to run web automation in Microsoft Edge browser.", + "check_text":"Check status", + "install_text": "Install", + "os": ["windows", "linux", "darwin"], + "status_function": edge.check_status, + "install_function": edge.install, + "user_password": "yes", + }, + ], + }, + { + "category": "MacOS", + "group": { + "check_text": "Check all", + "install_text": "", + }, + "services": [ + { + "name": "Xcode", + "status": "none", + "comment": "Xcode is a tool for managing Xcode devices.", + "check_text":"Check status", + "install_text": "Install", + "os": ["darwin"], + "status_function": macos_xcode.check_status, + "install_function": macos_xcode.install, + "user_password": "yes", } - ] + ], }, { "category": "Database", + "group": { + "check_text": "Check all", + "install_text": "", + }, "services": [ { "name": "PostgreSQL", "status": "none", "comment": "PostgreSQL driver is required to connect to PostgreSQL database.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["windows", "linux", "darwin"], "status_function": postgresql.check_status, - "install_function": postgresql.install + "install_function": postgresql.install, + "user_password": "no", }, { "name": "MySQL", "status": "none", "comment": "MySQL driver is required to connect to MySQL database.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["windows", "linux", "darwin"], "status_function": mysql.check_status, - "install_function": mysql.install + "install_function": mysql.install, + "user_password": "no", }, { "name": "MariaDB", "status": "none", "comment": "MariaDB driver is required to connect to MariaDB database.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["windows", "linux", "darwin"], "status_function": mariadb.check_status, - "install_function": mariadb.install + "install_function": mariadb.install, + "user_password": "yes", }, { "name": "Oracle", "status": "none", "comment": "Oracle driver is required to connect to Oracle database.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["windows", "linux", "darwin"], "status_function": oracle.check_status, - "install_function": oracle.install - } - ] + "install_function": oracle.install, + "user_password": "no", + }, + ], }, { "category": "Windows", + "group": { + "check_text": "Check all", + "install_text": "Install all", + }, + "install_function": inspector.install, "services": [ { "name": "Inspector", "status": "none", "comment": "Inspector is a tool for managing Inspector devices.", - "install_text": "install", + "check_text":"Check status", + "install_text": "Install", "os": ["windows"], "status_function": inspector.check_status, - "install_function": inspector.install + "install_function": inspector.install, + "user_password": "no", } - ] - } + ], + }, ] + class Item(BaseModel): - name: str + name: Optional[str] = None category: str + user_password: str = ( + "" # Optional user password for installations requiring sudo/admin + ) + class Value(BaseModel): - model_config = ConfigDict(extra='forbid') - - action: Literal["services_list", "install", "status"] + model_config = ConfigDict(extra="forbid") + + action: Literal[ + "services_list", + "install", + "status", + "system_info", + "group_status", + "group_install", + ] item: Item | None = None + class Response(BaseModel): - model_config = ConfigDict(extra='forbid') - - value: Value | None \ No newline at end of file + model_config = ConfigDict(extra="forbid") + + value: Value | None diff --git a/Framework/install_handler/system_info/system_info.py b/Framework/install_handler/system_info/system_info.py new file mode 100644 index 000000000..4f62f93f4 --- /dev/null +++ b/Framework/install_handler/system_info/system_info.py @@ -0,0 +1,1222 @@ +import platform +import subprocess +import asyncio +import re +import time +from typing import Dict, List, Any, Optional +try: + import psutil +except ImportError: + psutil = None + + +def get_system_uptime() -> Dict[str, Any]: + """ + Get system uptime in seconds and human-readable format. + Returns a dictionary with uptime_seconds and uptime_human. + """ + try: + if psutil: + boot_time = psutil.boot_time() + uptime_seconds = time.time() - boot_time + else: + system = platform.system() + if system == "Linux": + try: + result = subprocess.run( + ["cat", "/proc/uptime"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + uptime_seconds = float(result.stdout.split()[0]) + else: + uptime_seconds = None + except Exception: + uptime_seconds = None + elif system == "Darwin": + try: + result = subprocess.run( + ["sysctl", "-n", "kern.boottime"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + # Format: { sec = 1234567890, usec = 0 } Mon Jan 1 00:00:00 2000 + import re + match = re.search(r'sec\s*=\s*(\d+)', result.stdout) + if match: + boot_time = int(match.group(1)) + uptime_seconds = time.time() - boot_time + else: + uptime_seconds = None + else: + uptime_seconds = None + except Exception: + uptime_seconds = None + elif system == "Windows": + try: + result = subprocess.run( + ["wmic", "os", "get", "LastBootUpTime"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + if len(lines) > 1: + boot_time_str = lines[1].strip() + # Format: 20240101120000.000000+000 + from datetime import datetime + boot_time = datetime.strptime(boot_time_str[:14], "%Y%m%d%H%M%S").timestamp() + uptime_seconds = time.time() - boot_time + else: + uptime_seconds = None + else: + uptime_seconds = None + except Exception: + uptime_seconds = None + else: + uptime_seconds = None + + if uptime_seconds is not None: + # Convert to human-readable format + days = int(uptime_seconds // 86400) + hours = int((uptime_seconds % 86400) // 3600) + minutes = int((uptime_seconds % 3600) // 60) + + if days > 0: + uptime_human = f"{days}d {hours}h {minutes}m" + elif hours > 0: + uptime_human = f"{hours}h {minutes}m" + else: + uptime_human = f"{minutes}m" + + return { + "uptime_seconds": int(uptime_seconds), + "uptime_human": uptime_human + } + else: + return { + "uptime_seconds": None, + "uptime_human": "Unknown" + } + except Exception as e: + print(f"[installer][system-info] Error getting uptime: {e}") + return { + "uptime_seconds": None, + "uptime_human": "Unknown" + } + + +def get_device_model() -> str: + """ + Get device/model name if available. + Returns device model string or "Unknown". + """ + try: + system = platform.system() + if system == "Linux": + try: + # Try to get product name from /sys/class/dmi/id/product_name + result = subprocess.run( + ["cat", "/sys/class/dmi/id/product_name"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + model = result.stdout.strip() + if model: + return model + except Exception: + pass + + try: + # Try to get model from /proc/device-tree/model + result = subprocess.run( + ["cat", "/proc/device-tree/model"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + model = result.stdout.strip() + if model: + return model + except Exception: + pass + elif system == "Darwin": + try: + result = subprocess.run( + ["sysctl", "-n", "hw.model"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + pass + elif system == "Windows": + try: + result = subprocess.run( + ["wmic", "computersystem", "get", "model"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + if len(lines) > 1: + return lines[1].strip() + except Exception: + pass + + return "Unknown" + except Exception as e: + print(f"[installer][system-info] Error getting device model: {e}") + return "Unknown" + + +def get_os_info() -> Dict[str, str]: + """ + Get OS details (name, version, release). + Returns a dictionary with os_name, os_version, and os_release. + """ + try: + system = platform.system() + release = platform.release() + version = platform.version() + + # Get more specific OS name + os_name = system + if system == "Linux": + # Try to get distribution name + try: + result = subprocess.run( + ["lsb_release", "-si"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + os_name = result.stdout.strip() + else: + # Try /etc/os-release + try: + with open("/etc/os-release", "r") as f: + for line in f: + if line.startswith("NAME="): + os_name = line.split("=", 1)[1].strip().strip('"') + break + except Exception: + pass + except Exception: + pass + elif system == "Darwin": + os_name = "macOS" + # Get macOS version + try: + result = subprocess.run( + ["sw_vers", "-productVersion"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + version = result.stdout.strip() + except Exception: + pass + elif system == "Windows": + os_name = "Windows" + # Get Windows version + try: + result = subprocess.run( + ["cmd", "/c", "ver"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + version = result.stdout.strip() + except Exception: + pass + + return { + "os_name": os_name, + "os_version": version, + "os_release": release + } + except Exception as e: + print(f"[installer][system-info] Error getting OS info: {e}") + return { + "os_name": platform.system(), + "os_version": platform.version(), + "os_release": platform.release() + } + + +def get_cpu_info() -> Dict[str, Any]: + """ + Get CPU info (cores, model, architecture). + Returns a dictionary with cpu_cores, cpu_model, and cpu_architecture. + """ + try: + cpu_cores = psutil.cpu_count(logical=True) if psutil else None + cpu_physical_cores = psutil.cpu_count(logical=False) if psutil else None + cpu_architecture = platform.machine() + cpu_model = "Unknown" + + system = platform.system() + + if system == "Linux": + try: + # Try to get CPU model from /proc/cpuinfo + result = subprocess.run( + ["grep", "-m", "1", "model name", "/proc/cpuinfo"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + cpu_model = result.stdout.split(":", 1)[1].strip() + except Exception: + pass + elif system == "Darwin": + try: + result = subprocess.run( + ["sysctl", "-n", "machdep.cpu.brand_string"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + cpu_model = result.stdout.strip() + except Exception: + pass + elif system == "Windows": + try: + result = subprocess.run( + ["wmic", "cpu", "get", "name"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + if len(lines) > 1: + cpu_model = lines[1].strip() + except Exception: + pass + + # Get CPU usage and load average + cpu_usage_percent = None + cpu_load_avg = None + cpu_temperature = None + + if psutil: + # CPU usage percentage (average across all cores) + cpu_usage_percent = psutil.cpu_percent(interval=0.1) + + # Load average (1m, 5m, 15m) - only available on Unix systems + if system in ["Linux", "Darwin"]: + try: + load_avg = psutil.getloadavg() + cpu_load_avg = { + "1m": round(load_avg[0], 2), + "5m": round(load_avg[1], 2), + "15m": round(load_avg[2], 2) + } + except AttributeError: + # getloadavg() not available on this system + pass + + # CPU temperature (if available) + if hasattr(psutil, "sensors_temperatures"): + try: + temps = psutil.sensors_temperatures() + if temps: + # Get CPU temperature (look for 'cpu_thermal', 'coretemp', etc.) + for name, entries in temps.items(): + if 'cpu' in name.lower() or 'core' in name.lower(): + if entries: + cpu_temperature = round(entries[0].current, 1) + break + except Exception: + pass + + return { + "cpu_cores": cpu_cores, + "cpu_physical_cores": cpu_physical_cores, + "cpu_model": cpu_model, + "cpu_architecture": cpu_architecture, + "cpu_usage_percent": cpu_usage_percent, + "cpu_load_avg": cpu_load_avg, + "cpu_temperature": cpu_temperature + } + except Exception as e: + print(f"[installer][system-info] Error getting CPU info: {e}") + return { + "cpu_cores": None, + "cpu_physical_cores": None, + "cpu_model": "Unknown", + "cpu_architecture": platform.machine() + } + + +def get_memory_info() -> Dict[str, Any]: + """ + Get memory info (total RAM and swap). + Returns a dictionary with total_ram and swap info (in bytes and human-readable format). + """ + try: + if psutil: + memory = psutil.virtual_memory() + swap = psutil.swap_memory() + + total_ram_bytes = memory.total + total_ram_gb = total_ram_bytes / (1024 ** 3) + total_ram_mb = total_ram_bytes / (1024 ** 2) + + swap_total_bytes = swap.total + swap_used_bytes = swap.used + swap_free_bytes = swap.free + swap_total_gb = swap_total_bytes / (1024 ** 3) + swap_used_gb = swap_used_bytes / (1024 ** 3) + swap_free_gb = swap_free_bytes / (1024 ** 3) + swap_percent = swap.percent if swap_total_bytes > 0 else 0 + + # Get RAM usage details + used_ram_bytes = memory.used + free_ram_bytes = memory.available + used_ram_gb = used_ram_bytes / (1024 ** 3) + free_ram_gb = free_ram_bytes / (1024 ** 3) + ram_percent = memory.percent + + return { + "total_ram_bytes": total_ram_bytes, + "total_ram_gb": round(total_ram_gb, 2), + "total_ram_mb": round(total_ram_mb, 2), + "total_ram_human": f"{total_ram_gb:.2f} GB", + "used_ram_bytes": used_ram_bytes, + "used_ram_gb": round(used_ram_gb, 2), + "used_ram_human": f"{used_ram_gb:.2f} GB", + "free_ram_bytes": free_ram_bytes, + "free_ram_gb": round(free_ram_gb, 2), + "free_ram_human": f"{free_ram_gb:.2f} GB", + "ram_percent": round(ram_percent, 2), + "swap_total_bytes": swap_total_bytes, + "swap_total_gb": round(swap_total_gb, 2), + "swap_total_human": f"{swap_total_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_used_bytes": swap_used_bytes, + "swap_used_gb": round(swap_used_gb, 2), + "swap_used_human": f"{swap_used_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_free_bytes": swap_free_bytes, + "swap_free_gb": round(swap_free_gb, 2), + "swap_free_human": f"{swap_free_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_percent": round(swap_percent, 2) if swap_total_bytes > 0 else 0 + } + else: + # Fallback for systems without psutil + system = platform.system() + if system == "Linux": + try: + # Get RAM info + ram_result = subprocess.run( + ["grep", "MemTotal", "/proc/meminfo"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + + # Get swap info + swap_total_result = subprocess.run( + ["grep", "SwapTotal", "/proc/meminfo"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + + swap_free_result = subprocess.run( + ["grep", "SwapFree", "/proc/meminfo"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + + total_ram_bytes = None + swap_total_bytes = 0 + swap_free_bytes = 0 + + if ram_result.returncode == 0: + # Format: MemTotal: 16384000 kB + parts = ram_result.stdout.split() + if len(parts) >= 2: + total_ram_kb = int(parts[1]) + total_ram_bytes = total_ram_kb * 1024 + + if swap_total_result.returncode == 0: + parts = swap_total_result.stdout.split() + if len(parts) >= 2: + swap_total_kb = int(parts[1]) + swap_total_bytes = swap_total_kb * 1024 + + if swap_free_result.returncode == 0: + parts = swap_free_result.stdout.split() + if len(parts) >= 2: + swap_free_kb = int(parts[1]) + swap_free_bytes = swap_free_kb * 1024 + + swap_used_bytes = swap_total_bytes - swap_free_bytes + swap_total_gb = swap_total_bytes / (1024 ** 3) + swap_used_gb = swap_used_bytes / (1024 ** 3) + swap_free_gb = swap_free_bytes / (1024 ** 3) + swap_percent = (swap_used_bytes / swap_total_bytes * 100) if swap_total_bytes > 0 else 0 + + if total_ram_bytes: + total_ram_gb = total_ram_bytes / (1024 ** 3) + return { + "total_ram_bytes": total_ram_bytes, + "total_ram_gb": round(total_ram_gb, 2), + "total_ram_mb": round(total_ram_bytes / (1024 ** 2), 2), + "total_ram_human": f"{total_ram_gb:.2f} GB", + "swap_total_bytes": swap_total_bytes, + "swap_total_gb": round(swap_total_gb, 2), + "swap_total_human": f"{swap_total_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_used_bytes": swap_used_bytes, + "swap_used_gb": round(swap_used_gb, 2), + "swap_used_human": f"{swap_used_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_free_bytes": swap_free_bytes, + "swap_free_gb": round(swap_free_gb, 2), + "swap_free_human": f"{swap_free_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_percent": round(swap_percent, 2) if swap_total_bytes > 0 else 0 + } + except Exception: + pass + elif system == "Darwin": + try: + # Get RAM + ram_result = subprocess.run( + ["sysctl", "-n", "hw.memsize"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + + # Get swap info + swap_result = subprocess.run( + ["sysctl", "-n", "vm.swapusage"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + + total_ram_bytes = None + swap_total_bytes = 0 + swap_used_bytes = 0 + swap_free_bytes = 0 + + if ram_result.returncode == 0: + total_ram_bytes = int(ram_result.stdout.strip()) + + if swap_result.returncode == 0: + # Format: total = 4096.00M used = 0.00M free = 4096.00M (encrypted) + swap_output = swap_result.stdout.strip() + # Parse the swap output + total_match = re.search(r'total\s*=\s*([\d.]+)([KMGT]?)', swap_output, re.IGNORECASE) + used_match = re.search(r'used\s*=\s*([\d.]+)([KMGT]?)', swap_output, re.IGNORECASE) + free_match = re.search(r'free\s*=\s*([\d.]+)([KMGT]?)', swap_output, re.IGNORECASE) + + def parse_size(value: str, unit: str) -> int: + """Convert size string to bytes""" + val = float(value) + unit = unit.upper() + if unit == 'K': + return int(val * 1024) + elif unit == 'M': + return int(val * 1024 * 1024) + elif unit == 'G': + return int(val * 1024 * 1024 * 1024) + elif unit == 'T': + return int(val * 1024 * 1024 * 1024 * 1024) + else: + return int(val) + + if total_match: + swap_total_bytes = parse_size(total_match.group(1), total_match.group(2) or 'M') + if used_match: + swap_used_bytes = parse_size(used_match.group(1), used_match.group(2) or 'M') + if free_match: + swap_free_bytes = parse_size(free_match.group(1), free_match.group(2) or 'M') + + swap_total_gb = swap_total_bytes / (1024 ** 3) + swap_used_gb = swap_used_bytes / (1024 ** 3) + swap_free_gb = swap_free_bytes / (1024 ** 3) + swap_percent = (swap_used_bytes / swap_total_bytes * 100) if swap_total_bytes > 0 else 0 + + if total_ram_bytes: + total_ram_gb = total_ram_bytes / (1024 ** 3) + return { + "total_ram_bytes": total_ram_bytes, + "total_ram_gb": round(total_ram_gb, 2), + "total_ram_mb": round(total_ram_bytes / (1024 ** 2), 2), + "total_ram_human": f"{total_ram_gb:.2f} GB", + "swap_total_bytes": swap_total_bytes, + "swap_total_gb": round(swap_total_gb, 2), + "swap_total_human": f"{swap_total_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_used_bytes": swap_used_bytes, + "swap_used_gb": round(swap_used_gb, 2), + "swap_used_human": f"{swap_used_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_free_bytes": swap_free_bytes, + "swap_free_gb": round(swap_free_gb, 2), + "swap_free_human": f"{swap_free_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_percent": round(swap_percent, 2) if swap_total_bytes > 0 else 0 + } + except Exception: + pass + elif system == "Windows": + try: + # Get RAM + ram_result = subprocess.run( + ["wmic", "computersystem", "get", "TotalPhysicalMemory"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + + # Get swap info (page file) + swap_result = subprocess.run( + ["wmic", "pagefile", "get", "AllocatedBaseSize,CurrentUsage"], + capture_output=True, + text=True, + timeout=5, + check=False + ) + + total_ram_bytes = None + swap_total_bytes = 0 + swap_used_bytes = 0 + swap_free_bytes = 0 + + if ram_result.returncode == 0: + lines = ram_result.stdout.strip().split("\n") + if len(lines) > 1: + total_ram_bytes = int(lines[1].strip()) + + if swap_result.returncode == 0: + # Parse page file info (size is in MB) + lines = swap_result.stdout.strip().split("\n") + for line in lines[1:]: # Skip header + parts = line.split() + if len(parts) >= 2: + try: + allocated_mb = int(parts[0]) + used_mb = int(parts[1]) if parts[1].isdigit() else 0 + swap_total_bytes = allocated_mb * 1024 * 1024 + swap_used_bytes = used_mb * 1024 * 1024 + swap_free_bytes = swap_total_bytes - swap_used_bytes + break + except (ValueError, IndexError): + continue + + swap_total_gb = swap_total_bytes / (1024 ** 3) + swap_used_gb = swap_used_bytes / (1024 ** 3) + swap_free_gb = swap_free_bytes / (1024 ** 3) + swap_percent = (swap_used_bytes / swap_total_bytes * 100) if swap_total_bytes > 0 else 0 + + if total_ram_bytes: + total_ram_gb = total_ram_bytes / (1024 ** 3) + return { + "total_ram_bytes": total_ram_bytes, + "total_ram_gb": round(total_ram_gb, 2), + "total_ram_mb": round(total_ram_bytes / (1024 ** 2), 2), + "total_ram_human": f"{total_ram_gb:.2f} GB", + "swap_total_bytes": swap_total_bytes, + "swap_total_gb": round(swap_total_gb, 2), + "swap_total_human": f"{swap_total_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_used_bytes": swap_used_bytes, + "swap_used_gb": round(swap_used_gb, 2), + "swap_used_human": f"{swap_used_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_free_bytes": swap_free_bytes, + "swap_free_gb": round(swap_free_gb, 2), + "swap_free_human": f"{swap_free_gb:.2f} GB" if swap_total_bytes > 0 else "0 GB", + "swap_percent": round(swap_percent, 2) if swap_total_bytes > 0 else 0 + } + except Exception: + pass + + return { + "total_ram_bytes": None, + "total_ram_gb": None, + "total_ram_mb": None, + "total_ram_human": "Unknown", + "swap_total_bytes": 0, + "swap_total_gb": 0, + "swap_total_human": "0 GB", + "swap_used_bytes": 0, + "swap_used_gb": 0, + "swap_used_human": "0 GB", + "swap_free_bytes": 0, + "swap_free_gb": 0, + "swap_free_human": "0 GB", + "swap_percent": 0 + } + except Exception as e: + print(f"[installer][system-info] Error getting memory info: {e}") + return { + "total_ram_bytes": None, + "total_ram_gb": None, + "total_ram_mb": None, + "total_ram_human": "Unknown", + "swap_total_bytes": 0, + "swap_total_gb": 0, + "swap_total_human": "0 GB", + "swap_used_bytes": 0, + "swap_used_gb": 0, + "swap_used_human": "0 GB", + "swap_free_bytes": 0, + "swap_free_gb": 0, + "swap_free_human": "0 GB", + "swap_percent": 0 + } + + +def _should_include_mount_point(device: str, mountpoint: str, fstype: str) -> bool: + """ + Determine if a mount point should be included in disk info. + Filters out snap mounts, loop devices, and other virtual filesystems. + """ + # Filter out snap mounts (they're loop devices, part of main filesystem) + if mountpoint.startswith("/snap/"): + return False + + # Filter out loop devices + if device.startswith("/dev/loop"): + return False + + # Filter out virtual filesystems + virtual_fs = ["tmpfs", "devtmpfs", "sysfs", "proc", "devpts", "cgroup", "cgroup2", "pstore", "bpf", "tracefs", "debugfs", "securityfs", "hugetlbfs", "mqueue", "overlay", "autofs", "binfmt_misc"] + if fstype in virtual_fs: + return False + + # Filter out virtual mount points + virtual_mounts = ["/proc", "/sys", "/dev", "/run", "/tmp", "/var/run"] + if mountpoint in virtual_mounts or mountpoint.startswith("/proc/") or mountpoint.startswith("/sys/"): + return False + + return True + + +def get_disk_info() -> Dict[str, Any]: + """ + Get disk info (total space, mount points). + Returns a dictionary with total_disk_space and mount_points (list of dictionaries). + Filters out snap mounts, loop devices, and virtual filesystems to avoid double-counting. + """ + try: + mount_points = [] + + if psutil: + partitions = psutil.disk_partitions() + total_disk_space_bytes = 0 + seen_devices = set() # Track devices to avoid double-counting + + for partition in partitions: + # Skip if we've already seen this device (avoid double-counting) + if partition.device in seen_devices: + continue + + # Filter out snap mounts, loop devices, and virtual filesystems + if not _should_include_mount_point(partition.device, partition.mountpoint, partition.fstype): + continue + + try: + usage = psutil.disk_usage(partition.mountpoint) + + # Only count each device once (use the largest mount point if device appears multiple times) + if partition.device not in seen_devices: + total_disk_space_bytes += usage.total + seen_devices.add(partition.device) + + mount_points.append({ + "device": partition.device, + "mountpoint": partition.mountpoint, + "fstype": partition.fstype, + "total_bytes": usage.total, + "total_gb": round(usage.total / (1024 ** 3), 2), + "used_bytes": usage.used, + "used_gb": round(usage.used / (1024 ** 3), 2), + "free_bytes": usage.free, + "free_gb": round(usage.free / (1024 ** 3), 2), + "percent_used": round(usage.percent, 2) + }) + except PermissionError: + # Skip partitions we don't have permission to access + continue + except Exception as e: + print(f"[installer][system-info] Error getting usage for {partition.mountpoint}: {e}") + continue + + total_disk_space_gb = total_disk_space_bytes / (1024 ** 3) + + # Calculate total used and free disk space + total_used_bytes = sum(mp.get("used_bytes", 0) for mp in mount_points) + total_free_bytes = sum(mp.get("free_bytes", 0) for mp in mount_points) + total_used_gb = total_used_bytes / (1024 ** 3) + total_free_gb = total_free_bytes / (1024 ** 3) + disk_percent = (total_used_bytes / total_disk_space_bytes * 100) if total_disk_space_bytes > 0 else 0 + + # Get disk I/O stats + disk_io = None + if psutil: + try: + io_counters = psutil.disk_io_counters() + if io_counters: + disk_io = { + "read_bytes": io_counters.read_bytes, + "write_bytes": io_counters.write_bytes, + "read_count": io_counters.read_count, + "write_count": io_counters.write_count + } + except Exception: + pass + + return { + "total_disk_space_bytes": total_disk_space_bytes, + "total_disk_space_gb": round(total_disk_space_gb, 2), + "total_disk_space_human": f"{total_disk_space_gb:.2f} GB", + "total_used_bytes": total_used_bytes, + "total_used_gb": round(total_used_gb, 2), + "total_used_human": f"{total_used_gb:.2f} GB", + "total_free_bytes": total_free_bytes, + "total_free_gb": round(total_free_gb, 2), + "total_free_human": f"{total_free_gb:.2f} GB", + "disk_percent": round(disk_percent, 2), + "disk_io": disk_io, + "mount_points": mount_points + } + else: + # Fallback for systems without psutil + system = platform.system() + if system == "Linux": + try: + result = subprocess.run( + ["df", "-B1", "-x", "tmpfs", "-x", "devtmpfs", "-x", "sysfs", "-x", "proc", "-x", "overlay"], + capture_output=True, + text=True, + timeout=10, + check=False + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + total_disk_space_bytes = 0 + seen_devices = set() + + # Get individual mount points (skip header) + for line in lines[1:]: + parts = line.split() + if len(parts) >= 6: + device = parts[0] + mountpoint = parts[5] + fstype = parts[1] if len(parts) > 1 else "unknown" + + # Filter out snap mounts, loop devices, and virtual filesystems + if not _should_include_mount_point(device, mountpoint, fstype): + continue + + # Skip if we've already counted this device + if device in seen_devices: + continue + + total_bytes = int(parts[1]) + total_disk_space_bytes += total_bytes + seen_devices.add(device) + + mount_points.append({ + "device": device, + "mountpoint": mountpoint, + "fstype": fstype, + "total_bytes": total_bytes, + "total_gb": round(total_bytes / (1024 ** 3), 2), + "used_bytes": int(parts[2]), + "used_gb": round(int(parts[2]) / (1024 ** 3), 2), + "free_bytes": int(parts[3]), + "free_gb": round(int(parts[3]) / (1024 ** 3), 2), + "percent_used": round((int(parts[2]) / total_bytes) * 100, 2) if total_bytes > 0 else 0 + }) + + total_disk_space_gb = total_disk_space_bytes / (1024 ** 3) + + # Calculate total used and free + total_used_bytes = sum(mp.get("used_bytes", 0) for mp in mount_points) + total_free_bytes = sum(mp.get("free_bytes", 0) for mp in mount_points) + total_used_gb = total_used_bytes / (1024 ** 3) + total_free_gb = total_free_bytes / (1024 ** 3) + disk_percent = (total_used_bytes / total_disk_space_bytes * 100) if total_disk_space_bytes > 0 else 0 + + return { + "total_disk_space_bytes": total_disk_space_bytes, + "total_disk_space_gb": round(total_disk_space_gb, 2), + "total_disk_space_human": f"{total_disk_space_gb:.2f} GB", + "total_used_bytes": total_used_bytes, + "total_used_gb": round(total_used_gb, 2), + "total_used_human": f"{total_used_gb:.2f} GB", + "total_free_bytes": total_free_bytes, + "total_free_gb": round(total_free_gb, 2), + "total_free_human": f"{total_free_gb:.2f} GB", + "disk_percent": round(disk_percent, 2), + "disk_io": None, + "mount_points": mount_points + } + except Exception as e: + print(f"[installer][system-info] Error getting disk info (Linux fallback): {e}") + elif system == "Darwin": + try: + result = subprocess.run( + ["df", "-B1"], + capture_output=True, + text=True, + timeout=10, + check=False + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + total_disk_space_bytes = 0 + for line in lines[1:]: # Skip header + parts = line.split() + if len(parts) >= 9: + total_bytes = int(parts[1]) + total_disk_space_bytes += total_bytes + mount_points.append({ + "device": parts[0], + "mountpoint": parts[8], + "fstype": parts[2] if len(parts) > 2 else "unknown", + "total_bytes": total_bytes, + "total_gb": round(total_bytes / (1024 ** 3), 2), + "used_bytes": int(parts[2]), + "used_gb": round(int(parts[2]) / (1024 ** 3), 2), + "free_bytes": int(parts[3]), + "free_gb": round(int(parts[3]) / (1024 ** 3), 2), + "percent_used": round((int(parts[2]) / total_bytes) * 100, 2) if total_bytes > 0 else 0 + }) + + total_disk_space_gb = total_disk_space_bytes / (1024 ** 3) + return { + "total_disk_space_bytes": total_disk_space_bytes, + "total_disk_space_gb": round(total_disk_space_gb, 2), + "total_disk_space_human": f"{total_disk_space_gb:.2f} GB", + "mount_points": mount_points + } + except Exception as e: + print(f"[installer][system-info] Error getting disk info (macOS fallback): {e}") + elif system == "Windows": + try: + result = subprocess.run( + ["wmic", "logicaldisk", "get", "size,freespace,caption,filesystem"], + capture_output=True, + text=True, + timeout=10, + check=False + ) + if result.returncode == 0: + lines = result.stdout.strip().split("\n") + total_disk_space_bytes = 0 + for line in lines[1:]: # Skip header + parts = line.split() + if len(parts) >= 4: + caption = parts[0] + fstype = parts[1] if len(parts) > 1 else "unknown" + size = int(parts[2]) if parts[2].isdigit() else 0 + freespace = int(parts[3]) if parts[3].isdigit() else 0 + used = size - freespace + total_disk_space_bytes += size + + mount_points.append({ + "device": caption, + "mountpoint": caption, + "fstype": fstype, + "total_bytes": size, + "total_gb": round(size / (1024 ** 3), 2), + "used_bytes": used, + "used_gb": round(used / (1024 ** 3), 2), + "free_bytes": freespace, + "free_gb": round(freespace / (1024 ** 3), 2), + "percent_used": round((used / size) * 100, 2) if size > 0 else 0 + }) + + total_disk_space_gb = total_disk_space_bytes / (1024 ** 3) + + # Calculate total used and free + total_used_bytes = sum(mp.get("used_bytes", 0) for mp in mount_points) + total_free_bytes = sum(mp.get("free_bytes", 0) for mp in mount_points) + total_used_gb = total_used_bytes / (1024 ** 3) + total_free_gb = total_free_bytes / (1024 ** 3) + disk_percent = (total_used_bytes / total_disk_space_bytes * 100) if total_disk_space_bytes > 0 else 0 + + return { + "total_disk_space_bytes": total_disk_space_bytes, + "total_disk_space_gb": round(total_disk_space_gb, 2), + "total_disk_space_human": f"{total_disk_space_gb:.2f} GB", + "total_used_bytes": total_used_bytes, + "total_used_gb": round(total_used_gb, 2), + "total_used_human": f"{total_used_gb:.2f} GB", + "total_free_bytes": total_free_bytes, + "total_free_gb": round(total_free_gb, 2), + "total_free_human": f"{total_free_gb:.2f} GB", + "disk_percent": round(disk_percent, 2), + "disk_io": None, + "mount_points": mount_points + } + except Exception as e: + print(f"[installer][system-info] Error getting disk info (Windows fallback): {e}") + + return { + "total_disk_space_bytes": None, + "total_disk_space_gb": None, + "total_disk_space_human": "Unknown", + "total_used_bytes": None, + "total_used_gb": None, + "total_used_human": "Unknown", + "total_free_bytes": None, + "total_free_gb": None, + "total_free_human": "Unknown", + "disk_percent": None, + "disk_io": None, + "mount_points": [] + } + except Exception as e: + print(f"[installer][system-info] Error getting disk info: {e}") + return { + "total_disk_space_bytes": None, + "total_disk_space_gb": None, + "total_disk_space_human": "Unknown", + "total_used_bytes": None, + "total_used_gb": None, + "total_used_human": "Unknown", + "total_free_bytes": None, + "total_free_gb": None, + "total_free_human": "Unknown", + "disk_percent": None, + "disk_io": None, + "mount_points": [] + } + + +async def get_all_system_info() -> Dict[str, Any]: + """ + Get all system information (OS, CPU, Memory, Disk, Uptime, Device Model). + Returns a dictionary containing all system information. + """ + try: + loop = asyncio.get_event_loop() + + # Run synchronous functions in executor to avoid blocking + os_info = await loop.run_in_executor(None, get_os_info) + cpu_info = await loop.run_in_executor(None, get_cpu_info) + memory_info = await loop.run_in_executor(None, get_memory_info) + disk_info = await loop.run_in_executor(None, get_disk_info) + + return { + "os": os_info, + "cpu": cpu_info, + "memory": memory_info, + "disk": disk_info + } + except Exception as e: + print(f"[installer][system-info] Error getting all system info: {e}") + return { + "os": get_os_info(), + "cpu": get_cpu_info(), + "memory": get_memory_info(), + "disk": get_disk_info() + } + + +def format_size_gb(size_gb: Optional[float]) -> str: + """ + Format size in GB to a clean string format. + Removes unnecessary decimal places (e.g., "16.00 GB" -> "16 GB"). + + Args: + size_gb: Size in GB as a float, or None if unknown + + Returns: + Formatted string like "16 GB" or "500.50 GB" or "Unknown" + """ + if size_gb is None: + return "Unknown" + + # Round to nearest integer if close to whole number, otherwise keep 2 decimals + if abs(size_gb - round(size_gb)) < 0.01: + return f"{int(round(size_gb))} GB" + else: + return f"{size_gb:.2f} GB" + + +async def get_formatted_system_info() -> Dict[str, Any]: + """ + Get system information formatted according to the server schema. + Returns a dictionary with "action" and "data" keys matching the expected format. + + Returns: + { + "os": { + "name": "Ubuntu", + "version": "22.04", + "release": "22.04.3 LTS" + }, + "cpu": { + "cores": 8, + "model": "Intel Core i7", + "architecture": "x86_64" + }, + "memory": { + "total_ram": "16 GB", + "swap": { + "total": "4 GB", + "used": "0.5 GB", + "free": "3.5 GB", + "percent": 12.5 + } + }, + "disk": { + "total_space": "500 GB", + "mount_points": [ + { + "path": "/", + "total": "500 GB", + "available": "200 GB" + } + ] + } + } + """ + try: + # Get all system info + system_info = await get_all_system_info() + + # Format OS info + os_data = { + "name": system_info.get("os", {}).get("os_name", "Unknown"), + "version": system_info.get("os", {}).get("os_version", "Unknown"), + "release": system_info.get("os", {}).get("os_release", "Unknown"), + "kernel": system_info.get("os", {}).get("os_release", "Unknown") # Kernel version (same as release on Linux) + } + + # Format CPU info + cpu_info_raw = system_info.get("cpu", {}) + cpu_data = { + "cores": cpu_info_raw.get("cpu_cores"), + "model": cpu_info_raw.get("cpu_model", "Unknown"), + "architecture": cpu_info_raw.get("cpu_architecture", "Unknown"), + "usage_percent": round(cpu_info_raw.get("cpu_usage_percent", 0), 2) if cpu_info_raw.get("cpu_usage_percent") is not None else None, + "load_avg": cpu_info_raw.get("cpu_load_avg"), + "temperature": cpu_info_raw.get("cpu_temperature") + } + + # Format Memory info - extract GB value and format as string + memory_info_raw = system_info.get("memory", {}) + memory_gb = memory_info_raw.get("total_ram_gb") + swap_total_gb = memory_info_raw.get("swap_total_gb", 0) + swap_used_gb = memory_info_raw.get("swap_used_gb", 0) + swap_free_gb = memory_info_raw.get("swap_free_gb", 0) + swap_percent = memory_info_raw.get("swap_percent", 0) + + used_ram_gb = memory_info_raw.get("used_ram_gb") + free_ram_gb = memory_info_raw.get("free_ram_gb") + ram_percent = memory_info_raw.get("ram_percent", 0) + + memory_data = { + "total_ram": format_size_gb(memory_gb) if memory_gb is not None else "Unknown", + "used_ram": format_size_gb(used_ram_gb) if used_ram_gb is not None else "Unknown", + "free_ram": format_size_gb(free_ram_gb) if free_ram_gb is not None else "Unknown", + "ram_percent": round(ram_percent, 2) if ram_percent is not None else 0, + "swap": { + "total": format_size_gb(swap_total_gb) if swap_total_gb > 0 else "0 GB", + "used": format_size_gb(swap_used_gb) if swap_total_gb > 0 else "0 GB", + "free": format_size_gb(swap_free_gb) if swap_total_gb > 0 else "0 GB", + "percent": round(swap_percent, 2) if swap_total_gb > 0 else 0 + } + } + + # Format Disk info + disk_info_raw = system_info.get("disk", {}) + disk_total_gb = disk_info_raw.get("total_disk_space_gb") + disk_used_gb = disk_info_raw.get("total_used_gb") + disk_free_gb = disk_info_raw.get("total_free_gb") + disk_percent = disk_info_raw.get("disk_percent", 0) + disk_io_raw = disk_info_raw.get("disk_io") + + # Format disk I/O as human-readable strings + disk_io = None + if disk_io_raw: + read_bytes = disk_io_raw.get("read_bytes", 0) + write_bytes = disk_io_raw.get("write_bytes", 0) + read_gb = read_bytes / (1024 ** 3) + write_gb = write_bytes / (1024 ** 3) + + disk_io = { + "read_bytes": format_size_gb(read_gb), + "write_bytes": format_size_gb(write_gb), + "read_count": disk_io_raw.get("read_count", 0), + "write_count": disk_io_raw.get("write_count", 0) + } + + disk_data = { + "total_space": format_size_gb(disk_total_gb) if disk_total_gb is not None else "Unknown", + "used_space": format_size_gb(disk_used_gb) if disk_used_gb is not None else "Unknown", + "free_space": format_size_gb(disk_free_gb) if disk_free_gb is not None else "Unknown", + "disk_percent": round(disk_percent, 2) if disk_percent is not None else 0, + "disk_io": disk_io, + "mount_points": [] + } + + # Format mount points - simplify to just path, total, and available + mount_points_raw = disk_info_raw.get("mount_points", []) + for mp in mount_points_raw: + mount_point = { + "path": mp.get("mountpoint", "Unknown"), + "total": format_size_gb(mp.get("total_gb")), + "available": format_size_gb(mp.get("free_gb")) + } + disk_data["mount_points"].append(mount_point) + + # Return formatted response + return { + "os": os_data, + "cpu": cpu_data, + "memory": memory_data, + "disk": disk_data + } + except Exception as e: + print(f"[installer][system-info] Error formatting system info: {e}") + # Return minimal error response + return { + "os": {"name": "Unknown", "version": "Unknown", "release": "Unknown"}, + "cpu": {"cores": None, "model": "Unknown", "architecture": "Unknown"}, + "memory": {"total_ram": "Unknown"}, + "disk": {"total_space": "Unknown", "mount_points": []} + } + diff --git a/Framework/install_handler/utils.py b/Framework/install_handler/utils.py index cedafa9f0..d1ea521ff 100644 --- a/Framework/install_handler/utils.py +++ b/Framework/install_handler/utils.py @@ -1,25 +1,67 @@ +import datetime import httpx +import platform from Framework.Utilities import RequestFormatter, ConfigModule, CommonUtil debug = False +version = "2.0.0" +current_os = platform.system().lower() + def read_node_id(): return CommonUtil.MachineInfo().getLocalUser().lower() +def generate_services_list(services): + filtered_services = [] + for category in services: + filtered_category = { + "group": category["group"], + "category": category["category"], + "services": [] + } + for service in category["services"]: + if current_os not in service["os"]: + continue + + filtered_service = { + "name": service["name"], + "status": service["status"], + "comment": service["comment"], + "install_text": service["install_text"], + "check_text": service["check_text"], + "user_password": service["user_password"] + } + filtered_category["services"].append(filtered_service) + + filtered_services.append(filtered_category) + + return filtered_services + + async def send_response(data=None) -> None: try: + from Framework.install_handler.route import services + api_key = ConfigModule.get_config_value("Authentication", "api-key") url = RequestFormatter.form_uri("d/nodes/install/server/push") - payload = { - "node_id": read_node_id(), - "data": data - } + data['last_updated'] = datetime.datetime.now(datetime.timezone.utc).timestamp() + data['version'] = version + data['node_id'] = read_node_id() + + services_list = generate_services_list(services) + + if data['action'] in ["status", "group_status", "services_update"]: + data['all_data'] = { + "system_info": None, + "services": services_list + } + if debug: - print(f"[installer] Sending response to server: {payload}") + print(f"[installer] Sending response to server: {data}") async with httpx.AsyncClient(timeout=30.0, verify=False) as client: - resp = await client.post(url, json=payload, headers={"X-API-KEY": api_key}) + resp = await client.post(url, json=data, headers={"X-API-KEY": api_key}) if debug: print(f"[installer] Response status: {resp.status_code}") print(f"[installer] Response content: {resp.content}") @@ -28,4 +70,3 @@ async def send_response(data=None) -> None: print(f"[installer] Failed to send response: {resp.status_code}") except Exception as e: print(f"[installer] Error sending response: {e}") - diff --git a/Framework/install_handler/web/chrome_for_testing.py b/Framework/install_handler/web/chrome_for_testing.py index 248cefa52..9d54348f1 100644 --- a/Framework/install_handler/web/chrome_for_testing.py +++ b/Framework/install_handler/web/chrome_for_testing.py @@ -1,7 +1,148 @@ -async def check_status(): - print("[chrome_for_testing] Checking status...") +import asyncio +from Framework.Built_In_Automation.Web.Selenium.utils import ChromeForTesting +from Framework.install_handler.utils import send_response -async def install(): - print("[chrome_for_testing] Installing...") +async def check_status() -> bool: + """Check if Chrome for Testing is installed.""" + print("[installer][web-chrome_for_testing] Checking status...") + + try: + cft = ChromeForTesting() + + # Get the latest version + latest_version = cft.get_latest_version(channel="Stable", force_check=False) + + if not latest_version: + print("[installer][web-chrome_for_testing] Not installed (no version available)") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Chrome For Testing", + "status": "not installed", + "comment": "Install Chrome for Testing to use it.", + } + }) + return False + + # Check if the latest version is installed + is_installed = cft.is_version_installed(latest_version) + + if is_installed: + print(f"[installer][web-chrome_for_testing] Already installed (version: {latest_version})") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Chrome For Testing", + "status": "installed", + "comment": f"Chrome for Testing is installed (version: {latest_version})", + } + }) + return True + else: + print("[installer][web-chrome_for_testing] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Chrome For Testing", + "status": "not installed", + "comment": "Chrome for Testing is not installed. Install it to use it.", + } + }) + return False + except Exception as e: + print(f"[installer][web-chrome_for_testing] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Chrome For Testing", + "status": "not installed", + "comment": "Unable to check Chrome for Testing status.", + } + }) + return False + + +async def install() -> bool: + """Install Chrome for Testing.""" + print("[installer][web-chrome_for_testing] Installing...") + + # Check if already installed + if await check_status(): + print("[installer][web-chrome_for_testing] Chrome for Testing is already installed") + return True + + try: + cft = ChromeForTesting() + + # Send initial status update + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "ChromeForTesting", + "status": "installing", + "comment": "Download started. Check the terminal on which ZeuZ Node is open for updates", + } + }) + + # Wrap synchronous setup_chrome_for_testing in executor to avoid blocking event loop + loop = asyncio.get_event_loop() + chrome_bin, driver_bin = await loop.run_in_executor( + None, + lambda: cft.setup_chrome_for_testing(version=None, channel="Stable") + ) + + if chrome_bin and driver_bin: + print("[installer][web-chrome_for_testing] Chrome for Testing installation successful") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "ChromeForTesting", + "status": "installed", + "comment": "Chrome for Testing is installed", + } + }) + return True + else: + print("[installer][web-chrome_for_testing] Chrome for Testing installation failed") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "ChromeForTesting", + "status": "not installed", + "comment": "Failed to install Chrome for Testing", + } + }) + return False + except FileNotFoundError as e: + print(f"[installer][web-chrome_for_testing] Error installing: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "ChromeForTesting", + "status": "not installed", + "comment": f"Failed to install Chrome for Testing: Required binaries not found", + } + }) + return False + except Exception as e: + print(f"[installer][web-chrome_for_testing] Error installing: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "ChromeForTesting", + "status": "not installed", + "comment": f"Failed to install Chrome for Testing: {str(e)}", + } + }) + return False diff --git a/Framework/install_handler/web/edge.py b/Framework/install_handler/web/edge.py new file mode 100644 index 000000000..622349cc8 --- /dev/null +++ b/Framework/install_handler/web/edge.py @@ -0,0 +1,736 @@ +import subprocess +import platform +import httpx +import asyncio +import os +import shutil +import json +from pathlib import Path +from Framework.install_handler.utils import send_response +from settings import ZEUZ_NODE_DOWNLOADS_DIR + + +def _is_windows(): + """Check if running on Windows""" + return platform.system() == 'Windows' + + +def _is_linux(): + """Check if running on Linux""" + return platform.system() == 'Linux' + + +def _is_darwin(): + """Check if running on macOS""" + return platform.system() == 'Darwin' + + +def _get_linux_package_manager(): + """Detect Linux package manager""" + if shutil.which("apt-get"): + return "apt" + elif shutil.which("dnf"): + return "dnf" + elif shutil.which("yum"): + return "yum" + elif shutil.which("pacman"): + return "pacman" + elif shutil.which("zypper"): + return "zypper" + elif shutil.which("apk"): + return "apk" + elif shutil.which("emerge"): + return "emerge" + elif shutil.which("nix"): + return "nix" + return None + + +async def check_status() -> bool: + """Check if Microsoft Edge is installed.""" + print("[installer][web-edge] Checking status...") + + try: + result = None + + if _is_windows(): + # Windows: Check registry for Edge installation + ps_command = ''' + $edge = Get-ItemProperty "HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*", + "HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*" | + Where-Object { $_.DisplayName -like "*Edge*" } | + Select-Object -First 1 + if ($edge) { + @{ + InstallLocation = $edge.InstallLocation + DisplayVersion = $edge.DisplayVersion + DisplayName = $edge.DisplayName + } | ConvertTo-Json + } + ''' + result = subprocess.run( + ["powershell", "-Command", ps_command], + capture_output=True, + text=True, + check=False + ) + + # If registry check found Edge, parse JSON and get version + if result.returncode == 0 and result.stdout.strip(): + try: + edge_info = json.loads(result.stdout.strip()) + display_version = edge_info.get('DisplayVersion', '') + + # Use DisplayVersion from registry directly + if display_version: + version_text = f"Microsoft Edge {display_version}" + else: + # Found in registry but couldn't get version + version_text = "Microsoft Edge" + + if version_text: + result.stdout = version_text + result.returncode = 0 + else: + result.returncode = 1 + except (json.JSONDecodeError, KeyError, Exception): + # If JSON parsing fails, Edge is still installed (found in registry) + result.stdout = "Microsoft Edge" + result.returncode = 0 + else: + # Not found in registry, set returncode to indicate not installed + result.returncode = 1 + else: + # Linux: try different possible command names + commands = ["microsoft-edge", "--version"] + result = subprocess.run( + commands, + capture_output=True, + text=True, + check=False + ) + + if result.returncode != 0: + # Try alternative command on Linux + result = subprocess.run( + ["edge", "--version"], + capture_output=True, + text=True, + check=False + ) + + if result.returncode != 0: + print("[installer][web-edge] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Install Microsoft Edge to use it.", + } + }) + return False + + # Edge version output is typically in stdout or stderr + version_text = (result.stdout or result.stderr).strip() + if not version_text: + print("[installer][web-edge] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Install Microsoft Edge to use it.", + } + }) + return False + + print("[installer][web-edge] Already installed") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "installed", + "comment": f"Microsoft Edge is installed (version: {version_text[:50]})", + } + }) + return True + except (FileNotFoundError, OSError): + # Edge command not found - Edge is not installed + print("[installer][web-edge] Not installed (msedge not found)") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Install Microsoft Edge to use it.", + } + }) + return False + except Exception as e: + print(f"[installer][web-edge] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Unable to check Microsoft Edge status.", + } + }) + return False + + +async def _download_edge_installer(): + """Download Edge installer based on platform""" + print("[installer][web-edge] Downloading Microsoft Edge installer...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "installing", + "comment": "Downloading Microsoft Edge installer...", + } + }) + + download_dir = ZEUZ_NODE_DOWNLOADS_DIR / "edge" + download_dir.mkdir(parents=True, exist_ok=True) + + system = platform.system().lower() + arch = platform.machine().lower() + + try: + if system == "windows": + # Windows: Download MSI installer + installer_url = "https://go.microsoft.com/fwlink/?linkid=2109048&Channel=Stable&language=en" + installer_path = download_dir / "MicrosoftEdgeSetup.msi" + elif system == "linux": + installer_url = "https://go.microsoft.com/fwlink?linkid=2149051&brand=M102" + installer_path = download_dir / "microsoft-edge-stable.deb" + elif system == "darwin": + # macOS: Download .pkg installer from Microsoft Edge website + installer_url = "https://go.microsoft.com/fwlink/?linkid=2093504" + installer_path = download_dir / "MicrosoftEdge.pkg" + else: + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": f"Unsupported platform: {system}", + } + }) + return None + + async with httpx.AsyncClient(timeout=900.0, follow_redirects=True) as client: + async with client.stream("GET", installer_url) as response: + response.raise_for_status() + + total_size = int(response.headers.get("content-length", 0)) + chunk_size = 8192 + downloaded = 0 + + count = [] + with open(installer_path, "wb") as f: + async for chunk in response.aiter_bytes(chunk_size): + f.write(chunk) + downloaded += len(chunk) + + if total_size > 0: + progress = (downloaded / total_size) * 100 + bar_length = 50 + filled_length = int(bar_length * downloaded // total_size) + bar = '█' * filled_length + '-' * (bar_length - filled_length) + + mb_downloaded = downloaded / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + + print(f"\r[installer][web-edge] |{bar}| {progress:.1f}% ({mb_downloaded:.1f}/{mb_total:.1f} MB)", end='', flush=True) + + p = round(mb_downloaded/mb_total, 1) + if p not in count: + count.append(p) + asyncio.create_task(send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "installing", + "comment": f"Downloading Microsoft Edge... {progress:.1f}% ({mb_downloaded:.1f}/{mb_total:.1f} MB)", + } + })) + + print() + print(f"[installer][web-edge] Download complete: {installer_path}") + return installer_path + except Exception as e: + print(f"\n[installer][web-edge] Download failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": f"Microsoft Edge download failed: {str(e)}", + } + }) + return None + + +async def _install_edge_windows(installer_path, user_password: str = ""): + """Install Edge on Windows""" + print("[installer][web-edge] Installing Microsoft Edge on Windows...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "installing", + "comment": "Installing Microsoft Edge...", + } + }) + + try: + # Helper function to run commands with elevation if password provided + def run_elevated(cmd_list): + if user_password: + # Use PowerShell to run with credentials + # Note: This requires username, so we'll use current user + import getpass + username = getpass.getuser() + # Create secure string and run with credentials + # Properly escape arguments for PowerShell + escaped_args = [] + for arg in cmd_list[1:]: + escaped_arg = arg.replace('"', '`"').replace('$', '`$') + escaped_args.append(f'"{escaped_arg}"') + args_str = ','.join(escaped_args) + ps_script = f''' + $password = ConvertTo-SecureString -String "{user_password}" -AsPlainText -Force + $credential = New-Object System.Management.Automation.PSCredential("{username}", $password) + Start-Process -FilePath "{cmd_list[0]}" -ArgumentList {args_str} -Credential $credential -Wait -NoNewWindow + ''' + return subprocess.run( + ["powershell", "-Command", ps_script], + capture_output=True, + text=True, + check=False + ) + else: + # Try with RunAs elevation prompt + # Use Start-Process with -Verb RunAs for elevation + if cmd_list[0] == "winget": + args_str = ' '.join([f'"{arg}"' for arg in cmd_list[1:]]) + ps_script = f'Start-Process -FilePath "winget" -ArgumentList {args_str} -Verb RunAs -Wait -NoNewWindow' + elif cmd_list[0] == "msiexec": + args_str = ' '.join([f'"{arg}"' for arg in cmd_list[1:]]) + ps_script = f'Start-Process -FilePath "msiexec" -ArgumentList {args_str} -Verb RunAs -Wait -NoNewWindow' + else: + return subprocess.run(cmd_list, capture_output=True, text=True, check=False) + + return subprocess.run( + ["powershell", "-Command", ps_script], + capture_output=True, + text=True, + check=False + ) + + # Try using winget first (Windows 10/11) + winget_result = run_elevated(["winget", "install", "--id", "Microsoft.Edge", "--silent", "--accept-package-agreements", "--accept-source-agreements"]) + + if winget_result.returncode == 0: + print("[installer][web-edge] Microsoft Edge installed via winget") + return True + + # Fallback to MSI installer + if installer_path and installer_path.exists(): + msi_result = run_elevated(["msiexec", "/i", str(installer_path), "/quiet", "/norestart"]) + + if msi_result.returncode == 0: + print("[installer][web-edge] Microsoft Edge installed via MSI") + return True + else: + print(f"[installer][web-edge] Installation failed. Error: {msi_result.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + else: + print("[installer][web-edge] Installer not found, trying direct download") + # Try direct download URL (this will prompt for elevation if needed) + if user_password: + import getpass + username = getpass.getuser() + ps_script = f''' + $password = ConvertTo-SecureString -String "{user_password}" -AsPlainText -Force + $credential = New-Object System.Management.Automation.PSCredential("{username}", $password) + Start-Process "https://go.microsoft.com/fwlink/?linkid=2109048" -Credential $credential -Wait + ''' + download_result = subprocess.run( + ["powershell", "-Command", ps_script], + capture_output=True, + text=True, + check=False + ) + else: + download_result = subprocess.run( + ["powershell", "-Command", "Start-Process", "https://go.microsoft.com/fwlink/?linkid=2109048", "-Wait"], + capture_output=True, + text=True, + check=False + ) + + if download_result.returncode == 0: + return True + else: + print(f"[installer][web-edge] Installation failed. Error: {download_result.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + + # All installation methods failed + print(f"[installer][web-edge] Installation failed. Error: {winget_result.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + except Exception as e: + print(f"[installer][web-edge] Windows installation failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + + +async def _install_edge_linux(installer_path, user_password: str = ""): + """Install Edge on Linux""" + print("[installer][web-edge] Installing Microsoft Edge on Linux...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "installing", + "comment": "Installing Microsoft Edge...", + } + }) + + try: + pkg_manager = _get_linux_package_manager() + + # Helper function to run sudo commands with password if provided + def run_sudo(cmd_list): + if user_password: + # Use echo to pipe password to sudo -S (read password from stdin) + cmd = f"echo '{user_password}' | sudo -S {' '.join(cmd_list[1:])}" + return subprocess.run(cmd, shell=True, capture_output=True, text=True, check=False) + else: + return subprocess.run(cmd_list, capture_output=True, text=True, check=False) + + if pkg_manager == "apt": + # Try installing via apt (if repository is configured) + apt_result = run_sudo(["sudo", "apt-get", "update"]) + + apt_install_result = run_sudo(["sudo", "apt-get", "install", "-y", "microsoft-edge-stable"]) + + if apt_install_result.returncode == 0: + print("[installer][web-edge] Microsoft Edge installed via apt") + return True + + + # Fallback to .deb package + if installer_path and installer_path.exists(): + deb_result = run_sudo(["sudo", "dpkg", "-i", str(installer_path)]) + + if deb_result.returncode != 0: + # Install dependencies if needed + run_sudo(["sudo", "apt-get", "install", "-f", "-y"]) + deb_result = run_sudo(["sudo", "dpkg", "-i", str(installer_path)]) + + if deb_result.returncode == 0: + print("[installer][web-edge] Microsoft Edge installed via .deb package") + return True + + # .deb installation failed + print(f"[installer][web-edge] Installation failed. Error: {deb_result.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + else: + # No .deb package available and apt failed + print(f"[installer][web-edge] Installation failed. Error: {apt_install_result.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + + elif pkg_manager == "yum": + # Try installing via yum + yum_result = run_sudo(["sudo", "yum", "install", "-y", "microsoft-edge-stable"]) + + if yum_result.returncode == 0: + print("[installer][web-edge] Microsoft Edge installed via yum") + return True + + # Installation failed + print(f"[installer][web-edge] Installation failed. Error: {yum_result.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + + elif pkg_manager == "dnf": + # Try installing via dnf + dnf_result = run_sudo(["sudo", "dnf", "install", "-y", "microsoft-edge-stable"]) + + if dnf_result.returncode == 0: + print("[installer][web-edge] Microsoft Edge installed via dnf") + return True + + # Installation failed + print(f"[installer][web-edge] Installation failed. Error: {dnf_result.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + except Exception as e: + print(f"[installer][web-edge] Linux installation failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + + +async def _install_edge_darwin(installer_path, user_password: str = ""): + """Install Edge on macOS""" + print("[installer][web-edge] Installing Microsoft Edge on macOS...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "installing", + "comment": "Installing Microsoft Edge...", + } + }) + + try: + # Try using homebrew first (doesn't need sudo) + brew_result = subprocess.run( + ["brew", "install", "--cask", "microsoft-edge"], + capture_output=True, + text=True, + check=False + ) + + if brew_result.returncode == 0: + print("[installer][web-edge] Microsoft Edge installed via homebrew") + return True + + # Fallback to .pkg installer (needs sudo) + if installer_path and installer_path.exists(): + if user_password: + # Use echo to pipe password to sudo -S + cmd = f"echo '{user_password}' | sudo -S installer -pkg {str(installer_path)} -target /" + pkg_result = subprocess.run(cmd, shell=True, capture_output=True, text=True, check=False) + else: + pkg_result = subprocess.run( + ["sudo", "installer", "-pkg", str(installer_path), "-target", "/"], + capture_output=True, + text=True, + check=False + ) + + if pkg_result.returncode == 0: + print("[installer][web-edge] Microsoft Edge installed via .pkg") + return True + else: + print(f"[installer][web-edge] Installation failed. Error: {pkg_result.stderr}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + + # All installation methods failed + if brew_result.returncode != 0: + print(f"[installer][web-edge] Installation failed. Error: {brew_result.stderr}") + + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + except Exception as e: + print(f"[installer][web-edge] macOS installation failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": "Installation failed. Please ensure you have provided the correct password.", + } + }) + return False + + +async def _verify_edge_installation(): + """Verify that Edge is properly installed""" + print("[installer][web-edge] Verifying Microsoft Edge installation...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "installing", + "comment": "Verifying Microsoft Edge installation...", + } + }) + + # Wait a moment for installation to complete + await asyncio.sleep(2) + + # Check if Edge is installed by running check_status + return await check_status() + + +async def install(user_password: str = "") -> bool: + """Main function to install Microsoft Edge""" + print("[installer][web-edge] Installing Microsoft Edge...") + + # Check if Edge is already installed + if await check_status(): + print("[installer][web-edge] Microsoft Edge is already installed") + return True + + installer_path = None + system = platform.system().lower() + + # Download installer if needed + if system == "windows" or system == "darwin" or system == "linux": + installer_path = await _download_edge_installer() + if not installer_path: + return False + + # Install based on platform + if system == "windows": + success = await _install_edge_windows(installer_path, user_password) + elif system == "linux": + success = await _install_edge_linux(installer_path, user_password) + elif system == "darwin": + success = await _install_edge_darwin(installer_path, user_password) + else: + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "not installed", + "comment": f"Unsupported platform: {system}", + } + }) + return False + + if not success: + return False + + # Verify installation + if not await _verify_edge_installation(): + print("[installer][web-edge] Microsoft Edge installation verification failed") + return False + + print("[installer][web-edge] Microsoft Edge installation complete") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Edge", + "status": "installed", + "comment": "Microsoft Edge is installed", + } + }) + return True diff --git a/Framework/install_handler/web/mozilla.py b/Framework/install_handler/web/mozilla.py new file mode 100644 index 000000000..fedfdc8aa --- /dev/null +++ b/Framework/install_handler/web/mozilla.py @@ -0,0 +1,863 @@ +import subprocess +import platform +import shutil +import httpx +import asyncio +import os +import json +import tarfile +import re +from pathlib import Path +from Framework.install_handler.utils import send_response +from settings import ZEUZ_NODE_DOWNLOADS_DIR + + +def _is_windows(): + """Check if running on Windows""" + return platform.system() == 'Windows' + + +def _is_linux(): + """Check if running on Linux""" + return platform.system() == 'Linux' + + +def _is_darwin(): + """Check if running on macOS""" + return platform.system() == 'Darwin' + + +def _get_linux_package_manager(): + """Detect Linux package manager""" + if shutil.which("apt-get"): + return "apt" + elif shutil.which("dnf"): + return "dnf" + elif shutil.which("yum"): + return "yum" + elif shutil.which("pacman"): + return "pacman" + elif shutil.which("zypper"): + return "zypper" + elif shutil.which("apk"): + return "apk" + elif shutil.which("emerge"): + return "emerge" + elif shutil.which("nix"): + return "nix" + return None + + +async def check_status() -> bool: + """Check if Mozilla Firefox is installed.""" + print("[installer][web-mozilla] Checking status...") + + try: + result = None + + if platform.system() == "Windows": + # Windows: Check registry for Firefox installation + ps_command = ''' + $firefox = Get-ItemProperty "HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*", + "HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*" | + Where-Object { $_.DisplayName -like "*Firefox*" } | + Select-Object -First 1 + if ($firefox) { + @{ + InstallLocation = $firefox.InstallLocation + DisplayVersion = $firefox.DisplayVersion + DisplayName = $firefox.DisplayName + } | ConvertTo-Json + } + ''' + result = subprocess.run( + ["powershell", "-Command", ps_command], + capture_output=True, + text=True, + check=False + ) + + # If registry check found Firefox, parse JSON and get version + if result.returncode == 0 and result.stdout.strip(): + try: + firefox_info = json.loads(result.stdout.strip()) + install_location = firefox_info.get('InstallLocation', '') + display_version = firefox_info.get('DisplayVersion', '') + + # Try to get version from firefox.exe if we have install location + version_text = None + if install_location and install_location != '': + firefox_exe = Path(install_location) / "firefox.exe" + if firefox_exe.exists(): + version_result = subprocess.run( + [str(firefox_exe), "--version"], + capture_output=True, + text=True, + check=False + ) + if version_result.returncode == 0: + version_text = (version_result.stdout or version_result.stderr).strip() + + # Use DisplayVersion from registry if we couldn't get it from exe + if not version_text and display_version: + version_text = f"Mozilla Firefox {display_version}" + elif not version_text: + # Found in registry but couldn't get version + version_text = "Mozilla Firefox" + + if version_text: + result.stdout = version_text + result.returncode = 0 + else: + result.returncode = 1 + except (json.JSONDecodeError, KeyError, Exception): + # If JSON parsing fails, Firefox is still installed (found in registry) + result.stdout = "Mozilla Firefox" + result.returncode = 0 + else: + # Not found in registry, set returncode to indicate not installed + result.returncode = 1 + elif platform.system() == "Linux": + # Linux: try firefox command + result = subprocess.run( + ["firefox", "--version"], + capture_output=True, + text=True, + check=False + ) + + # If not found, try using shutil.which + if result.returncode != 0: + firefox_path = shutil.which("firefox") + if firefox_path: + result = subprocess.run( + [firefox_path, "--version"], + capture_output=True, + text=True, + check=False + ) + elif platform.system() == "Darwin": + # macOS: try firefox command + result = subprocess.run( + ["firefox", "--version"], + capture_output=True, + text=True, + check=False + ) + + # If not found, try using shutil.which + if result.returncode != 0: + firefox_path = shutil.which("firefox") + if firefox_path: + result = subprocess.run( + [firefox_path, "--version"], + capture_output=True, + text=True, + check=False + ) + else: + # Default fallback for other platforms + result = subprocess.run( + ["firefox", "--version"], + capture_output=True, + text=True, + check=False + ) + + if result.returncode != 0: + print("[installer][web-mozilla] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": "Install Mozilla Firefox to use it.", + } + }) + return False + + # Firefox version output is typically in stdout or stderr + version_text = (result.stdout or result.stderr).strip() + if not version_text: + print("[installer][web-mozilla] Not installed") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": "Install Mozilla Firefox to use it.", + } + }) + return False + + print("[installer][web-mozilla] Already installed") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installed", + "comment": f"Mozilla Firefox is installed version: {version_text[:50]}", + } + }) + return True + except (FileNotFoundError, OSError): + # Firefox command not found - Firefox is not installed + print("[installer][web-mozilla] Not installed (firefox not found)") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": "Install Mozilla Firefox to use it.", + } + }) + return False + except Exception as e: + print(f"[installer][web-mozilla] Error checking status: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": "Unable to check Mozilla Firefox status.", + } + }) + return False + + +async def _download_firefox_installer(): + """Download Firefox installer based on platform""" + print("[installer][web-mozilla] Downloading Mozilla Firefox installer...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": "Downloading Mozilla Firefox installer...", + } + }) + + download_dir = ZEUZ_NODE_DOWNLOADS_DIR / "firefox" + download_dir.mkdir(parents=True, exist_ok=True) + + system = platform.system().lower() + arch = platform.machine().lower() + + try: + if system == "windows": + # Windows: Download .exe installer + # Firefox provides direct download links for Windows + installer_url = "https://download.mozilla.org/?product=firefox-latest&os=win64&lang=en-US" + # The actual filename will be determined from the download (may include version number) + # We'll find any .exe file in the download directory after download + installer_path = download_dir / "FirefoxSetup.exe" + elif system == "linux": + # Linux: Download .tar.xz package (Mozilla now uses xz instead of bz2) + installer_url = "https://download.mozilla.org/?product=firefox-latest&os=linux64&lang=en-US" + installer_path = download_dir / "firefox-latest.tar.xz" + elif system == "darwin": + # macOS: Download .dmg installer + installer_url = "https://download.mozilla.org/?product=firefox-latest&os=osx&lang=en-US" + installer_path = download_dir / "Firefox.dmg" + else: + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": f"Unsupported platform: {system}", + } + }) + return None + + async with httpx.AsyncClient(timeout=900.0, follow_redirects=True) as client: + async with client.stream("GET", installer_url) as response: + response.raise_for_status() + + # Try to get actual filename from Content-Disposition header or URL + content_disposition = response.headers.get("content-disposition", "") + actual_filename = None + if content_disposition and "filename=" in content_disposition: + # Extract filename from Content-Disposition header + match = re.search(r'filename[^;=\n]*=(([\'"]).*?\2|[^;\n]*)', content_disposition) + if match: + actual_filename = match.group(1).strip('"\'') + + # Use actual filename if found, otherwise use default + if system == "windows" and actual_filename: + installer_path = download_dir / actual_filename + + total_size = int(response.headers.get("content-length", 0)) + chunk_size = 8192 + downloaded = 0 + + count = [] + with open(installer_path, "wb") as f: + async for chunk in response.aiter_bytes(chunk_size): + f.write(chunk) + downloaded += len(chunk) + + if total_size > 0: + progress = (downloaded / total_size) * 100 + bar_length = 50 + filled_length = int(bar_length * downloaded // total_size) + bar = '█' * filled_length + '-' * (bar_length - filled_length) + + mb_downloaded = downloaded / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + + print(f"\r[installer][web-mozilla] |{bar}| {progress:.1f}% ({mb_downloaded:.1f}/{mb_total:.1f} MB)", end='', flush=True) + + p = round(mb_downloaded/mb_total, 1) + if p not in count: + count.append(p) + asyncio.create_task(send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": f"Downloading Mozilla Firefox... {progress:.1f}% ({mb_downloaded:.1f}/{mb_total:.1f} MB)", + } + })) + + # For Windows, if we couldn't get filename from header, find any .exe file in download directory + if system == "windows": + if not installer_path.exists(): + exe_files = list(download_dir.glob("*.exe")) + if exe_files: + installer_path = exe_files[0] # Use the first .exe file found + print(f"[installer][web-mozilla] Found installer file: {installer_path.name}") + + print() + print(f"[installer][web-mozilla] Download complete: {installer_path}") + return installer_path + except Exception as e: + print(f"\n[installer][web-mozilla] Download failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": f"Mozilla Firefox download failed: {str(e)}", + } + }) + return None + + +async def _install_firefox_windows(installer_path, user_password: str = ""): + """Install Firefox on Windows""" + print("[installer][web-mozilla] Installing Mozilla Firefox on Windows...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": "Installing Mozilla Firefox...", + } + }) + + try: + download_dir = ZEUZ_NODE_DOWNLOADS_DIR / "firefox" + + # Find the installer .exe file (may have versioned name like "Firefox Setup 145.0.2.exe") + installer_exe = None + if installer_path and installer_path.exists(): + installer_exe = installer_path + else: + # Look for any .exe file in the firefox download directory + if download_dir.exists(): + exe_files = list(download_dir.glob("*.exe")) + if exe_files: + installer_exe = exe_files[0] + print(f"[installer][web-mozilla] Found installer: {installer_exe.name}") + + if not installer_exe or not installer_exe.exists(): + error_msg = "Firefox installer not found in download directory" + print(f"[installer][web-mozilla] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": error_msg, + } + }) + return False + + # Use simple PowerShell Start-Process command with /S flag for silent installation + # This installs to default location (Program Files) which works better for automation + installer_path_str = str(installer_exe).replace('/', '\\') + ps_command = f'Start-Process "{installer_path_str}" -ArgumentList "/S" -Wait' + + print(f"[installer][web-mozilla] Running installer: {installer_exe.name}") + print(f"[installer][web-mozilla] Command: {ps_command}") + + # Run the command (UAC prompt will appear if needed for elevation) + result = subprocess.run( + ["powershell", "-Command", ps_command], + capture_output=True, + text=True, + check=False + ) + + if result.returncode == 0: + print("[installer][web-mozilla] Mozilla Firefox installed successfully") + return True + else: + error_msg = f"Installation failed: {result.stderr or result.stdout}" + print(f"[installer][web-mozilla] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": f"Installation failed. Please check if you have administrator privileges.", + } + }) + return False + except Exception as e: + print(f"[installer][web-mozilla] Windows installation failed: {e}") + import traceback + traceback.print_exc() + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": f"Mozilla Firefox installation failed: {str(e)}", + } + }) + return False + + +async def _install_firefox_linux(installer_path, user_password: str = ""): + """Install Firefox on Linux""" + print("[installer][web-mozilla] Installing Mozilla Firefox on Linux...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": "Installing Mozilla Firefox...", + } + }) + + try: + # Helper function to run sudo commands with password if provided + def run_sudo(cmd_list): + if user_password: + # Use echo to pipe password to sudo -S (read password from stdin) + cmd = f"echo '{user_password}' | sudo -S {' '.join(cmd_list[1:])}" + return subprocess.run(cmd, shell=True, capture_output=True, text=True, check=False) + else: + return subprocess.run(cmd_list, capture_output=True, text=True, check=False) + + # First, try to install from downloaded .tar.xz file (ensures Selenium can find binary) + # Find any .tar.xz file in the download directory (filename may vary: firefox-145.0.2.tar.xz, firefox-latest.tar.xz, etc.) + download_dir = ZEUZ_NODE_DOWNLOADS_DIR / "firefox" + tar_file = None + + # If installer_path exists and is a .tar.xz file, use it + if installer_path and installer_path.exists() and installer_path.suffix == '.xz': + tar_file = installer_path + else: + # Otherwise, find any .tar.xz file in the download directory (there should be only one) + if download_dir.exists(): + tar_files = list(download_dir.glob("*.tar.xz")) + if tar_files: + tar_file = tar_files[0] # Use the first .tar.xz file found + print(f"[installer][web-mozilla] Found Firefox installer: {tar_file.name}") + + if tar_file and tar_file.exists(): + print("[installer][web-mozilla] Attempting to install from downloaded .tar.xz file...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": "Extracting Firefox from archive...", + } + }) + + try: + extract_dir = ZEUZ_NODE_DOWNLOADS_DIR / "firefox" + extract_dir.mkdir(parents=True, exist_ok=True) + + print("[installer][web-mozilla] Extracting Firefox from archive...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": "Extracting Firefox from archive...", + } + }) + + # Extract the tar.xz file directly to download directory (works with any filename) + with tarfile.open(tar_file, 'r:xz') as tar: + tar.extractall(extract_dir) + + # Find the firefox directory (usually named firefox/) + firefox_dir = None + for item in extract_dir.iterdir(): + if item.is_dir() and item.name.startswith('firefox'): + firefox_dir = item + break + + if firefox_dir and (firefox_dir / "firefox").exists(): + # Keep files in download directory - no copying needed + # Binary path: ~/.zeuz/zeuz_node_downloads/firefox/firefox/firefox + firefox_binary_path = firefox_dir / "firefox" + + print(f"[installer][web-mozilla] Creating symlink to Firefox binary...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": "Creating symlink to Firefox...", + } + }) + + # Create symlink in /usr/local/bin or /usr/bin (prefer /usr/local/bin) + symlink_paths = [ + Path("/usr/local/bin/firefox"), + Path("/usr/bin/firefox") + ] + + symlink_created = False + for symlink_path in symlink_paths: + # Remove existing symlink or file if it exists + if symlink_path.exists() or symlink_path.is_symlink(): + remove_result = run_sudo(["sudo", "rm", "-f", str(symlink_path)]) + if remove_result.returncode != 0: + print(f"[installer][web-mozilla] Warning: Failed to remove existing {symlink_path}: {remove_result.stderr}") + + # Create new symlink pointing directly to the binary in download directory + symlink_result = run_sudo([ + "sudo", "ln", "-s", str(firefox_binary_path), str(symlink_path) + ]) + + if symlink_result.returncode == 0: + print(f"[installer][web-mozilla] Created symlink: {symlink_path} -> {firefox_binary_path}") + run_sudo(["sudo", "chmod", "+x", str(symlink_path)]) + symlink_created = True + break + else: + print(f"[installer][web-mozilla] Failed to create symlink at {symlink_path}: {symlink_result.stderr}") + + if not symlink_created: + print("[installer][web-mozilla] Failed to create symlink in any location") + else: + # Create .desktop file to appear in application menu + desktop_dir = Path.home() / ".local" / "share" / "applications" + desktop_dir.mkdir(parents=True, exist_ok=True) + desktop_file = desktop_dir / "firefox.desktop" + + # Find icon path (default128.png or fallback to any icon) + icon_path = firefox_dir / "browser" / "chrome" / "icons" / "default" / "default128.png" + if not icon_path.exists(): + # Try to find any icon file + icon_dir = firefox_dir / "browser" / "chrome" / "icons" / "default" + if icon_dir.exists(): + icon_files = list(icon_dir.glob("*.png")) + if icon_files: + icon_path = icon_files[0] + + # Create .desktop file content + desktop_content = f"""[Desktop Entry] +Version=1.0 +Name=Firefox (Custom) +Comment=Mozilla Firefox Web Browser +Exec={firefox_binary_path} +Icon={icon_path if icon_path.exists() else ''} +Terminal=false +Type=Application +Categories=Network;WebBrowser; +StartupNotify=true +""" + + try: + with open(desktop_file, "w") as f: + f.write(desktop_content) + # Make .desktop file executable + os.chmod(desktop_file, 0o755) + print(f"[installer][web-mozilla] Created .desktop file: {desktop_file}") + except Exception as e: + print(f"[installer][web-mozilla] Warning: Failed to create .desktop file: {e}") + + # Verify installation by testing firefox command (uses symlink) + test_result = subprocess.run( + ["firefox", "--version"], + capture_output=True, + text=True, + check=False + ) + if test_result.returncode == 0: + print(f"[installer][web-mozilla] Firefox successfully installed") + print(f"[installer][web-mozilla] Binary location: {firefox_binary_path}") + print(f"[installer][web-mozilla] Symlink: {symlink_path} -> {firefox_binary_path}") + return True + else: + error_msg = f"Firefox verification failed: {test_result.stderr}" + print(f"[installer][web-mozilla] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": error_msg, + } + }) + return False + else: + error_msg = "Could not find Firefox directory or binary after extraction" + print(f"[installer][web-mozilla] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": error_msg, + } + }) + return False + except Exception as e: + error_msg = f"Installation from .tar.xz failed: {str(e)}" + print(f"[installer][web-mozilla] {error_msg}") + import traceback + traceback.print_exc() + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": error_msg, + } + }) + return False + + # If no installer file was downloaded or tar.xz installation not attempted + error_msg = "Firefox installer file not found or invalid. Cannot proceed with installation." + print(f"[installer][web-mozilla] {error_msg}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": error_msg, + } + }) + return False + except Exception as e: + print(f"[installer][web-mozilla] Linux installation failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": f"Mozilla Firefox installation failed: {str(e)}", + } + }) + return False + + +async def _install_firefox_darwin(installer_path, user_password: str = ""): + """Install Firefox on macOS""" + print("[installer][web-mozilla] Installing Mozilla Firefox on macOS...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": "Installing Mozilla Firefox...", + } + }) + + try: + # Try using homebrew first (doesn't need sudo) + brew_result = subprocess.run( + ["brew", "install", "--cask", "firefox"], + capture_output=True, + text=True, + check=False + ) + + if brew_result.returncode == 0: + print("[installer][web-mozilla] Mozilla Firefox installed via homebrew") + return True + + # Fallback to .dmg installer + if installer_path and installer_path.exists(): + # Mount the DMG (doesn't need sudo) + mount_result = subprocess.run( + ["hdiutil", "attach", str(installer_path)], + capture_output=True, + text=True, + check=False + ) + + if mount_result.returncode == 0: + try: + # Find the mounted volume + mount_point = None + for line in mount_result.stdout.split('\n'): + if '/Volumes/Firefox' in line: + parts = line.split('\t') + if len(parts) > 2: + mount_point = parts[-1].strip() + break + + if mount_point: + firefox_app = Path(mount_point) / "Firefox.app" + if firefox_app.exists(): + # Copy to Applications (may need sudo if permissions require it) + if user_password: + cmd = f"echo '{user_password}' | sudo -S cp -R {str(firefox_app)} /Applications/" + copy_result = subprocess.run(cmd, shell=True, capture_output=True, text=True, check=False) + else: + copy_result = subprocess.run( + ["cp", "-R", str(firefox_app), "/Applications/"], + capture_output=True, + text=True, + check=False + ) + + if copy_result.returncode == 0: + print("[installer][web-mozilla] Mozilla Firefox installed via .dmg") + return True + finally: + # Unmount the DMG (doesn't need sudo) + subprocess.run( + ["hdiutil", "detach", mount_point or "/Volumes/Firefox"], + capture_output=True, + text=True, + check=False + ) + + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": "Mozilla Firefox installation failed. Please install manually.", + } + }) + return False + except Exception as e: + print(f"[installer][web-mozilla] macOS installation failed: {e}") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": f"Mozilla Firefox installation failed: {str(e)}", + } + }) + return False + + +async def _verify_firefox_installation(): + """Verify that Firefox is properly installed""" + print("[installer][web-mozilla] Verifying Mozilla Firefox installation...") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installing", + "comment": "Verifying Mozilla Firefox installation...", + } + }) + + # Wait a moment for installation to complete + await asyncio.sleep(2) + + # Check if Firefox is installed by running check_status + return await check_status() + + +async def install(user_password: str = "") -> bool: + """Main function to install Mozilla Firefox""" + print("[installer][web-mozilla] Installing Mozilla Firefox...") + + # Check if Firefox is already installed + if await check_status(): + print("[installer][web-mozilla] Mozilla Firefox is already installed") + return True + + installer_path = None + system = platform.system().lower() + + # Download installer if needed + if system == "windows" or system == "darwin" or system == "linux": + installer_path = await _download_firefox_installer() + if not installer_path: + return False + + # Install based on platform + if system == "windows": + success = await _install_firefox_windows(installer_path, user_password) + elif system == "linux": + success = await _install_firefox_linux(installer_path, user_password) + elif system == "darwin": + success = await _install_firefox_darwin(installer_path, user_password) + else: + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "not installed", + "comment": f"Unsupported platform: {system}", + } + }) + return False + + if not success: + return False + + # Verify installation + if not await _verify_firefox_installation(): + print("[installer][web-mozilla] Mozilla Firefox installation verification failed") + return False + + # Keep installer file in downloads directory (not cleaning up) + # The installer is kept for potential reuse + + print("[installer][web-mozilla] Mozilla Firefox installation complete") + await send_response({ + "action": "status", + "data": { + "category": "Web", + "name": "Mozilla", + "status": "installed", + "comment": "Mozilla Firefox is installed", + } + }) + return True diff --git a/Framework/install_handler/windows/inspector.py b/Framework/install_handler/windows/inspector.py index a84b2b13c..68e3c9824 100644 --- a/Framework/install_handler/windows/inspector.py +++ b/Framework/install_handler/windows/inspector.py @@ -16,6 +16,7 @@ async def check_status() -> bool: "name": "Inspector", "status": "installed", "comment": f"Open the inspector here: {inspector_path}", + "install_text": "installed", } }) return True @@ -28,6 +29,7 @@ async def check_status() -> bool: "name": "Inspector", "status": "not installed", "comment": f"Install the inspector to use it.", + "install_text": "install", } }) return False @@ -45,6 +47,7 @@ async def install() -> bool: "name": "Inspector", "status": "installed", "comment": f"Open the inspector here: {inspector_path}", + "install_text": "installed", } }) print("[installer][windows-inspector] Already installed") @@ -89,6 +92,7 @@ async def install() -> bool: "name": "Inspector", "status": "installing", "comment": f"Downloading inspector... {progress:.1f}% ({mb_downloaded:.1f}/{mb_total:.1f} MB)", + "install_text": "installing", } })) @@ -102,6 +106,7 @@ async def install() -> bool: "name": "Inspector", "status": "installed", "comment": f"Open the inspector here: {inspector_path}", + "install_text": "installed", } }) return True diff --git a/pyproject.toml b/pyproject.toml index 686b109dd..ab2cc5f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,8 @@ dependencies = [ "pipdeptree>=2.26.1", "axe-selenium-python>=2.1.6", "filelock>=3.20.0", + "snowflake-connector-python>=3.12.0", + "pyopenssl>=23.0.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 21d8baec0..321351952 100644 --- a/uv.lock +++ b/uv.lock @@ -8516,4 +8516,4 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513 }, { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118 }, { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940 }, -] +] \ No newline at end of file