#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Winetricks Handler Module Handles wine component installation using bundled winetricks. Discovery, installation strategy, and verification live in mixins. """ import os import subprocess import logging from pathlib import Path from typing import Optional, List, Callable from .winetricks_discovery import WinetricksDiscoveryMixin from .winetricks_env import WinetricksEnvMixin from .winetricks_installation import WinetricksInstallationMixin from .winetricks_verification import WinetricksVerificationMixin logger = logging.getLogger(__name__) class WinetricksHandler( WinetricksDiscoveryMixin, WinetricksEnvMixin, WinetricksInstallationMixin, WinetricksVerificationMixin, ): """Handles wine component installation. Discovery, installation, verification in mixins.""" def __init__(self, logger=None): self.logger = logger or logging.getLogger(__name__) self.winetricks_path = self._get_bundled_winetricks_path() def install_wine_components(self, wineprefix: str, game_var: str, specific_components: Optional[List[str]] = None, status_callback: Optional[Callable[[str], None]] = None, appid: Optional[str] = None) -> bool: """ Install the specified Wine components into the given prefix using winetricks. If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022). Args: wineprefix: Path to Wine prefix game_var: Game name for logging specific_components: Optional list of specific components to install status_callback: Optional callback function(status_message: str) for progress updates appid: Optional Steam App ID (for fallback or logging) """ if not self.is_available(): self.logger.error("Winetricks is not available") return False env, components_to_install = self._build_winetricks_env(wineprefix, status_callback, specific_components) if env is None: return False if not components_to_install: return True # Flatpak Steam: use protontricks only; bundled winetricks is unreliable (e.g. from AppImage) flatpak_steam = False try: from ..services.steam_restart_service import is_flatpak_steam flatpak_steam = is_flatpak_steam() except Exception as e: self.logger.debug("Could not check Flatpak Steam via CLI: %s", e) if not flatpak_steam and self._is_flatpak_steam_prefix(wineprefix): flatpak_steam = True self.logger.info("Flatpak Steam prefix detected (path): using protontricks only") if flatpak_steam: self.logger.info("Flatpak Steam detected: using protontricks only for component installation") return self._install_components_protontricks_only( components_to_install, wineprefix, game_var, status_callback, appid=appid ) # Check user preference for component installation method from ..handlers.config_handler import ConfigHandler config_handler = ConfigHandler() # Get component installation method with migration method = config_handler.get('component_installation_method', 'winetricks') # Migrate bundled_protontricks to system_protontricks (no longer supported) if method == 'bundled_protontricks': self.logger.warning("Bundled protontricks no longer supported, migrating to system_protontricks") method = 'system_protontricks' config_handler.set('component_installation_method', 'system_protontricks') # Choose installation method based on user preference if method == 'system_protontricks': self.logger.info("=" * 80) self.logger.info("USING PROTONTRICKS") self.logger.info("=" * 80) self.logger.info("Using system protontricks for all components") return self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback) # Install all components together with winetricks (faster) self.logger.info("=" * 80) self.logger.info("USING WINETRICKS") self.logger.info("=" * 80) max_attempts = 3 winetricks_failed = False last_error_details = None for attempt in range(1, max_attempts + 1): if attempt > 1: self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...") self._cleanup_wine_processes() elif attempt == 1: self._kill_wineserver_for_prefix(env) try: cmd = [self.winetricks_path, '--unattended'] + components_to_install run_env = env # Log full command for advanced users to reproduce manually (debug mode only) cmd_str = ' '.join(cmd) self.logger.debug("=" * 80) self.logger.debug("WINETRICKS COMMAND (for manual reproduction):") self.logger.debug(f" {cmd_str}") self.logger.debug("") self.logger.debug("Environment variables required:") self.logger.debug(f" WINEPREFIX={env.get('WINEPREFIX', 'NOT SET')}") self.logger.debug(f" WINE={env.get('WINE', 'NOT SET')}") self.logger.debug(f" WINESERVER={env.get('WINESERVER', 'NOT SET')}") self.logger.debug("=" * 80) # Enhanced diagnostics for bundled winetricks self.logger.debug("=== Winetricks Environment Diagnostics ===") self.logger.debug(f"Bundled winetricks path: {self.winetricks_path}") self.logger.debug(f"Winetricks exists: {os.path.exists(self.winetricks_path)}") self.logger.debug(f"Winetricks executable: {os.access(self.winetricks_path, os.X_OK)}") if os.path.exists(self.winetricks_path): try: winetricks_stat = os.stat(self.winetricks_path) self.logger.debug(f"Winetricks permissions: {oct(winetricks_stat.st_mode)}") self.logger.debug(f"Winetricks size: {winetricks_stat.st_size} bytes") except Exception as stat_err: self.logger.debug(f"Could not stat winetricks: {stat_err}") self.logger.debug(f"WINE binary: {env.get('WINE', 'NOT SET')}") wine_binary = env.get('WINE', '') if wine_binary and os.path.exists(wine_binary): self.logger.debug(f"WINE binary exists: True") else: self.logger.debug(f"WINE binary exists: False") self.logger.debug(f"WINEPREFIX: {env.get('WINEPREFIX', 'NOT SET')}") wineprefix = env.get('WINEPREFIX', '') if wineprefix and os.path.exists(wineprefix): self.logger.debug(f"WINEPREFIX exists: True") self.logger.debug(f"WINEPREFIX/pfx exists: {os.path.exists(os.path.join(wineprefix, 'pfx'))}") else: self.logger.debug(f"WINEPREFIX exists: False") self.logger.debug(f"DISPLAY: {env.get('DISPLAY', 'NOT SET')}") self.logger.debug(f"WINETRICKS_CACHE: {env.get('WINETRICKS_CACHE', 'NOT SET')}") self.logger.debug(f"Components to install: {components_to_install}") self.logger.debug("==========================================") result = subprocess.run( cmd, env=run_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=600 ) self.logger.debug(f"Winetricks output: {result.stdout}") if result.returncode == 0: self.logger.info("Wine Component installation command completed.") # Verify components were actually installed if self._verify_components_installed(wineprefix, components_to_install, env): self.logger.info("Component verification successful - all components installed correctly.") components_list = ', '.join(components_to_install) if status_callback: status_callback(f"Wine components installed and verified: {components_list}") self._set_windows_10_mode_after_install(wineprefix, env) return True else: self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})") winetricks_failed = True elif result.returncode == 1 and "returned status 120" in result.stderr and "aborting" in result.stderr.lower(): # VC redist / some installers return 120 under Wine (ERROR_CALL_NOT_IMPLEMENTED); install may still have succeeded self.logger.info("Winetricks returned 1 with status 120 (installer quirk under Wine); verifying components...") if self._verify_components_installed(wineprefix, components_to_install, env): self.logger.info("Component verification passed after status 120 - accepting as success.") if status_callback: status_callback(f"Wine components installed and verified: {', '.join(components_to_install)}") self._set_windows_10_mode_after_install(wineprefix, env) return True last_error_details = {'returncode': result.returncode, 'stdout': result.stdout.strip(), 'stderr': result.stderr.strip(), 'attempt': attempt} winetricks_failed = True else: # Store detailed error information for fallback diagnostics last_error_details = { 'returncode': result.returncode, 'stdout': result.stdout.strip(), 'stderr': result.stderr.strip(), 'attempt': attempt } # Log full error details to help diagnose failures self.logger.error("=" * 80) self.logger.error(f"WINETRICKS FAILED (Attempt {attempt}/{max_attempts})") self.logger.error(f"Return Code: {result.returncode}") self.logger.error("") self.logger.error("STDOUT:") if result.stdout.strip(): for line in result.stdout.strip().split('\n'): self.logger.error(f" {line}") else: self.logger.error(" (empty)") self.logger.error("") self.logger.error("STDERR:") if result.stderr.strip(): # Filter out verbose winetricks "Executing..." messages - these are informational, not errors error_lines = [] verbose_lines = [] for line in result.stderr.strip().split('\n'): line_lower = line.lower().strip() # Skip verbose informational messages if (line_lower.startswith('executing ') or (line_lower.startswith('grep: warning:') and 'stray' in line_lower) or ('warning; possible' in line_lower and 'extra bytes' in line_lower)): # These are verbose info messages, log at debug level instead verbose_lines.append(line) else: # Actual error/warning messages (including "returned status", "aborting", dbus errors, etc.) error_lines.append(line) if error_lines: self.logger.error(" Actual errors/warnings:") for line in error_lines: self.logger.error(f" {line}") if verbose_lines: self.logger.debug(f" ({len(verbose_lines)} verbose 'Executing...' lines suppressed - see debug log for details)") else: self.logger.error(" (only verbose output, no actual errors)") if verbose_lines: self.logger.debug(f" ({len(verbose_lines)} verbose lines suppressed)") else: self.logger.error(" (empty)") self.logger.error("=" * 80) # Enhanced error diagnostics with actionable information stderr_lower = result.stderr.lower() stdout_lower = result.stdout.lower() # Log which diagnostic category matches diagnostic_found = False if "command not found" in stderr_lower or "no such file" in stderr_lower: self.logger.error("DIAGNOSTIC: Winetricks or dependency binary not found") self.logger.error(" - Bundled winetricks may be missing dependencies") self.logger.error(" - Check dependency check output above for missing tools") self.logger.error(" - Will attempt protontricks fallback if all attempts fail") diagnostic_found = True elif ("returned status" in stderr_lower and "aborting" in stderr_lower) or "connection reset by peer" in stderr_lower: self.logger.error("DIAGNOSTIC: Wine/Proton command failed (regedit, VC redist, etc.)") self.logger.error(" - Wine subprocess returned non-zero or wineserver connection reset") self.logger.error(" - Common when running winetricks from AppImage against Proton prefix; protontricks fallback uses same prefix from inside Steam env") diagnostic_found = True elif "w_metadata" in stderr_lower and ("unix path" in stderr_lower or "windows path" in stderr_lower): self.logger.error("DIAGNOSTIC: Winetricks metadata bug (e.g. jet40 installed_file path)") self.logger.error(" - Known winetricks bug: component metadata uses Unix path instead of Windows path") self.logger.error(" - Upstream fix in newer winetricks; protontricks fallback will be used") diagnostic_found = True elif "permission denied" in stderr_lower: self.logger.error("DIAGNOSTIC: Permission issue detected") self.logger.error(f" - Check permissions on: {self.winetricks_path}") self.logger.error(f" - Check permissions on WINEPREFIX: {env.get('WINEPREFIX', 'N/A')}") diagnostic_found = True elif "timeout" in stderr_lower: self.logger.error("DIAGNOSTIC: Timeout issue detected during component download/install") self.logger.error(" - Network may be slow or unstable") self.logger.error(" - Component download may be taking too long") diagnostic_found = True elif "sha256sum mismatch" in stderr_lower or ("checksum" in stderr_lower and ("fail" in stderr_lower or "mismatch" in stderr_lower)): self.logger.error("DIAGNOSTIC: Checksum verification failed") self.logger.error(" - Component download may be corrupted") self.logger.error(" - Network issue or upstream file change") diagnostic_found = True elif ("please install" in stderr_lower or "please install" in stdout_lower) and ("wget" in stderr_lower or "aria2c" in stderr_lower or "curl" in stderr_lower or "wget" in stdout_lower or "aria2c" in stdout_lower or "curl" in stdout_lower): # Winetricks explicitly says to install a downloader self._handle_missing_downloader_error() diagnostic_found = True elif "curl" in stderr_lower or "wget" in stderr_lower or "aria2c" in stderr_lower: self.logger.error("DIAGNOSTIC: Download tool (curl/wget/aria2c) issue") self.logger.error(" - Network connectivity problem or missing download tool") self.logger.error(" - Check dependency check output above") diagnostic_found = True elif "cabextract" in stderr_lower and ("not found" in stderr_lower or "failed" in stderr_lower or "command not found" in stderr_lower or "no such file" in stderr_lower): self.logger.error("DIAGNOSTIC: cabextract missing or failed") self.logger.error(" - Required for extracting Windows cabinet files") self.logger.error(" - Bundled cabextract should be available, check PATH") diagnostic_found = True elif ("unzip" in stderr_lower or "7z" in stderr_lower) and ("not found" in stderr_lower or "failed" in stderr_lower or "error" in stderr_lower): self.logger.error("DIAGNOSTIC: Archive extraction tool (unzip/7z) missing or failed") self.logger.error(" - Required for extracting zip/7z archives") self.logger.error(" - Check dependency check output above") diagnostic_found = True if not diagnostic_found: self.logger.error("DIAGNOSTIC: Unknown winetricks failure pattern") self.logger.error(" - Error details logged above (STDOUT/STDERR)") self.logger.error(" - Check dependency check output above for missing tools") self.logger.error(" - Will attempt protontricks fallback if all attempts fail") winetricks_failed = True except subprocess.TimeoutExpired as e: self.logger.error(f"Winetricks timed out (Attempt {attempt}/{max_attempts}): {e}") last_error_details = {'error': 'timeout', 'attempt': attempt} winetricks_failed = True except Exception as e: self.logger.error(f"Error during winetricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True) last_error_details = {'error': str(e), 'attempt': attempt} winetricks_failed = True # All winetricks attempts failed - try automatic fallback to protontricks if winetricks_failed: self.logger.error("=" * 80) self.logger.error(f"WINETRICKS FAILED AFTER {max_attempts} ATTEMPTS") self.logger.error("") if last_error_details: self.logger.error("Last error details:") if 'returncode' in last_error_details: self.logger.error(f" Return code: {last_error_details['returncode']}") if 'stderr' in last_error_details and last_error_details['stderr']: self.logger.error(f" Last stderr (first 500 chars): {last_error_details['stderr'][:500]}") if 'stdout' in last_error_details and last_error_details['stdout']: self.logger.error(f" Last stdout (first 500 chars): {last_error_details['stdout'][:500]}") self.logger.error("") self.logger.error("Attempting automatic fallback to protontricks...") self.logger.error("=" * 80) # Network diagnostics before fallback (non-fatal) self.logger.warning("=" * 80) self.logger.warning("NETWORK DIAGNOSTICS: Testing connectivity to component download sources...") try: # Check if curl is available curl_check = subprocess.run(['which', 'curl'], capture_output=True, timeout=5) if curl_check.returncode == 0: # Test Microsoft download servers (used by winetricks for .NET, VC runtimes, DirectX) test_result = subprocess.run(['curl', '-I', '--max-time', '10', 'https://download.microsoft.com'], capture_output=True, text=True, timeout=15) if test_result.returncode == 0: self.logger.warning("Can reach download.microsoft.com") else: self.logger.error("Cannot reach download.microsoft.com - network/DNS issue likely") self.logger.error(f" Curl exit code: {test_result.returncode}") if test_result.stderr: self.logger.error(f" Curl error: {test_result.stderr.strip()}") else: self.logger.warning("curl not available, skipping network diagnostic test") except Exception as e: self.logger.warning(f"Network diagnostic test skipped: {e}") self.logger.warning("=" * 80) # Check if protontricks is available for fallback using centralized handler try: from .protontricks_handler import ProtontricksHandler steamdeck = os.path.exists('/home/deck') protontricks_handler = ProtontricksHandler(steamdeck) protontricks_available = protontricks_handler.detect_protontricks() if protontricks_available: self.logger.warning("=" * 80) self.logger.warning("AUTOMATIC FALLBACK: Winetricks failed, attempting protontricks fallback...") self.logger.warning(f"Last winetricks error: {last_error_details}") self.logger.warning("=" * 80) self.logger.info("=" * 80) self.logger.info("USING PROTONTRICKS") self.logger.info("=" * 80) # Attempt fallback to protontricks fallback_success = self._install_components_protontricks_only(components_to_install, wineprefix, game_var, status_callback) if fallback_success: self.logger.info("SUCCESS: Protontricks fallback succeeded where winetricks failed") return True else: self.logger.error("FAILURE: Both winetricks and protontricks fallback failed") return False else: self.logger.error("Protontricks not available for fallback") self.logger.error(f"Final winetricks error details: {last_error_details}") return False except Exception as e: self.logger.error(f"Could not check for protontricks fallback: {e}") return False return False def _handle_missing_downloader_error(self): """Handle winetricks error indicating missing downloader - provide platform-specific instructions""" from ..services.platform_detection_service import PlatformDetectionService platform = PlatformDetectionService.get_instance() is_steamos = platform.is_steamdeck self.logger.error("=" * 80) self.logger.error("CRITICAL: Winetricks cannot find a downloader (curl, wget, or aria2c)") self.logger.error("") if is_steamos: self.logger.error("STEAMOS/STEAM DECK DETECTED") self.logger.error("") self.logger.error("SteamOS has a read-only filesystem. To install packages:") self.logger.error("") self.logger.error("1. Disable read-only mode (required for package installation):") self.logger.error(" sudo steamos-readonly disable") self.logger.error("") self.logger.error("2. Install curl (recommended - most reliable):") self.logger.error(" sudo pacman -S curl") self.logger.error("") self.logger.error("3. (Optional) Re-enable read-only mode after installation:") self.logger.error(" sudo steamos-readonly enable") self.logger.error("") self.logger.error("Note: curl is usually pre-installed on SteamOS. If missing,") self.logger.error(" the above steps will install it.") else: self.logger.error("SOLUTION: Install one of the following downloaders:") self.logger.error("") self.logger.error(" For Debian/Ubuntu/PopOS:") self.logger.error(" sudo apt install curl # or: sudo apt install wget") self.logger.error("") self.logger.error(" For Fedora/RHEL/CentOS:") self.logger.error(" sudo dnf install curl # or: sudo dnf install wget") self.logger.error("") self.logger.error(" For Arch/Manjaro:") self.logger.error(" sudo pacman -S curl # or: sudo pacman -S wget") self.logger.error("") self.logger.error(" For openSUSE:") self.logger.error(" sudo zypper install curl # or: sudo zypper install wget") self.logger.error("") self.logger.error("Note: Most Linux distributions include curl by default.") self.logger.error(" If curl is missing, install it using your package manager.") self.logger.error("=" * 80) def _kill_wineserver_for_prefix(self, env: dict) -> None: """Kill wineserver for the current WINEPREFIX so the next wine invocation starts a fresh one (avoids connection reset by peer).""" wineserver = env.get('WINESERVER') if not wineserver or not os.path.exists(wineserver): return try: subprocess.run( [wineserver, '-k'], env=env, timeout=10, capture_output=True, ) self.logger.debug("Killed wineserver for prefix so winetricks can start a fresh one") except Exception as e: self.logger.debug("Wineserver -k failed (non-fatal): %s", e) def _cleanup_wine_processes(self): """Clean up winetricks processes only during component installation.""" try: subprocess.run("pkill -f winetricks", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self.logger.debug("Cleaned up winetricks processes only") except Exception as e: self.logger.error(f"Error cleaning up winetricks processes: {e}")