#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Protontricks Handler Module Handles detection and operation of Protontricks """ import os import re import subprocess from pathlib import Path import shutil import logging from typing import Dict, Optional, List import sys # Initialize logger logger = logging.getLogger(__name__) class ProtontricksHandler: """ Handles operations related to Protontricks detection and usage This handler now supports native Steam operations as a fallback/replacement for protontricks functionality. """ def __init__(self, steamdeck: bool, logger=None): self.logger = logger or logging.getLogger(__name__) self.which_protontricks = None # 'flatpak', 'native', or 'bundled' self.flatpak_install_type = None # 'user' or 'system' (for flatpak installations) self.protontricks_version = None self.protontricks_path = None self.steamdeck = steamdeck # Store steamdeck status self._native_steam_service = None self.use_native_operations = True # Enable native Steam operations by default def _get_steam_dir_from_libraryfolders(self) -> Optional[Path]: """ Determine the Steam installation directory from libraryfolders.vdf location. This is the source of truth - we read libraryfolders.vdf to find where Steam is actually installed. Returns: Path to Steam installation directory (the one with config/, steamapps/, etc.) or None """ from ..handlers.path_handler import PathHandler # Check all possible libraryfolders.vdf locations vdf_paths = [ Path.home() / ".steam/steam/config/libraryfolders.vdf", Path.home() / ".local/share/Steam/config/libraryfolders.vdf", Path.home() / ".steam/root/config/libraryfolders.vdf", Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", # Flatpak Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/config/libraryfolders.vdf", # Flatpak alternative ] for vdf_path in vdf_paths: if vdf_path.is_file(): # The Steam installation directory is the parent of the config directory steam_dir = vdf_path.parent.parent # Verify it has steamapps directory (required by protontricks) if (steam_dir / "steamapps").exists(): logger.debug(f"Determined STEAM_DIR from libraryfolders.vdf: {steam_dir}") return steam_dir # Fallback: try to get from library paths library_paths = PathHandler.get_all_steam_library_paths() if library_paths: # For Flatpak Steam, library path is .local/share/Steam, but Steam installation might be data/Steam first_lib = library_paths[0] if '.var/app/com.valvesoftware.Steam' in str(first_lib): # Check if data/Steam exists (main Flatpak Steam installation) data_steam = Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam" if (data_steam / "steamapps").exists(): logger.debug(f"Determined STEAM_DIR from Flatpak data path: {data_steam}") return data_steam # Otherwise use the library path itself if (first_lib / "steamapps").exists(): logger.debug(f"Determined STEAM_DIR from Flatpak library path: {first_lib}") return first_lib else: # Native Steam - library path should be the Steam installation if (first_lib / "steamapps").exists(): logger.debug(f"Determined STEAM_DIR from native library path: {first_lib}") return first_lib logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf") return None def _get_bundled_winetricks_path(self) -> Optional[Path]: """ Get the path to the bundled winetricks script following AppImage best practices. Same logic as WinetricksHandler._get_bundled_winetricks_path() """ possible_paths = [] # AppImage environment - use APPDIR (standard AppImage best practice) if os.environ.get('APPDIR'): appdir_path = Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'winetricks' possible_paths.append(appdir_path) # Development environment - relative to module location module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/ dev_path = module_dir / 'tools' / 'winetricks' possible_paths.append(dev_path) # Try each path until we find one that works for path in possible_paths: if path.exists() and os.access(path, os.X_OK): logger.debug(f"Found bundled winetricks at: {path}") return path logger.warning(f"Bundled winetricks not found. Tried paths: {possible_paths}") return None def _get_bundled_cabextract_path(self) -> Optional[Path]: """ Get the path to the bundled cabextract binary following AppImage best practices. Same logic as WinetricksHandler._get_bundled_cabextract() """ possible_paths = [] # AppImage environment - use APPDIR (standard AppImage best practice) if os.environ.get('APPDIR'): appdir_path = Path(os.environ['APPDIR']) / 'opt' / 'jackify' / 'tools' / 'cabextract' possible_paths.append(appdir_path) # Development environment - relative to module location module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/ dev_path = module_dir / 'tools' / 'cabextract' possible_paths.append(dev_path) # Try each path until we find one that works for path in possible_paths: if path.exists() and os.access(path, os.X_OK): logger.debug(f"Found bundled cabextract at: {path}") return path logger.warning(f"Bundled cabextract not found. Tried paths: {possible_paths}") return None def _get_clean_subprocess_env(self): """ Create a clean environment for subprocess calls by removing bundle-specific environment variables that can interfere with external program execution. Uses the centralized get_clean_subprocess_env() to ensure AppImage variables are removed to prevent subprocess spawning issues. Returns: dict: Cleaned environment dictionary """ # Use centralized function that removes AppImage variables from .subprocess_utils import get_clean_subprocess_env env = get_clean_subprocess_env() # Clean library path variables that frozen bundles modify (Linux/Unix) if 'LD_LIBRARY_PATH_ORIG' in env: # Restore original LD_LIBRARY_PATH if it was backed up by the bundler env['LD_LIBRARY_PATH'] = env['LD_LIBRARY_PATH_ORIG'] else: # Remove bundle-modified LD_LIBRARY_PATH env.pop('LD_LIBRARY_PATH', None) # Clean macOS library path (if present) if 'DYLD_LIBRARY_PATH' in env and hasattr(sys, '_MEIPASS'): dyld_entries = env['DYLD_LIBRARY_PATH'].split(os.pathsep) cleaned_dyld = [p for p in dyld_entries if not p.startswith(sys._MEIPASS)] if cleaned_dyld: env['DYLD_LIBRARY_PATH'] = os.pathsep.join(cleaned_dyld) else: env.pop('DYLD_LIBRARY_PATH', None) return env def _get_native_steam_service(self): """Get native Steam operations service instance""" if self._native_steam_service is None: from ..services.native_steam_operations_service import NativeSteamOperationsService self._native_steam_service = NativeSteamOperationsService(steamdeck=self.steamdeck) return self._native_steam_service def detect_protontricks(self): """ Detect if protontricks is installed (silent detection for GUI/automated use). Returns True if protontricks is found, False otherwise. Does NOT prompt user or attempt installation - that's handled by the GUI. """ logger.debug("Detecting if protontricks is installed...") # Check if protontricks exists as a command protontricks_path_which = shutil.which("protontricks") self.flatpak_path = shutil.which("flatpak") if protontricks_path_which: # Check if it's a flatpak wrapper try: with open(protontricks_path_which, 'r') as f: content = f.read() if "flatpak run" in content: logger.debug(f"Detected Protontricks is a Flatpak wrapper at {protontricks_path_which}") self.which_protontricks = 'flatpak' else: logger.info(f"Native Protontricks found at {protontricks_path_which}") self.which_protontricks = 'native' self.protontricks_path = protontricks_path_which return True except Exception as e: logger.error(f"Error reading protontricks executable: {e}") # Check if flatpak protontricks is installed (check both user and system) try: env = self._get_clean_subprocess_env() # Check user installation first result_user = subprocess.run( ["flatpak", "list", "--user"], capture_output=True, text=True, env=env ) if result_user.returncode == 0 and "com.github.Matoking.protontricks" in result_user.stdout: logger.info("Flatpak Protontricks is installed (user-level)") self.which_protontricks = 'flatpak' self.flatpak_install_type = 'user' return True # Check system installation result_system = subprocess.run( ["flatpak", "list", "--system"], capture_output=True, text=True, env=env ) if result_system.returncode == 0 and "com.github.Matoking.protontricks" in result_system.stdout: logger.info("Flatpak Protontricks is installed (system-level)") self.which_protontricks = 'flatpak' self.flatpak_install_type = 'system' return True except FileNotFoundError: logger.warning("'flatpak' command not found. Cannot check for Flatpak Protontricks.") except Exception as e: logger.error(f"Unexpected error checking flatpak: {e}") # Not found logger.warning("Protontricks not found (native or flatpak).") return False def _get_flatpak_run_args(self) -> List[str]: """ Get the correct flatpak run arguments based on installation type. Returns list starting with ['flatpak', 'run', '--user'|'--system', ...] """ base_args = ["flatpak", "run"] if self.flatpak_install_type == 'user': base_args.append("--user") elif self.flatpak_install_type == 'system': base_args.append("--system") # If flatpak_install_type is None, don't add flag (shouldn't happen in normal flow) return base_args def _get_flatpak_alias_string(self, command=None) -> str: """ Get the correct flatpak alias string based on installation type. Args: command: Optional command override (e.g., 'protontricks-launch'). If None, returns base protontricks alias. Returns: String like 'flatpak run --user com.github.Matoking.protontricks' """ flag = f"--{self.flatpak_install_type}" if self.flatpak_install_type else "" if command: # For commands like protontricks-launch if flag: return f"flatpak run {flag} --command={command} com.github.Matoking.protontricks" else: return f"flatpak run --command={command} com.github.Matoking.protontricks" else: # Base protontricks command if flag: return f"flatpak run {flag} com.github.Matoking.protontricks" else: return f"flatpak run com.github.Matoking.protontricks" def check_protontricks_version(self): """ Check if the protontricks version is sufficient Returns True if version is sufficient, False otherwise """ try: if self.which_protontricks == 'flatpak': cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-V"] else: cmd = ["protontricks", "-V"] result = subprocess.run(cmd, capture_output=True, text=True) version_str = result.stdout.split(' ')[1].strip('()') # Clean version string cleaned_version = re.sub(r'[^0-9.]', '', version_str) self.protontricks_version = cleaned_version # Parse version components version_parts = cleaned_version.split('.') if len(version_parts) >= 2: major, minor = int(version_parts[0]), int(version_parts[1]) if major < 1 or (major == 1 and minor < 12): logger.error(f"Protontricks version {cleaned_version} is too old. Version 1.12.0 or newer is required.") return False return True else: logger.error(f"Could not parse protontricks version: {cleaned_version}") return False except Exception as e: logger.error(f"Error checking protontricks version: {e}") return False def run_protontricks(self, *args, **kwargs): """ Run protontricks with the given arguments and keyword arguments. kwargs are passed directly to subprocess.run (e.g., stderr=subprocess.DEVNULL). Use stdout=subprocess.PIPE, stderr=subprocess.PIPE/DEVNULL instead of capture_output=True. Returns subprocess.CompletedProcess object """ # Ensure protontricks is detected first if self.which_protontricks is None: if not self.detect_protontricks(): logger.error("Could not detect protontricks installation") return None # Build command based on detected protontricks type if self.which_protontricks == 'bundled': # CRITICAL: Use safe Python executable to prevent AppImage recursive spawning from .subprocess_utils import get_safe_python_executable python_exe = get_safe_python_executable() # Use bundled wrapper script for reliable invocation # The wrapper script imports cli and calls it with sys.argv wrapper_script = self._get_bundled_protontricks_wrapper_path() if wrapper_script and Path(wrapper_script).exists(): cmd = [python_exe, str(wrapper_script)] cmd.extend([str(a) for a in args]) else: # Fallback: use python -m to run protontricks CLI directly # This avoids importing protontricks.__init__ which imports gui.py which needs Pillow cmd = [python_exe, "-m", "protontricks.cli.main"] cmd.extend([str(a) for a in args]) elif self.which_protontricks == 'flatpak': cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks"] cmd.extend(args) else: # native cmd = ["protontricks"] cmd.extend(args) # Default to capturing stdout/stderr unless specified otherwise in kwargs run_kwargs = { 'stdout': subprocess.PIPE, 'stderr': subprocess.PIPE, 'text': True, **kwargs # Allow overriding defaults (like stderr=DEVNULL) } # Handle environment: if env was passed in kwargs, merge it with our clean env # Otherwise create a clean env from scratch if 'env' in kwargs and kwargs['env']: # Merge passed env with our clean env (our values take precedence) env = self._get_clean_subprocess_env() env.update(kwargs['env']) # Merge passed env, but our clean env is base # Re-apply our critical settings after merge to ensure they're set else: # Bundled-runtime fix: Use cleaned environment for all protontricks calls env = self._get_clean_subprocess_env() # Suppress Wine debug output env['WINEDEBUG'] = '-all' # CRITICAL: Set STEAM_DIR based on libraryfolders.vdf to prevent user prompts steam_dir = self._get_steam_dir_from_libraryfolders() if steam_dir: env['STEAM_DIR'] = str(steam_dir) logger.debug(f"Set STEAM_DIR for protontricks: {steam_dir}") else: logger.warning("Could not determine STEAM_DIR from libraryfolders.vdf - protontricks may prompt user") # CRITICAL: Only set bundled winetricks for NATIVE protontricks # Flatpak protontricks runs in a sandbox and CANNOT access AppImage FUSE mounts (/tmp/.mount_*) # Flatpak protontricks has its own winetricks bundled inside the flatpak if self.which_protontricks == 'native': winetricks_path = self._get_bundled_winetricks_path() if winetricks_path: env['WINETRICKS'] = str(winetricks_path) logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") else: logger.warning("Bundled winetricks not found - native protontricks will use system winetricks") cabextract_path = self._get_bundled_cabextract_path() if cabextract_path: cabextract_dir = str(cabextract_path.parent) current_path = env.get('PATH', '') env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}") else: logger.warning("Bundled cabextract not found - native protontricks will use system cabextract") else: # Flatpak protontricks - DO NOT set bundled paths logger.debug(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") # CRITICAL: Suppress winetricks verbose output when not in debug mode # WINETRICKS_SUPER_QUIET suppresses "Executing..." messages from winetricks from ..handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) if not debug_mode: env['WINETRICKS_SUPER_QUIET'] = '1' logger.debug("Set WINETRICKS_SUPER_QUIET=1 to suppress winetricks verbose output") else: logger.debug("Debug mode enabled - winetricks verbose output will be shown") # Note: No need to modify LD_LIBRARY_PATH for Wine/Proton as it's a system dependency # Wine/Proton finds its own libraries through the system's library search paths run_kwargs['env'] = env try: return subprocess.run(cmd, **run_kwargs) except Exception as e: logger.error(f"Error running protontricks: {e}") return None def set_protontricks_permissions(self, modlist_dir, steamdeck=False): """ Set permissions for Steam operations to access the modlist directory. Uses native operations when enabled, falls back to protontricks permissions. Returns True on success, False on failure """ # Use native operations if enabled if self.use_native_operations: logger.debug("Using native Steam operations, permissions handled natively") try: return self._get_native_steam_service().set_steam_permissions(modlist_dir, steamdeck) except Exception as e: logger.warning(f"Native permissions failed, falling back to protontricks: {e}") if self.which_protontricks != 'flatpak': logger.debug("Using Native protontricks, skip setting permissions") return True logger.info("Setting Protontricks permissions...") try: # Bundled-runtime fix: Use cleaned environment env = self._get_clean_subprocess_env() subprocess.run(["flatpak", "override", "--user", "com.github.Matoking.protontricks", f"--filesystem={modlist_dir}"], check=True, env=env) if steamdeck: logger.warn("Checking for SDCard and setting permissions appropriately...") # Find sdcard path result = subprocess.run(["df", "-h"], capture_output=True, text=True, env=env) for line in result.stdout.splitlines(): if "/run/media" in line: sdcard_path = line.split()[-1] logger.debug(f"SDCard path: {sdcard_path}") subprocess.run(["flatpak", "override", "--user", f"--filesystem={sdcard_path}", "com.github.Matoking.protontricks"], check=True, env=env) # Add standard Steam Deck SD card path as fallback subprocess.run(["flatpak", "override", "--user", "--filesystem=/run/media/mmcblk0p1", "com.github.Matoking.protontricks"], check=True, env=env) logger.debug("Permissions set successfully") return True except Exception as e: logger.error(f"Failed to set Protontricks permissions: {e}") return False def create_protontricks_alias(self): """ Create aliases for protontricks in ~/.bashrc if using flatpak Returns True if created or already exists, False on failure """ if self.which_protontricks != 'flatpak': logger.debug("Not using flatpak, skipping alias creation") return True try: bashrc_path = os.path.expanduser("~/.bashrc") # Check if file exists and read content if os.path.exists(bashrc_path): with open(bashrc_path, 'r') as f: content = f.read() # Check if aliases already exist protontricks_alias_exists = "alias protontricks=" in content launch_alias_exists = "alias protontricks-launch" in content # Add missing aliases with correct flag based on installation type with open(bashrc_path, 'a') as f: if not protontricks_alias_exists: logger.info("Adding protontricks alias to ~/.bashrc") alias_cmd = self._get_flatpak_alias_string() f.write(f"\nalias protontricks='{alias_cmd}'\n") if not launch_alias_exists: logger.info("Adding protontricks-launch alias to ~/.bashrc") launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch') f.write(f"\nalias protontricks-launch='{launch_alias_cmd}'\n") return True else: logger.error("~/.bashrc not found, skipping alias creation") return False except Exception as e: logger.error(f"Failed to create protontricks aliases: {e}") return False # def get_modlists(self): # Keep commented out or remove old method # """ # Get a list of Skyrim, Fallout, Oblivion modlists from Steam via protontricks # Returns a list of modlist names # """ # ... (old implementation with filtering) ... # Renamed from list_non_steam_games for clarity and purpose def list_non_steam_shortcuts(self) -> Dict[str, str]: """List ALL non-Steam shortcuts. Uses native VDF parsing when enabled, falls back to protontricks -l parsing. Returns: A dictionary mapping the shortcut name (AppName) to its AppID. Returns an empty dictionary if none are found or an error occurs. """ # Use native operations if enabled if self.use_native_operations: logger.info("Listing non-Steam shortcuts via native VDF parsing...") try: return self._get_native_steam_service().list_non_steam_shortcuts() except Exception as e: logger.warning(f"Native shortcut listing failed, falling back to protontricks: {e}") logger.info("Listing ALL non-Steam shortcuts via protontricks...") non_steam_shortcuts = {} # --- Ensure protontricks is detected before proceeding --- if not self.which_protontricks: self.logger.info("Protontricks type/path not yet determined. Running detection...") if not self.detect_protontricks(): self.logger.error("Protontricks detection failed. Cannot list shortcuts.") return {} self.logger.info(f"Protontricks detection successful: {self.which_protontricks}") # --- End detection check --- try: cmd = [] # Initialize cmd list if self.which_protontricks == 'flatpak': cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "-l"] elif self.protontricks_path: cmd = [self.protontricks_path, "-l"] else: logger.error("Protontricks path not determined, cannot list shortcuts.") return {} self.logger.debug(f"Running command: {' '.join(cmd)}") # Bundled-runtime fix: Use cleaned environment env = self._get_clean_subprocess_env() result = subprocess.run(cmd, capture_output=True, text=True, check=True, encoding='utf-8', errors='ignore', env=env) # Regex to capture name and AppID pattern = re.compile(r"Non-Steam shortcut:\s+(.+)\s+\((\d+)\)") for line in result.stdout.splitlines(): line = line.strip() match = pattern.match(line) if match: app_name = match.group(1).strip() # Get the name app_id = match.group(2).strip() # Get the AppID non_steam_shortcuts[app_name] = app_id logger.debug(f"Found non-Steam shortcut: '{app_name}' with AppID {app_id}") if not non_steam_shortcuts: logger.warning("No non-Steam shortcuts found in protontricks output.") except FileNotFoundError: logger.error(f"Protontricks command not found. Path: {cmd[0] if cmd else 'N/A'}") return {} except subprocess.CalledProcessError as e: # Log error but don't necessarily stop; might have partial output logger.error(f"Error running protontricks -l (Exit code: {e.returncode}): {e}") logger.error(f"Stderr (truncated): {e.stderr[:500] if e.stderr else ''}") # Return what we have, might be useful except Exception as e: logger.error(f"Unexpected error listing non-Steam shortcuts: {e}", exc_info=True) return {} return non_steam_shortcuts def enable_dotfiles(self, appid): """ Enable visibility of (.)dot files in the Wine prefix Returns True on success, False on failure Args: appid (str): The app ID to use Returns: bool: True on success, False on failure """ logger.debug(f"APPID={appid}") logger.info("Enabling visibility of (.)dot files...") try: # Check current setting result = self.run_protontricks( "-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles", appid, stderr=subprocess.DEVNULL # Suppress stderr for this query ) # Check if the initial query command ran successfully and contained expected output if result and result.returncode == 0 and "ShowDotFiles" in result.stdout and "Y" in result.stdout: logger.info("DotFiles already enabled via registry... skipping") return True elif result and result.returncode != 0: # Log as info/debug since non-zero exit is expected if key doesn't exist logger.info(f"Initial query for ShowDotFiles likely failed because the key doesn't exist yet (Exit Code: {result.returncode}). Proceeding to set it. Stderr: {result.stderr}") elif not result: logger.error("Failed to execute initial dotfile query command.") # Proceed cautiously # --- Try to set the value --- dotfiles_set_success = False # Method 1: Set registry key (Primary Method) logger.debug("Attempting to set ShowDotFiles registry key...") result_add = self.run_protontricks( "-c", "WINEDEBUG=-all wine reg add \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles /d Y /f", appid, # Keep stderr for this one to log potential errors from reg add # stderr=subprocess.DEVNULL ) if result_add and result_add.returncode == 0: logger.info("'wine reg add' command executed successfully.") dotfiles_set_success = True # Tentative success elif result_add: logger.warning(f"'wine reg add' command failed (Exit Code: {result_add.returncode}). Stderr: {result_add.stderr}") else: logger.error("Failed to execute 'wine reg add' command.") # Method 2: Create user.reg entry (Backup Method) # This is useful if registry commands fail but direct file access works logger.debug("Ensuring user.reg has correct entry...") prefix_path = self.get_wine_prefix_path(appid) if prefix_path: user_reg_path = Path(prefix_path) / "user.reg" try: if user_reg_path.exists(): content = user_reg_path.read_text(encoding='utf-8', errors='ignore') # Check for CORRECT format with proper backslash escaping has_correct_format = '[Software\\\\Wine]' in content and '"ShowDotFiles"="Y"' in content has_broken_format = '[SoftwareWine]' in content and '"ShowDotFiles"="Y"' in content if has_broken_format and not has_correct_format: # Fix the broken format by replacing the section header logger.debug(f"Found broken ShowDotFiles format in {user_reg_path}, fixing...") content = content.replace('[SoftwareWine]', '[Software\\\\Wine]') user_reg_path.write_text(content, encoding='utf-8') dotfiles_set_success = True elif not has_correct_format: logger.debug(f"Adding ShowDotFiles entry to {user_reg_path}") with open(user_reg_path, 'a', encoding='utf-8') as f: f.write('\n[Software\\\\Wine] 1603891765\n') f.write('"ShowDotFiles"="Y"\n') dotfiles_set_success = True # Count file write as success too else: logger.debug("ShowDotFiles already present in correct format in user.reg") dotfiles_set_success = True # Already there counts as success else: logger.warning(f"user.reg not found at {user_reg_path}, creating it.") with open(user_reg_path, 'w', encoding='utf-8') as f: f.write('[Software\\\\Wine] 1603891765\n') f.write('"ShowDotFiles"="Y"\n') dotfiles_set_success = True # Creating file counts as success except Exception as e: logger.warning(f"Error reading/writing user.reg: {e}") else: logger.warning("Could not get WINEPREFIX path, skipping user.reg modification.") # --- Verification Step --- logger.debug("Verifying dotfile setting after attempts...") verify_result = self.run_protontricks( "-c", "WINEDEBUG=-all wine reg query \"HKEY_CURRENT_USER\\Software\\Wine\" /v ShowDotFiles", appid, stderr=subprocess.DEVNULL # Suppress stderr for verification query ) query_verified = False if verify_result and verify_result.returncode == 0 and "ShowDotFiles" in verify_result.stdout and "Y" in verify_result.stdout: logger.debug("Verification query successful and key is set.") query_verified = True elif verify_result: # Change Warning to Info - verification failing right after setting is common logger.info(f"Verification query failed or key not found (Exit Code: {verify_result.returncode}). Stderr: {verify_result.stderr}") else: logger.error("Failed to execute verification query command.") # --- Final Decision --- if dotfiles_set_success: # If the add command or file write succeeded, we report overall success, # even if the verification query failed, but log the query status. if query_verified: logger.info("Dotfiles enabled and verified successfully!") else: # Change Warning to Info - verification failing right after setting is common logger.info("Dotfiles potentially enabled (reg add/user.reg succeeded), but verification query failed.") return True # Report success based on the setting action else: # If both the reg add and user.reg steps failed logger.error("Failed to enable dotfiles using registry and user.reg methods.") return False except Exception as e: logger.error(f"Unexpected error enabling dotfiles: {e}", exc_info=True) return False def set_win10_prefix(self, appid): """ Set Windows 10 version in the proton prefix Returns True on success, False on failure """ try: # Bundled-runtime fix: Use cleaned environment env = self._get_clean_subprocess_env() env["WINEDEBUG"] = "-all" if self.which_protontricks == 'flatpak': cmd = self._get_flatpak_run_args() + ["com.github.Matoking.protontricks", "--no-bwrap", appid, "win10"] else: cmd = ["protontricks", "--no-bwrap", appid, "win10"] subprocess.run(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return True except Exception as e: logger.error(f"Error setting Windows 10 prefix: {e}") return False def protontricks_alias(self): """ Create protontricks alias in ~/.bashrc """ logger.info("Creating protontricks alias in ~/.bashrc...") try: if self.which_protontricks == 'flatpak': # Check if aliases already exist bashrc_path = os.path.expanduser("~/.bashrc") protontricks_alias_exists = False launch_alias_exists = False if os.path.exists(bashrc_path): with open(bashrc_path, 'r') as f: content = f.read() protontricks_alias_exists = "alias protontricks=" in content launch_alias_exists = "alias protontricks-launch=" in content # Add aliases if they don't exist with correct flag based on installation type with open(bashrc_path, 'a') as f: if not protontricks_alias_exists: f.write("\n# Jackify: Protontricks alias\n") alias_cmd = self._get_flatpak_alias_string() f.write(f"alias protontricks='{alias_cmd}'\n") logger.debug("Added protontricks alias to ~/.bashrc") if not launch_alias_exists: f.write("\n# Jackify: Protontricks-launch alias\n") launch_alias_cmd = self._get_flatpak_alias_string(command='protontricks-launch') f.write(f"alias protontricks-launch='{launch_alias_cmd}'\n") logger.debug("Added protontricks-launch alias to ~/.bashrc") logger.info("Protontricks aliases created successfully") return True else: logger.info("Protontricks is not installed via flatpak, skipping alias creation") return True except Exception as e: logger.error(f"Error creating protontricks alias: {e}") return False def get_wine_prefix_path(self, appid) -> Optional[str]: """Gets the WINEPREFIX path for a given AppID. Uses native path discovery when enabled, falls back to protontricks detection. Args: appid (str): The Steam AppID. Returns: The WINEPREFIX path as a string, or None if detection fails. """ # Use native operations if enabled if self.use_native_operations: logger.debug(f"Getting WINEPREFIX for AppID {appid} via native path discovery") try: return self._get_native_steam_service().get_wine_prefix_path(appid) except Exception as e: logger.warning(f"Native WINEPREFIX detection failed, falling back to protontricks: {e}") logger.debug(f"Getting WINEPREFIX for AppID {appid}") result = self.run_protontricks("-c", "echo $WINEPREFIX", appid) if result and result.returncode == 0 and result.stdout.strip(): prefix_path = result.stdout.strip() logger.debug(f"Detected WINEPREFIX: {prefix_path}") return prefix_path else: logger.error(f"Failed to get WINEPREFIX for AppID {appid}. Stderr: {result.stderr if result else 'N/A'}") return None def run_protontricks_launch(self, appid, installer_path, *extra_args): """ Run protontricks-launch (for WebView or similar installers) using the correct method for bundled, flatpak, or native. Returns subprocess.CompletedProcess object. """ if self.which_protontricks is None: if not self.detect_protontricks(): self.logger.error("Could not detect protontricks installation") return None if self.which_protontricks == 'bundled': # CRITICAL: Use safe Python executable to prevent AppImage recursive spawning from .subprocess_utils import get_safe_python_executable python_exe = get_safe_python_executable() # Use bundled Python module cmd = [python_exe, "-m", "protontricks.cli.launch", "--appid", appid, str(installer_path)] elif self.which_protontricks == 'flatpak': cmd = self._get_flatpak_run_args() + ["--command=protontricks-launch", "com.github.Matoking.protontricks", "--appid", appid, str(installer_path)] else: # native launch_path = shutil.which("protontricks-launch") if not launch_path: self.logger.error("protontricks-launch command not found in PATH.") return None cmd = [launch_path, "--appid", appid, str(installer_path)] if extra_args: cmd.extend(extra_args) self.logger.debug(f"Running protontricks-launch: {' '.join(map(str, cmd))}") try: # Bundled-runtime fix: Use cleaned environment env = self._get_clean_subprocess_env() return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env) except Exception as e: self.logger.error(f"Error running protontricks-launch: {e}") return None def _ensure_flatpak_cache_access(self, cache_path: Path) -> bool: """ Ensure flatpak protontricks has filesystem access to the winetricks cache. Args: cache_path: Path to winetricks cache directory Returns: True if access granted or already exists, False on failure """ if self.which_protontricks != 'flatpak': return True # Not flatpak, no action needed try: # Check if flatpak already has access to this path result = subprocess.run( ['flatpak', 'override', '--user', '--show', 'com.github.Matoking.protontricks'], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: # Check if cache path is already in filesystem overrides cache_str = str(cache_path.resolve()) if f'filesystems=' in result.stdout and cache_str in result.stdout: self.logger.debug(f"Flatpak protontricks already has access to cache: {cache_str}") return True # Grant access to cache directory self.logger.info(f"Granting flatpak protontricks access to winetricks cache: {cache_path}") result = subprocess.run( ['flatpak', 'override', '--user', 'com.github.Matoking.protontricks', f'--filesystem={cache_path.resolve()}'], capture_output=True, text=True, timeout=10 ) if result.returncode == 0: self.logger.info("Successfully granted flatpak protontricks cache access") return True else: self.logger.warning(f"Failed to grant flatpak cache access: {result.stderr}") return False except Exception as e: self.logger.warning(f"Could not configure flatpak cache access: {e}") return False def install_wine_components(self, appid, game_var, specific_components: Optional[List[str]] = None): """ Install the specified Wine components into the given prefix using protontricks. If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022). """ env = self._get_clean_subprocess_env() env["WINEDEBUG"] = "-all" # CRITICAL: Only set bundled winetricks for NATIVE protontricks # Flatpak protontricks runs in a sandbox and CANNOT access AppImage FUSE mounts (/tmp/.mount_*) # Flatpak protontricks has its own winetricks bundled inside the flatpak if self.which_protontricks == 'native': winetricks_path = self._get_bundled_winetricks_path() if winetricks_path: env['WINETRICKS'] = str(winetricks_path) self.logger.debug(f"Set WINETRICKS for native protontricks: {winetricks_path}") else: self.logger.warning("Bundled winetricks not found - native protontricks will use system winetricks") cabextract_path = self._get_bundled_cabextract_path() if cabextract_path: cabextract_dir = str(cabextract_path.parent) current_path = env.get('PATH', '') env['PATH'] = f"{cabextract_dir}{os.pathsep}{current_path}" if current_path else cabextract_dir self.logger.debug(f"Added bundled cabextract to PATH for native protontricks: {cabextract_dir}") else: self.logger.warning("Bundled cabextract not found - native protontricks will use system cabextract") else: # Flatpak protontricks - DO NOT set bundled paths self.logger.info(f"Using {self.which_protontricks} protontricks - it has its own winetricks (cannot access AppImage mounts)") # CRITICAL: Suppress winetricks verbose output when not in debug mode from ..handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) if not debug_mode: env['WINETRICKS_SUPER_QUIET'] = '1' self.logger.debug("Set WINETRICKS_SUPER_QUIET=1 in install_wine_components to suppress winetricks verbose output") # Set up winetricks cache (shared with winetricks_handler for efficiency) from jackify.shared.paths import get_jackify_data_dir jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache' jackify_cache_dir.mkdir(parents=True, exist_ok=True) # Ensure flatpak protontricks has access to cache (no-op for native) self._ensure_flatpak_cache_access(jackify_cache_dir) env['WINETRICKS_CACHE'] = str(jackify_cache_dir) self.logger.info(f"Using winetricks cache: {jackify_cache_dir}") if specific_components is not None: components_to_install = specific_components self.logger.info(f"Installing specific components: {components_to_install}") else: components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"] self.logger.info(f"Installing default components: {components_to_install}") if not components_to_install: self.logger.info("No Wine components to install.") return True self.logger.info(f"AppID: {appid}, Game: {game_var}, Components: {components_to_install}") # print(f"\n[Jackify] Installing Wine components for AppID {appid} ({game_var}):\n {', '.join(components_to_install)}\n") # Suppressed per user request max_attempts = 3 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() try: result = self.run_protontricks("--no-bwrap", appid, "-q", *components_to_install, env=env, timeout=600) self.logger.debug(f"Protontricks output: {result.stdout if result else ''}") if result and result.returncode == 0: self.logger.info("Wine Component installation command completed.") # Verify components were actually installed if self._verify_components_installed(appid, components_to_install): self.logger.info("Component verification successful - all components installed correctly.") return True else: self.logger.error(f"Component verification failed (Attempt {attempt}/{max_attempts})") # Continue to retry else: self.logger.error(f"Protontricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode if result else 'N/A'}") # Only show stdout/stderr in debug mode to avoid verbose output from ..handlers.config_handler import ConfigHandler config_handler = ConfigHandler() debug_mode = config_handler.get('debug_mode', False) if debug_mode: self.logger.error(f"Stdout: {result.stdout.strip() if result else ''}") self.logger.error(f"Stderr: {result.stderr.strip() if result else ''}") else: # In non-debug mode, only show stderr if it contains actual errors (not verbose winetricks output) if result and result.stderr: stderr_lower = result.stderr.lower() # Filter out verbose winetricks messages if any(keyword in stderr_lower for keyword in ['error', 'failed', 'cannot', 'warning: cannot find']): # Only show actual errors, not "Executing..." messages error_lines = [line for line in result.stderr.strip().split('\n') if any(keyword in line.lower() for keyword in ['error', 'failed', 'cannot', 'warning: cannot find']) and 'executing' not in line.lower()] if error_lines: self.logger.error(f"Stderr (errors only): {' '.join(error_lines)}") except Exception as e: self.logger.error(f"Error during protontricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True) self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.") return False def _verify_components_installed(self, appid: str, components: List[str]) -> bool: """ Verify that Wine components were actually installed by querying protontricks. Args: appid: Steam AppID components: List of components that should be installed Returns: bool: True if all critical components are verified, False otherwise """ try: self.logger.info("Verifying installed components...") # Run protontricks list-installed to get actual installed components result = self.run_protontricks("--no-bwrap", appid, "list-installed", timeout=30) if not result or result.returncode != 0: self.logger.error("Failed to query installed components") self.logger.debug(f"list-installed stderr: {result.stderr if result else 'N/A'}") return False installed_output = result.stdout.lower() self.logger.debug(f"Installed components output: {installed_output}") # Define critical components that MUST be installed # These are the core components that determine success critical_components = ["vcrun2022", "xact"] # Check for critical components missing_critical = [] for component in critical_components: if component.lower() not in installed_output: missing_critical.append(component) if missing_critical: self.logger.error(f"CRITICAL: Missing essential components: {missing_critical}") self.logger.error("Installation reported success but components are NOT installed") return False # Check for requested components (warn but don't fail) missing_requested = [] for component in components: # Handle settings like fontsmooth=rgb (just check the base component name) base_component = component.split('=')[0].lower() if base_component not in installed_output and component.lower() not in installed_output: missing_requested.append(component) if missing_requested: self.logger.warning(f"Some requested components may not be installed: {missing_requested}") self.logger.warning("This may cause issues, but critical components are present") self.logger.info(f"Verification passed - critical components confirmed: {critical_components}") return True except Exception as e: self.logger.error(f"Error verifying components: {e}", exc_info=True) return False def _cleanup_wine_processes(self): """ Internal method to clean up wine processes during component installation """ try: subprocess.run("pgrep -f 'win7|win10|ShowDotFiles|protontricks' | xargs -r kill -9", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run("pkill -9 winetricks", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: logger.error(f"Error cleaning up wine processes: {e}") def check_and_setup_protontricks(self) -> bool: """ Runs all necessary checks and setup steps for Protontricks. - Detects (and prompts for install if missing) - Checks version - Creates aliases if using Flatpak Returns: bool: True if Protontricks is ready to use, False otherwise. """ logger.info("Checking and setting up Protontricks...") logger.info("Checking Protontricks installation...") if not self.detect_protontricks(): # Error message already printed by detect_protontricks if install fails/skipped return False logger.info(f"Protontricks detected: {self.which_protontricks}") logger.info("Checking Protontricks version...") if not self.check_protontricks_version(): # Error message already printed by check_protontricks_version print(f"Error: Protontricks version {self.protontricks_version} is too old or could not be checked.") return False logger.info(f"Protontricks version {self.protontricks_version} is sufficient.") # Aliases are non-critical, log warning if creation fails if self.which_protontricks == 'flatpak': logger.info("Ensuring Flatpak aliases exist in ~/.bashrc...") if not self.protontricks_alias(): # Logged by protontricks_alias, maybe add print? print("Warning: Failed to create/verify protontricks aliases in ~/.bashrc") # Don't necessarily fail the whole setup for this logger.info("Protontricks check and setup completed successfully.") return True