mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
985 lines
49 KiB
Python
985 lines
49 KiB
Python
#!/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' or 'native'
|
|
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
|
|
try:
|
|
env = self._get_clean_subprocess_env()
|
|
result = subprocess.run(
|
|
["flatpak", "list"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env
|
|
)
|
|
if result.returncode == 0 and "com.github.Matoking.protontricks" in result.stdout:
|
|
logger.info("Flatpak Protontricks is installed")
|
|
self.which_protontricks = 'flatpak'
|
|
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 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 = ["flatpak", "run", "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 = ["flatpak", "run", "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 open(bashrc_path, 'a') as f:
|
|
if not protontricks_alias_exists:
|
|
logger.info("Adding protontricks alias to ~/.bashrc")
|
|
f.write("\nalias protontricks='flatpak run com.github.Matoking.protontricks'\n")
|
|
|
|
if not launch_alias_exists:
|
|
logger.info("Adding protontricks-launch alias to ~/.bashrc")
|
|
f.write("\nalias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'\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 = ["flatpak", "run", "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')
|
|
if "ShowDotFiles" not in content:
|
|
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 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 = ["flatpak", "run", "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='flatpak run com.github.Matoking.protontricks'" in content
|
|
launch_alias_exists = "alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'" in content
|
|
|
|
# Add aliases if they don't exist
|
|
with open(bashrc_path, 'a') as f:
|
|
if not protontricks_alias_exists:
|
|
f.write("\n# Jackify: Protontricks alias\n")
|
|
f.write("alias protontricks='flatpak run com.github.Matoking.protontricks'\n")
|
|
logger.debug("Added protontricks alias to ~/.bashrc")
|
|
|
|
if not launch_alias_exists:
|
|
f.write("\n# Jackify: Protontricks-launch alias\n")
|
|
f.write("alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'\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 = ["flatpak", "run", "--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 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)
|
|
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 |