Sync from development - prepare for v0.3.0

This commit is contained in:
Omni
2026-02-07 18:26:54 +00:00
parent b55e1cf768
commit 12294d3186
169 changed files with 31749 additions and 33649 deletions

View File

@@ -0,0 +1,500 @@
"""Prefix creation methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional
import logging
import os
import time
import subprocess
logger = logging.getLogger(__name__)
def debug_print(message):
"""Log debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
logger.debug(message)
class PrefixCreationMixin:
"""Mixin providing prefix creation methods for AutomatedPrefixService."""
def detect_actual_prefix_appid(self, initial_appid: int, shortcut_name: str) -> Optional[int]:
"""
After Steam restart, detect the actual prefix AppID that was created.
Uses direct VDF file reading to find the actual AppID.
Args:
initial_appid: The initial (negative) AppID from shortcuts.vdf
shortcut_name: Name of the shortcut for logging
Returns:
The actual (positive) AppID of the created prefix, or None if not found
"""
try:
logger.info(f"Using VDF to detect actual AppID for shortcut: {shortcut_name}")
# Wait up to 30 seconds for Steam to process the shortcut
for i in range(30):
try:
from ..handlers.shortcut_handler import ShortcutHandler
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
shortcuts_path = path_handler._find_shortcuts_vdf()
if shortcuts_path:
from ..handlers.vdf_handler import VDFHandler
shortcuts_data = VDFHandler.load(shortcuts_path, binary=True)
if shortcuts_data and 'shortcuts' in shortcuts_data:
for idx, shortcut in shortcuts_data['shortcuts'].items():
app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
if app_name.lower() == shortcut_name.lower():
appid = shortcut.get('appid')
if appid:
actual_appid = int(appid) & 0xFFFFFFFF
logger.info(f"Found shortcut '{app_name}' in shortcuts.vdf")
logger.info(f" Initial AppID (signed): {initial_appid}")
logger.info(f" Actual AppID (unsigned): {actual_appid}")
return actual_appid
logger.debug(f"Shortcut '{shortcut_name}' not found in VDF yet (attempt {i+1}/30)")
time.sleep(1)
except Exception as e:
logger.warning(f"Error reading shortcuts.vdf on attempt {i+1}: {e}")
time.sleep(1)
logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf after 30 seconds")
return None
except Exception as e:
logger.error(f"Error detecting actual prefix AppID: {e}")
return None
def launch_shortcut_to_trigger_prefix(self, initial_appid: int) -> bool:
"""
Launch the shortcut using rungameid to trigger prefix creation.
This follows the same pattern as the working test script.
Args:
initial_appid: The initial (negative) AppID from shortcuts.vdf
Returns:
True if successful, False otherwise
"""
try:
# Convert signed AppID to unsigned AppID (same as STL's generateSteamShortID)
unsigned_appid = self.generate_steam_short_id(initial_appid)
# Calculate rungameid using the unsigned AppID
rungameid = (unsigned_appid << 32) | 0x02000000
logger.info(f"Launching shortcut with rungameid: {rungameid}")
debug_print(f"[DEBUG] Launching shortcut with rungameid: {rungameid}")
debug_print(f"[DEBUG] Initial signed AppID: {initial_appid}")
debug_print(f"[DEBUG] Unsigned AppID: {unsigned_appid}")
# Launch using rungameid
cmd = ['steam', f'steam://rungameid/{rungameid}']
debug_print(f"[DEBUG] About to run launch command: {' '.join(cmd)}")
# Use subprocess.Popen to launch asynchronously (steam command returns immediately)
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
# Wait a moment for the process to start
time.sleep(1)
# Check if the process is still running (steam command should exit quickly)
try:
return_code = process.poll()
if return_code is None:
# Process is still running, wait a bit more
time.sleep(2)
return_code = process.poll()
debug_print(f"[DEBUG] Steam launch process return code: {return_code}")
# Get any output
stdout, stderr = process.communicate(timeout=1)
if stdout:
debug_print(f"[DEBUG] Steam launch stdout: {stdout}")
if stderr:
debug_print(f"[DEBUG] Steam launch stderr: {stderr}")
except subprocess.TimeoutExpired:
debug_print("[DEBUG] Steam launch process timed out, but that's OK")
process.kill()
logger.info(f"Launch command executed: {' '.join(cmd)}")
# Give it a moment for the shortcut to actually start
time.sleep(5)
return True
except subprocess.TimeoutExpired:
logger.error("Launch command timed out")
debug_print("[DEBUG] Launch command timed out")
return False
except Exception as e:
logger.error(f"Error launching shortcut: {e}")
debug_print(f"[DEBUG] Error launching shortcut: {e}")
return False
def create_prefix_directly(self, appid: int, batch_file_path: str) -> Optional[Path]:
"""
Create prefix directly using Proton wrapper.
Args:
appid: The AppID from the shortcut
batch_file_path: Path to the temporary batch file
Returns:
Path to the created prefix, or None if failed
"""
proton_path = self.find_proton_experimental()
if not proton_path:
return None
# Steam uses negative AppIDs for non-Steam shortcuts, but we need the positive value for the prefix path
positive_appid = abs(appid)
logger.info(f"Using positive AppID {positive_appid} for prefix creation (original: {appid})")
# Create the prefix directory structure
prefix_path = self._get_compatdata_path_for_appid(positive_appid)
if not prefix_path:
logger.error(f"Could not determine compatdata path for AppID {positive_appid}")
return None
# Create the prefix directory structure
prefix_path.mkdir(parents=True, exist_ok=True)
pfx_dir = prefix_path / "pfx"
pfx_dir.mkdir(exist_ok=True)
# Set up environment
env = os.environ.copy()
env['STEAM_COMPAT_DATA_PATH'] = str(prefix_path)
env['STEAM_COMPAT_APP_ID'] = str(positive_appid) # Use positive AppID for environment
# Determine correct Steam root based on installation type
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
steam_library = path_handler.find_steam_library()
if steam_library and steam_library.name == "common":
# Extract Steam root from library path: .../Steam/steamapps/common -> .../Steam
steam_root = steam_library.parent.parent
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root)
else:
# Fallback to legacy path if detection fails
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(Path.home() / ".local/share/Steam")
# Build the command
cmd = [
str(proton_path / "proton"),
"run",
batch_file_path
]
logger.info(f"Creating prefix with command: {' '.join(cmd)}")
logger.info(f"Prefix path: {prefix_path}")
logger.info(f"Using AppID: {positive_appid} (original: {appid})")
try:
# Run the command with a timeout
result = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=30
)
# Check if prefix was created
time.sleep(2) # Give it a moment to settle
prefix_created = prefix_path.exists()
pfx_exists = (prefix_path / "pfx").exists()
logger.info(f"Return code: {result.returncode}")
logger.info(f"Prefix created: {prefix_created}")
logger.info(f"pfx directory exists: {pfx_exists}")
if result.stderr:
logger.debug(f"stderr: {result.stderr.strip()}")
success = prefix_created and pfx_exists
if success:
logger.info(f"Prefix created successfully at: {prefix_path}")
return prefix_path
else:
logger.error("Failed to create prefix")
return None
except subprocess.TimeoutExpired:
logger.warning("Command timed out, but this might be normal")
# Check if prefix was created despite timeout
prefix_created = prefix_path.exists()
pfx_exists = (prefix_path / "pfx").exists()
if prefix_created and pfx_exists:
logger.info(f"Prefix created successfully despite timeout at: {prefix_path}")
return prefix_path
else:
logger.error("No prefix created")
return None
except Exception as e:
logger.error(f"Error creating prefix: {e}")
return None
def _get_compatdata_path_for_appid(self, appid: int) -> Optional[Path]:
"""
Get the compatdata path for a given AppID.
First tries to find existing compatdata, then constructs path from libraryfolders.vdf
for creating new prefixes.
Args:
appid: The AppID to get the path for
Returns:
Path to the compatdata directory, or None if not found
"""
from ..handlers.path_handler import PathHandler
# First, try to find existing compatdata
compatdata_path = PathHandler.find_compat_data(str(appid))
if compatdata_path:
return compatdata_path
# Prefix doesn't exist yet - determine where to create it from libraryfolders.vdf
library_paths = PathHandler.get_all_steam_library_paths()
if library_paths:
# Use the first library (typically the default library)
# Construct compatdata path: library_path/steamapps/compatdata/appid
first_library = library_paths[0]
compatdata_base = first_library / "steamapps" / "compatdata"
return compatdata_base / str(appid)
# Only fallback if VDF parsing completely fails
logger.warning("Could not get library paths from libraryfolders.vdf, using fallback locations")
fallback_bases = [
Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/compatdata",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata",
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata",
]
for base_path in fallback_bases:
if base_path.is_dir():
return base_path / str(appid)
return None
def verify_prefix_creation(self, prefix_path: Path) -> bool:
"""
Verify that the prefix was created successfully.
Args:
prefix_path: Path to the prefix directory
Returns:
True if prefix is valid, False otherwise
"""
try:
logger.info(f"Verifying prefix: {prefix_path}")
# Check if prefix exists and has proper structure
if not prefix_path.exists():
logger.error("Prefix directory does not exist")
return False
pfx_dir = prefix_path / "pfx"
if not pfx_dir.exists():
logger.error("Prefix exists but no pfx subdirectory")
return False
# Check for key Wine files
system_reg = pfx_dir / "system.reg"
user_reg = pfx_dir / "user.reg"
drive_c = pfx_dir / "drive_c"
if not system_reg.exists():
logger.error("No system.reg found in prefix")
return False
if not user_reg.exists():
logger.error("No user.reg found in prefix")
return False
if not drive_c.exists():
logger.error("No drive_c directory found in prefix")
return False
logger.info("Prefix structure verified successfully")
return True
except Exception as e:
logger.error(f"Error verifying prefix: {e}")
return False
def wait_for_prefix_completion(self, prefix_id: str, timeout: int = 60) -> bool:
"""
Wait for system.reg to stop growing (indicates prefix creation is complete).
Args:
prefix_id: The Steam prefix ID to monitor
timeout: Maximum seconds to wait
Returns:
True if prefix creation completed, False if timeout
"""
try:
prefix_path = Path.home() / f".local/share/Steam/steamapps/compatdata/{prefix_id}"
system_reg = prefix_path / "pfx/system.reg"
logger.info(f"Monitoring prefix completion: {system_reg}")
last_size = 0
stable_count = 0
for i in range(timeout):
if system_reg.exists():
current_size = system_reg.stat().st_size
logger.debug(f"system.reg size: {current_size} bytes")
if current_size == last_size:
stable_count += 1
if stable_count >= 3: # Stable for 3 seconds
logger.info(" system.reg size stable - prefix creation complete")
return True
else:
stable_count = 0
last_size = current_size
time.sleep(1)
logger.warning(f"Timeout waiting for prefix completion after {timeout} seconds")
return False
except Exception as e:
logger.error(f"Error monitoring prefix completion: {e}")
return False
def create_prefix_with_proton_wrapper(self, appid: int) -> bool:
"""
Create a Proton prefix directly using Proton's wrapper and STEAM_COMPAT_DATA_PATH.
Args:
appid: The AppID to create the prefix for
Returns:
True if successful, False otherwise
"""
try:
# Determine Steam locations based on installation type
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
all_libraries = path_handler.get_all_steam_library_paths()
# Check if we have Flatpak Steam by looking for .var/app/com.valvesoftware.Steam in library paths
is_flatpak_steam = any('.var/app/com.valvesoftware.Steam' in str(lib) for lib in all_libraries)
if is_flatpak_steam and all_libraries:
# Flatpak Steam: Use the actual library root from libraryfolders.vdf
# Compatdata should be in the library root, not the client root
flatpak_library_root = all_libraries[0] # Use first library (typically the default)
flatpak_client_root = flatpak_library_root.parent.parent / ".steam/steam"
if not flatpak_library_root.is_dir():
logger.error(
f"Flatpak Steam library root does not exist: {flatpak_library_root}"
)
return False
steam_root = flatpak_client_root if flatpak_client_root.is_dir() else flatpak_library_root
# CRITICAL: compatdata must be in the library root, not client root
compatdata_dir = flatpak_library_root / "steamapps/compatdata"
proton_common_dir = flatpak_library_root / "steamapps/common"
else:
# Native Steam (or unknown): fall back to legacy ~/.steam/steam layout
steam_root = Path.home() / ".steam/steam"
compatdata_dir = steam_root / "steamapps/compatdata"
proton_common_dir = steam_root / "steamapps/common"
# Ensure compatdata root exists and is a directory we actually want to use
if not compatdata_dir.is_dir():
logger.error(f"Compatdata root does not exist: {compatdata_dir}. Aborting prefix creation.")
return False
# Find a Proton wrapper to use
proton_path = self._find_proton_binary(proton_common_dir)
if not proton_path:
logger.error("No Proton wrapper found")
return False
# Set up environment variables
env = os.environ.copy()
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root)
env['STEAM_COMPAT_DATA_PATH'] = str(compatdata_dir / str(abs(appid)))
# Suppress GUI windows using jackify-engine's proven approach
env['DISPLAY'] = ''
env['WAYLAND_DISPLAY'] = ''
env['WINEDEBUG'] = '-all'
env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d'
# Create the compatdata directory for this AppID (but never the whole tree)
compat_dir = compatdata_dir / str(abs(appid))
compat_dir.mkdir(exist_ok=True)
logger.info(f"Creating Proton prefix for AppID {appid}")
logger.info(f"STEAM_COMPAT_CLIENT_INSTALL_PATH={env['STEAM_COMPAT_CLIENT_INSTALL_PATH']}")
logger.info(f"STEAM_COMPAT_DATA_PATH={env['STEAM_COMPAT_DATA_PATH']}")
# Run proton run wineboot -u to initialize the prefix
cmd = [str(proton_path), 'run', 'wineboot', '-u']
logger.info(f"Running: {' '.join(cmd)}")
# Adjust timeout for SD card installations on Steam Deck (slower I/O)
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
is_steamdeck_sdcard = (platform_service.is_steamdeck and
str(proton_path).startswith('/run/media/'))
timeout = 180 if is_steamdeck_sdcard else 60
if is_steamdeck_sdcard:
logger.info(f"Using extended timeout ({timeout}s) for Steam Deck SD card Proton installation")
# Use jackify-engine's approach: UseShellExecute=false, CreateNoWindow=true equivalent
result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout,
shell=False, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
logger.info(f"Proton exit code: {result.returncode}")
if result.stdout:
logger.info(f"stdout: {result.stdout.strip()[:500]}")
if result.stderr:
logger.info(f"stderr: {result.stderr.strip()[:500]}")
# Give a moment for files to land
time.sleep(3)
# Check if prefix was created
pfx = compat_dir / 'pfx'
if pfx.exists():
logger.info(f" Proton prefix created at: {pfx}")
return True
else:
logger.warning(f"Proton prefix not found at: {pfx}")
return False
except subprocess.TimeoutExpired:
logger.warning("Proton timed out; prefix may still be initializing")
return False
except Exception as e:
logger.error(f"Error creating prefix: {e}")
return False

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Game utilities mixin for AutomatedPrefixService.
Handles game-specific operations:
- Launch options generation
- Game detection
- User directory creation
- Proton version preferences
"""
import os
import logging
from pathlib import Path
from typing import Optional, List
logger = logging.getLogger(__name__)
class GameUtilsMixin:
"""Mixin for game-related utility operations"""
def _generate_special_game_launch_options(self, special_game_type: str, modlist_install_dir: str) -> Optional[str]:
"""
Generate launch options for FNV/Enderal games that require vanilla compatdata.
Args:
special_game_type: "fnv" or "enderal"
modlist_install_dir: Directory where the modlist is installed
Returns:
Complete launch options string with STEAM_COMPAT_DATA_PATH, or None if failed
"""
if not special_game_type or special_game_type not in ["fnv", "enderal"]:
return None
logger.info(f"Generating {special_game_type.upper()} launch options")
# Map game types to AppIDs
appid_map = {"fnv": "22380", "enderal": "976620"}
appid = appid_map[special_game_type]
# Find vanilla game compatdata
from ..handlers.path_handler import PathHandler
compatdata_path = PathHandler.find_compat_data(appid)
if not compatdata_path:
logger.error(f"Could not find vanilla {special_game_type.upper()} compatdata directory (AppID {appid})")
return None
# Create STEAM_COMPAT_DATA_PATH string
compat_data_str = f'STEAM_COMPAT_DATA_PATH="{compatdata_path}"'
# Generate STEAM_COMPAT_MOUNTS if multiple libraries exist
compat_mounts_str = ""
try:
all_libs = PathHandler.get_all_steam_library_paths()
main_steam_lib_path_obj = PathHandler.find_steam_library()
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
else:
main_steam_lib_path = main_steam_lib_path_obj
mount_paths = []
if main_steam_lib_path:
main_resolved = main_steam_lib_path.resolve()
for lib_path in all_libs:
if lib_path.resolve() != main_resolved:
mount_paths.append(str(lib_path.resolve()))
if mount_paths:
mount_paths_str = ':'.join(mount_paths)
compat_mounts_str = f'STEAM_COMPAT_MOUNTS="{mount_paths_str}"'
logger.info(f"Added STEAM_COMPAT_MOUNTS for {special_game_type.upper()}")
except Exception as e:
logger.warning(f"Error generating STEAM_COMPAT_MOUNTS for {special_game_type}: {e}")
# Combine all launch options
launch_options = f"{compat_mounts_str} {compat_data_str} %command%".strip()
launch_options = ' '.join(launch_options.split()) # Clean up spacing
logger.info(f"Generated {special_game_type.upper()} launch options: {launch_options}")
return launch_options
def _find_steam_game(self, app_id: str, common_names: list) -> Optional[str]:
"""Find a Steam game installation path by AppID and common names"""
import os
from pathlib import Path
# Get Steam libraries from libraryfolders.vdf - check multiple possible locations
possible_config_paths = [
Path.home() / ".steam/steam/config/libraryfolders.vdf",
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf" # Flatpak
]
steam_config_path = None
for path in possible_config_paths:
if path.exists():
steam_config_path = path
break
if not steam_config_path:
return None
steam_libraries = []
try:
with open(steam_config_path, 'r') as f:
content = f.read()
# Parse library paths from VDF
import re
library_matches = re.findall(r'"path"\s+"([^"]+)"', content)
steam_libraries = [Path(path) / "steamapps" / "common" for path in library_matches]
except Exception as e:
logger.warning(f"Failed to parse Steam library folders: {e}")
return None
# Search for game in each library
for library_path in steam_libraries:
if not library_path.exists():
continue
# Check manifest file first (more reliable)
manifest_path = library_path.parent / "appmanifest_{}.acf".format(app_id)
if manifest_path.exists():
try:
with open(manifest_path, 'r') as f:
content = f.read()
install_dir_match = re.search(r'"installdir"\s+"([^"]+)"', content)
if install_dir_match:
game_path = library_path / install_dir_match.group(1)
if game_path.exists():
return str(game_path)
except Exception:
pass
# Fallback: check common folder names
for name in common_names:
game_path = library_path / name
if game_path.exists():
return str(game_path)
return None
def _create_game_user_directories(self, modlist_compatdata_path: str, special_game_type: str):
"""
Pre-create game-specific user directories to prevent first-launch issues.
Creates both My Documents/My Games and AppData/Local directories for the game.
This prevents issues where games fail to create these on first launch under Proton.
"""
# Map game types to their directory names
game_dir_names = {
"skyrim": "Skyrim Special Edition",
"fnv": "FalloutNV",
"fo4": "Fallout4",
"oblivion": "Oblivion",
"oblivion_remastered": "Oblivion Remastered",
"enderal": "Enderal Special Edition",
"starfield": "Starfield"
}
# Get the directory name for this game type
game_dir_name = game_dir_names.get(special_game_type)
if not game_dir_name:
logger.debug(f"No user directory mapping for game type: {special_game_type}")
return
base_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser")
directories_to_create = [
os.path.join(base_path, "Documents", "My Games", game_dir_name),
os.path.join(base_path, "AppData", "Local", game_dir_name)
]
created_count = 0
for directory in directories_to_create:
try:
os.makedirs(directory, exist_ok=True)
logger.info(f"Created user directory: {directory}")
created_count += 1
except Exception as e:
logger.warning(f"Failed to create directory {directory}: {e}")
if created_count > 0:
logger.info(f"Created {created_count} user directories for {game_dir_name}")
def _get_lorerim_preferred_proton(self):
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
try:
from jackify.backend.handlers.wine_utils import WineUtils
# Get all available Proton versions
available_versions = WineUtils.scan_all_proton_versions()
if not available_versions:
logger.warning("No Proton versions found for Lorerim override")
return None
# Priority order for Lorerim:
# 1. GEProton9-27 (specific version)
# 2. Other GEProton-9 versions (latest first)
# 3. Valve Proton 9 (any version)
preferred_candidates = []
for version in available_versions:
version_name = version['name']
# Priority 1: GEProton9-27 specifically
if version_name == 'GE-Proton9-27':
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
return version_name
# Priority 2: Other GE-Proton 9 versions
elif version_name.startswith('GE-Proton9-'):
preferred_candidates.append(('ge_proton_9', version_name, version))
# Priority 3: Valve Proton 9
elif 'Proton 9' in version_name:
preferred_candidates.append(('valve_proton_9', version_name, version))
# Return best candidate if any found
if preferred_candidates:
# Sort by priority (GE-Proton first, then by name for latest)
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
best_candidate = preferred_candidates[0]
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
return best_candidate[1]
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
return None
except Exception as e:
logger.error(f"Error detecting Lorerim Proton preference: {e}")
return None
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
"""Store Proton override information for end-of-install notification"""
try:
# Store override info for later display
if not hasattr(self, '_proton_overrides'):
self._proton_overrides = []
self._proton_overrides.append({
'modlist': modlist_name,
'proton_version': proton_version,
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
})
logger.debug(f"Stored Proton override notification: {modlist_name}{proton_version}")
except Exception as e:
logger.error(f"Failed to store Proton override notification: {e}")
def _show_proton_override_notification(self, progress_callback=None):
"""Display any Proton override notifications to the user"""
try:
if hasattr(self, '_proton_overrides') and self._proton_overrides:
for override in self._proton_overrides:
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
if progress_callback:
progress_callback("")
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
logger.info(notification_msg)
# Clear notifications after display
self._proton_overrides = []
except Exception as e:
logger.error(f"Failed to show Proton override notification: {e}")

View File

@@ -0,0 +1,673 @@
"""Proton/compatibility tool methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional
import logging
import os
import vdf
logger = logging.getLogger(__name__)
def debug_print(message):
"""Log debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
logger.debug(message)
class ProtonOperationsMixin:
"""Mixin providing Proton and compatibility tool methods for AutomatedPrefixService."""
def _get_user_proton_version(self, modlist_name: str = None):
"""Get user's preferred Proton version from config, with fallback to auto-detection
Args:
modlist_name: Optional modlist name for special handling (e.g., Lorerim)
"""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
# Check for Lorerim-specific Proton override first
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
if modlist_normalized == 'lorerim':
lorerim_proton = self._get_lorerim_preferred_proton()
if lorerim_proton:
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
self._store_proton_override_notification("Lorerim", lorerim_proton)
return lorerim_proton
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
if modlist_normalized == 'lostlegacy':
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
if lostlegacy_proton:
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
return lostlegacy_proton
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
if not user_proton_path or user_proton_path == 'auto':
logger.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence")
best = WineUtils.select_best_proton()
if best:
compat_name = best.get('steam_compat_name') or WineUtils.resolve_steam_compat_name(best['path'])
if compat_name:
logger.info(f"Auto-detected Proton: {compat_name}")
return compat_name
return "proton_experimental"
else:
# Resolve the actual Steam internal name from the Proton installation
resolved = WineUtils.resolve_steam_compat_name(user_proton_path)
if resolved:
logger.info(f"Using user-selected Proton: {resolved}")
return resolved
# Fallback for Proton installations without compatibilitytool.vdf
logger.warning(f"Could not resolve compat name for '{user_proton_path}', using basename")
proton_version = os.path.basename(user_proton_path)
if proton_version.startswith('GE-Proton'):
return proton_version
steam_proton_name = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
if not steam_proton_name.startswith('proton'):
steam_proton_name = f"proton_{steam_proton_name}"
logger.info(f"Using fallback Proton name: {steam_proton_name}")
return steam_proton_name
except Exception as e:
logger.error(f"Failed to get user Proton preference, using default: {e}")
return "proton_experimental"
def find_proton_experimental(self) -> Optional[Path]:
"""
Find Proton Experimental installation.
Returns:
Path to Proton Experimental, or None if not found
"""
proton_paths = [
Path.home() / ".local/share/Steam/steamapps/common/Proton - Experimental",
Path.home() / ".steam/steam/steamapps/common/Proton - Experimental",
Path.home() / ".local/share/Steam/steamapps/common/Proton Experimental",
Path.home() / ".steam/steam/steamapps/common/Proton Experimental",
]
for path in proton_paths:
if path.exists():
logger.info(f"Found Proton Experimental at: {path}")
return path
logger.error("Proton Experimental not found")
return None
def check_shortcut_proton_version(self, shortcut_name: str):
"""
Check if the shortcut has the Proton version set correctly.
Args:
shortcut_name: Name of the shortcut to check
"""
# STL sets the compatibility tool in config.vdf, not shortcuts.vdf
# We know this works from manual testing, so just log that we're skipping this check
logger.info(f"Skipping Proton version check for '{shortcut_name}' - STL handles this correctly")
debug_print(f"[DEBUG] Skipping Proton version check for '{shortcut_name}' - STL handles this correctly")
def set_proton_version_for_shortcut(self, appid: int, proton_version: str) -> bool:
"""
Set the Proton version for a shortcut in config.vdf.
Args:
appid: The AppID of the shortcut (negative for non-Steam shortcuts)
proton_version: The Proton version to set (e.g., 'proton_experimental')
Returns:
True if successful, False otherwise
"""
try:
# Get the config.vdf path
config_path = self._get_config_path()
if not config_path:
logger.error("No config.vdf path found")
return False
# Read current config (config.vdf is text format)
with open(config_path, 'r') as f:
config_data = vdf.load(f)
# Navigate to the correct location in the VDF structure
if 'Software' not in config_data:
config_data['Software'] = {}
if 'Valve' not in config_data['Software']:
config_data['Software']['Valve'] = {}
if 'Steam' not in config_data['Software']['Valve']:
config_data['Software']['Valve']['Steam'] = {}
# Get or create CompatToolMapping
if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']:
config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {}
# Set the Proton version for this AppID using Steam's expected format
# Steam requires a dict with 'name', 'config', and 'priority' keys
config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(appid)] = {
'name': proton_version,
'config': '',
'priority': '250'
}
# Write back to file (text format)
with open(config_path, 'w') as f:
vdf.dump(config_data, f)
# Ensure file is fully written to disk before Steam restart
import os
os.fsync(f.fileno()) if hasattr(f, 'fileno') else None
logger.info(f"Set Proton version {proton_version} for AppID {appid}")
debug_print(f"[DEBUG] Set Proton version {proton_version} for AppID {appid} in config.vdf")
# Small delay to ensure filesystem write completes
import time
time.sleep(0.5)
# Verify it was set correctly
with open(config_path, 'r') as f:
verify_data = vdf.load(f)
compat_mapping = verify_data.get('Software', {}).get('Valve', {}).get('Steam', {}).get('CompatToolMapping', {}).get(str(appid))
debug_print(f"[DEBUG] Verification: AppID {appid} -> {compat_mapping}")
return True
except Exception as e:
logger.error(f"Error setting Proton version: {e}")
return False
def set_compatool_on_shortcut(self, shortcut_name: str) -> bool:
"""
Set CompatTool on a shortcut immediately after STL creation.
This is CRITICAL to ensure the batch file shortcut has Proton set
so it can create a prefix when launched.
Args:
shortcut_name: Name of the shortcut to modify
Returns:
True if successful, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the shortcut by name
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name == name:
# Check current CompatTool setting
current_compat = shortcut.get('CompatTool', 'NOT_SET')
logger.info(f"Found shortcut '{name}' with CompatTool: '{current_compat}'")
# Set CompatTool to ensure batch file can create prefix
shortcut['CompatTool'] = 'proton_experimental'
logger.info(f" Set CompatTool=proton_experimental on shortcut: {name}")
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
return True
logger.error(f"Shortcut '{shortcut_name}' not found for CompatTool setting")
return False
except Exception as e:
logger.error(f"Error setting CompatTool on shortcut: {e}")
return False
def _set_proton_on_shortcut(self, shortcut_name: str) -> bool:
"""
Set Proton Experimental on a shortcut by name.
Args:
shortcut_name: Name of the shortcut to modify
Returns:
True if successful, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the shortcut by name
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name == name:
# Set CompatTool
shortcut['CompatTool'] = 'proton_experimental'
logger.info(f"Set CompatTool=proton_experimental on shortcut: {name}")
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
return True
logger.error(f"Shortcut '{shortcut_name}' not found for Proton setting")
return False
except Exception as e:
logger.error(f"Error setting Proton on shortcut: {e}")
return False
def set_compatibility_tool_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool:
"""
Set compatibility tool using STL's exact method.
This adds an entry to config.vdf's CompatToolMapping section using the unsigned AppID as the key,
exactly like STL does.
Args:
unsigned_appid: The unsigned AppID (Grid ID) to use as the key
compat_tool: The compatibility tool name (e.g., 'proton_experimental')
Returns:
True if successful, False otherwise
"""
try:
config_path = self._get_config_path()
if not config_path:
logger.error("No config.vdf path found")
return False
# Read current config (config.vdf is text format)
with open(config_path, 'r') as f:
config_data = vdf.load(f)
# Navigate to the correct location in the VDF structure
if 'Software' not in config_data:
config_data['Software'] = {}
if 'Valve' not in config_data['Software']:
config_data['Software']['Valve'] = {}
if 'Steam' not in config_data['Software']['Valve']:
config_data['Software']['Valve']['Steam'] = {}
# Get or create CompatToolMapping
if 'CompatToolMapping' not in config_data['Software']['Valve']['Steam']:
config_data['Software']['Valve']['Steam']['CompatToolMapping'] = {}
# Create the compatibility tool entry exactly like STL does
compat_entry = {
'name': compat_tool,
'config': '',
'priority': '250'
}
# Set the compatibility tool for this AppID (using unsigned AppID as key)
config_data['Software']['Valve']['Steam']['CompatToolMapping'][str(unsigned_appid)] = compat_entry
logger.info(f"Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}")
debug_print(f"[DEBUG] Added compatibility tool entry: {str(unsigned_appid)} -> {compat_tool}")
# Write back to file (text format)
with open(config_path, 'w') as f:
vdf.dump(config_data, f)
logger.info(f"Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}")
debug_print(f"[DEBUG] Set compatibility tool STL-style: AppID {unsigned_appid} -> {compat_tool}")
return True
except Exception as e:
logger.error(f"Error setting compatibility tool STL-style: {e}")
return False
def set_compatibility_tool_complete_stl_style(self, unsigned_appid: int, compat_tool: str) -> bool:
"""
Set compatibility tool using STL's complete method with direct text manipulation.
This replicates STL's approach by using direct text manipulation instead of VDF libraries
to preserve existing entries in both config.vdf and localconfig.vdf.
Args:
unsigned_appid: The unsigned AppID (Grid ID) to use as the key
compat_tool: The compatibility tool name (e.g., 'proton_experimental')
Returns:
True if successful, False otherwise
"""
try:
# Step 1: Update config.vdf using direct text manipulation (like STL does)
config_path = self._get_config_path()
if not config_path:
logger.error("No config.vdf path found")
return False
# Read the entire file as text
with open(config_path, 'r') as f:
lines = f.readlines()
# Find the CompatToolMapping section
compat_section_start = None
compat_section_end = None
for i, line in enumerate(lines):
if '"CompatToolMapping"' in line.strip():
compat_section_start = i
# Find the end of the CompatToolMapping section
brace_count = 0
for j in range(i + 1, len(lines)):
if '{' in lines[j]:
brace_count += 1
if '}' in lines[j]:
brace_count -= 1
if brace_count == 0:
compat_section_end = j
break
break
if compat_section_start is None:
logger.error("CompatToolMapping section not found in config.vdf")
return False
# Check if our AppID entry already exists
appid_entry_start = None
appid_entry_end = None
for i in range(compat_section_start, compat_section_end + 1):
if f'"{unsigned_appid}"' in lines[i]:
appid_entry_start = i
# Find the end of this AppID entry
brace_count = 0
for j in range(i + 1, compat_section_end + 1):
if '{' in lines[j]:
brace_count += 1
if '}' in lines[j]:
brace_count -= 1
if brace_count == 0:
appid_entry_end = j
break
break
# Create the new entry in Steam's exact format
new_entry_lines = [
f'\t\t\t\t\t\t\t\t\t"{unsigned_appid}"\n',
f'\t\t\t\t\t\t\t\t\t{{\n',
f'\t\t\t\t\t\t\t\t\t\t"name"\t\t\t\t"{compat_tool}"\n',
f'\t\t\t\t\t\t\t\t\t\t"config"\t\t\t\t\t""\n',
f'\t\t\t\t\t\t\t\t\t\t"priority"\t\t\t\t\t"250"\n',
f'\t\t\t\t\t\t\t\t\t}}\n'
]
if appid_entry_start is None:
# AppID entry doesn't exist, add it before the closing brace of CompatToolMapping
lines.insert(compat_section_end, ''.join(new_entry_lines))
else:
# AppID entry exists, replace it
del lines[appid_entry_start:appid_entry_end + 1]
lines.insert(appid_entry_start, ''.join(new_entry_lines))
# Write the updated file back
with open(config_path, 'w') as f:
f.writelines(lines)
logger.info(f"Updated config.vdf: AppID {unsigned_appid} -> {compat_tool}")
# Step 2: Update localconfig.vdf using direct text manipulation (like STL)
localconfig_path = self._get_localconfig_path()
if not localconfig_path:
logger.error("No localconfig.vdf path found")
return False
# Calculate signed AppID (like STL does)
signed_appid = (unsigned_appid | 0x80000000) & 0xFFFFFFFF
# Convert to signed 32-bit integer
import ctypes
signed_appid_int = ctypes.c_int32(signed_appid).value
# Read the entire file as text
with open(localconfig_path, 'r') as f:
lines = f.readlines()
# Check if Apps section exists
apps_section_start = None
apps_section_end = None
for i, line in enumerate(lines):
if line.strip() == '"Apps"':
apps_section_start = i
# Find the end of the Apps section
brace_count = 0
for j in range(i + 1, len(lines)):
if '{' in lines[j]:
brace_count += 1
if '}' in lines[j]:
brace_count -= 1
if brace_count == 0:
apps_section_end = j
break
break
# If Apps section doesn't exist, create it at the end of the file
if apps_section_start is None:
logger.info("Apps section not found, creating it at the end of the file")
# Find the last closing brace (before the final closing brace)
last_brace_pos = None
for i in range(len(lines) - 1, -1, -1):
if lines[i].strip() == '}':
last_brace_pos = i
break
if last_brace_pos is None:
logger.error("Could not find closing brace in localconfig.vdf")
return False
# Insert Apps section before the last closing brace
apps_section = [
' "Apps"\n',
' {\n',
f' "{signed_appid_int}"\n',
' {\n',
' "OverlayAppEnable" "1"\n',
' "DisableLaunchInVR" "1"\n',
' }\n',
' }\n'
]
lines.insert(last_brace_pos, ''.join(apps_section))
else:
# Apps section exists, check if our AppID entry exists
appid_entry_start = None
appid_entry_end = None
for i in range(apps_section_start, apps_section_end + 1):
if f'"{signed_appid_int}"' in lines[i]:
appid_entry_start = i
# Find the end of this AppID entry
brace_count = 0
for j in range(i + 1, apps_section_end + 1):
if '{' in lines[j]:
brace_count += 1
if '}' in lines[j]:
brace_count -= 1
if brace_count == 0:
appid_entry_end = j
break
break
if appid_entry_start is None:
# AppID entry doesn't exist, add it to the Apps section
logger.info(f"AppID {signed_appid_int} entry not found, adding it to Apps section")
# Insert before the closing brace of the Apps section
appid_entry = [
f' "{signed_appid_int}"\n',
' {\n',
' "OverlayAppEnable" "1"\n',
' "DisableLaunchInVR" "1"\n',
' }\n'
]
lines.insert(apps_section_end, ''.join(appid_entry))
else:
# AppID entry exists, update the values
logger.info(f"AppID {signed_appid_int} entry exists, updating values")
# Check if the values already exist and update them
overlay_found = False
vr_found = False
for i in range(appid_entry_start, appid_entry_end + 1):
if '"OverlayAppEnable"' in lines[i]:
lines[i] = ' "OverlayAppEnable" "1"\n'
overlay_found = True
elif '"DisableLaunchInVR"' in lines[i]:
lines[i] = ' "DisableLaunchInVR" "1"\n'
vr_found = True
# Add missing values
if not overlay_found or not vr_found:
# Find the position to insert (before the closing brace of the AppID entry)
insert_pos = appid_entry_end
for i in range(appid_entry_start, appid_entry_end + 1):
if lines[i].strip() == '}':
insert_pos = i
break
new_values = []
if not overlay_found:
new_values.append(' "OverlayAppEnable" "1"\n')
if not vr_found:
new_values.append(' "DisableLaunchInVR" "1"\n')
for value in new_values:
lines.insert(insert_pos, value)
# Write the updated file back
with open(localconfig_path, 'w') as f:
f.writelines(lines)
logger.info(f"Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1")
debug_print(f"[DEBUG] Updated localconfig.vdf: Signed AppID {signed_appid_int} -> OverlayAppEnable=1, DisableLaunchInVR=1")
return True
except Exception as e:
logger.error(f"Error setting compatibility tool complete STL-style: {e}")
return False
def verify_compatibility_tool_persists(self, appid: int) -> bool:
"""
Verify that the compatibility tool setting persists with correct Proton version.
Args:
appid: The AppID to check
Returns:
True if compatibility tool is correctly set, False otherwise
"""
try:
config_path = Path.home() / ".steam/steam/config/config.vdf"
if not config_path.exists():
logger.warning("Steam config.vdf not found")
return False
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
# Check if AppID exists and has a Proton version set
if f'"{appid}"' in content:
# Get the expected Proton version
expected_proton = self._get_user_proton_version()
# Look for the Proton version in the compatibility tool mapping
if expected_proton in content:
logger.info(f" Compatibility tool persists: {expected_proton}")
return True
else:
logger.warning(f"AppID {appid} found but Proton version '{expected_proton}' not set")
return False
else:
logger.warning("Compatibility tool not found")
return False
except Exception as e:
logger.error(f"Error verifying compatibility tool: {e}")
return False
def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]:
"""Locate a Proton wrapper script to use, respecting user's configuration."""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_game_proton_path()
# If user selected a specific Proton, try that first
if user_proton_path != 'auto':
# Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam
resolved_proton_path = os.path.realpath(user_proton_path)
# Check for wine binary in different Proton structures
valve_proton_wine = Path(resolved_proton_path) / "dist" / "bin" / "wine"
ge_proton_wine = Path(resolved_proton_path) / "files" / "bin" / "wine"
if valve_proton_wine.exists() or ge_proton_wine.exists():
# Found user's Proton, now find the proton wrapper script
proton_wrapper = Path(resolved_proton_path) / "proton"
if proton_wrapper.exists():
logger.info(f"Using user-selected Proton wrapper: {proton_wrapper}")
return proton_wrapper
else:
logger.warning(f"User-selected Proton missing wrapper script: {proton_wrapper}")
else:
logger.warning(f"User-selected Proton path invalid: {user_proton_path}")
# Fall back to auto-detection
logger.info("Falling back to automatic Proton detection")
candidates = []
preferred = [
"Proton - Experimental",
"Proton 9.0",
"Proton 8.0",
"Proton Hotfix",
]
for name in preferred:
p = proton_common_dir / name / "proton"
if p.exists():
candidates.append(p)
# As a fallback, scan all Proton* dirs
if not candidates and proton_common_dir.exists():
for p in proton_common_dir.glob("Proton*/proton"):
candidates.append(p)
if not candidates:
logger.error("No Proton wrapper found under steamapps/common")
return None
logger.info(f"Using auto-detected Proton wrapper: {candidates[0]}")
return candidates[0]
except Exception as e:
logger.error(f"Error finding Proton binary: {e}")
return None

View File

@@ -0,0 +1,276 @@
"""Registry operations mixin for AutomatedPrefixService."""
import os
import subprocess
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
class RegistryOperationsMixin:
"""Mixin providing Wine/Proton registry operations."""
def _update_registry_path(self, system_reg_path: str, section_name: str, path_key: str, new_path: str) -> bool:
"""Update a specific path value in Wine registry, preserving other entries"""
if not os.path.exists(system_reg_path):
return False
try:
# Read existing content
with open(system_reg_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
in_target_section = False
path_updated = False
# Determine Wine drive letter based on SD card detection
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.path_handler import PathHandler
linux_path = Path(new_path)
if FileSystemHandler.is_sd_card(linux_path):
# SD card paths use D: drive
# Strip SD card prefix using the same method as other handlers
relative_sd_path_str = PathHandler._strip_sdcard_path_prefix(linux_path)
wine_path = relative_sd_path_str.replace('/', '\\\\')
wine_drive = "D:"
logger.debug(f"SD card path detected: {new_path} -> D:\\{wine_path}")
else:
# Regular paths use Z: drive with full path
wine_path = new_path.strip('/').replace('/', '\\\\')
wine_drive = "Z:"
logger.debug(f"Regular path: {new_path} -> Z:\\{wine_path}")
# Update existing path if found
for i, line in enumerate(lines):
stripped_line = line.strip()
# Case-insensitive comparison for section name (Wine registry is case-insensitive)
if stripped_line.split(']')[0].lower() + ']' == section_name.lower() if ']' in stripped_line else stripped_line.lower() == section_name.lower():
in_target_section = True
elif stripped_line.startswith('[') and in_target_section:
in_target_section = False
elif in_target_section and f'"{path_key}"' in line:
lines[i] = f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n' # Add trailing backslashes
path_updated = True
break
# Add new section if path wasn't updated
if not path_updated:
lines.append(f'\n{section_name}\n')
lines.append(f'"{path_key}"="{wine_drive}\\\\{wine_path}\\\\"\n') # Add trailing backslashes
# Write updated content
with open(system_reg_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
return True
except Exception as e:
logger.error(f"Failed to update registry path: {e}")
return False
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists"""
try:
prefix_path = os.path.join(modlist_compatdata_path, "pfx")
if not os.path.exists(prefix_path):
logger.warning(f"Prefix path not found: {prefix_path}")
return False
logger.info("Applying universal dotnet4.x compatibility registry fixes...")
# Find the appropriate Wine binary to use for registry operations
wine_binary = self._find_wine_binary_for_registry(modlist_compatdata_path)
if not wine_binary:
logger.error("Could not find Wine binary for registry operations")
return False
# Set environment for Wine registry operations
env = os.environ.copy()
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Registry fix 1: Set *mscoree=native DLL override (asterisk for full override)
# Use native .NET runtime instead of Wine's
logger.debug("Setting *mscoree=native DLL override...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', '*mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace')
if result1.returncode == 0:
logger.info("Successfully applied *mscoree=native DLL override")
else:
logger.warning(f"Failed to set *mscoree DLL override: {result1.stderr}")
# Registry fix 2: Set OnlyUseLatestCLR=1
# Use latest CLR to avoid .NET version conflicts
logger.debug("Setting OnlyUseLatestCLR=1 registry entry...")
cmd2 = [
wine_binary, 'reg', 'add',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace')
if result2.returncode == 0:
logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}")
# Both fixes applied - this should eliminate dotnet4.x installation requirements
if result1.returncode == 0 and result2.returncode == 0:
logger.info("Universal dotnet4.x compatibility fixes applied successfully")
return True
else:
logger.warning("Some dotnet4.x registry fixes failed, but continuing...")
return False
except Exception as e:
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False
def _find_wine_binary_for_registry(self, modlist_compatdata_path: str) -> Optional[str]:
"""Find the appropriate Wine binary for registry operations"""
try:
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
# Method 1: Use the user's configured Proton version from settings
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
if user_proton_path and user_proton_path != 'auto':
# User has selected a specific Proton version
proton_path = Path(user_proton_path).expanduser()
# Check for wine binary in both GE-Proton and Valve Proton structures
wine_candidates = [
proton_path / "files" / "bin" / "wine", # GE-Proton structure
proton_path / "dist" / "bin" / "wine" # Valve Proton structure
]
for wine_path in wine_candidates:
if wine_path.exists() and wine_path.is_file():
logger.info(f"Using Wine binary from user's configured Proton: {wine_path}")
return str(wine_path)
# Wine binary not found at expected paths - search recursively in Proton directory
logger.debug(f"Wine binary not found at expected paths in {proton_path}, searching recursively...")
wine_binary = self._search_wine_in_proton_directory(proton_path)
if wine_binary:
logger.info(f"Found Wine binary via recursive search in Proton directory: {wine_binary}")
return wine_binary
logger.warning(f"User's configured Proton path has no wine binary: {user_proton_path}")
# Method 2: Fallback to auto-detection using WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if wine_binary:
logger.info(f"Using Wine binary from detected Proton: {wine_binary}")
return wine_binary
# NEVER fall back to system wine - it will break Proton prefixes with architecture mismatches
logger.error("No suitable Proton Wine binary found for registry operations")
return None
except Exception as e:
logger.error(f"Error finding Wine binary: {e}")
return None
def _search_wine_in_proton_directory(self, proton_path: Path) -> Optional[str]:
"""
Recursively search for wine binary within a Proton directory.
This handles cases where the directory structure might differ between Proton versions.
Args:
proton_path: Path to the Proton directory to search
Returns:
Path to wine binary if found, None otherwise
"""
try:
if not proton_path.exists() or not proton_path.is_dir():
return None
# Search for 'wine' executable (not 'wine64' or 'wine-preloader')
# Limit search depth to avoid scanning entire filesystem
max_depth = 5
for root, dirs, files in os.walk(proton_path, followlinks=False):
# Calculate depth relative to proton_path
try:
depth = len(Path(root).relative_to(proton_path).parts)
except ValueError:
# Path is not relative to proton_path (shouldn't happen, but be safe)
continue
if depth > max_depth:
dirs.clear() # Don't descend further
continue
# Check if 'wine' is in this directory
if 'wine' in files:
wine_path = Path(root) / 'wine'
# Verify it's actually an executable file
if wine_path.is_file() and os.access(wine_path, os.X_OK):
logger.debug(f"Found wine binary at: {wine_path}")
return str(wine_path)
return None
except Exception as e:
logger.debug(f"Error during recursive wine search in {proton_path}: {e}")
return None
def _inject_game_registry_entries(self, modlist_compatdata_path: str, special_game_type: str):
"""Detect and inject FNV/Enderal game paths and apply universal dotnet4.x compatibility fixes"""
system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg")
if not os.path.exists(system_reg_path):
logger.warning("system.reg not found, skipping game path injection")
return
logger.info("Detecting game registry entries...")
# Universal dotnet4.x registry fixes applied in modlist_handler.py after .reg downloads
# Game configurations
games_config = {
"22380": { # Fallout New Vegas AppID
"name": "Fallout New Vegas",
"common_names": ["Fallout New Vegas", "FalloutNV"],
"registry_section": "[Software\\\\Wow6432Node\\\\bethesda softworks\\\\falloutnv]",
"path_key": "Installed Path"
},
"976620": { # Enderal Special Edition AppID
"name": "Enderal",
"common_names": ["Enderal: Forgotten Stories (Special Edition)", "Enderal Special Edition", "Enderal"],
"registry_section": "[Software\\\\Wow6432Node\\\\SureAI\\\\Enderal SE]",
"path_key": "installed path"
}
}
# Detect and inject each game
for app_id, config in games_config.items():
game_path = self._find_steam_game(app_id, config["common_names"])
if game_path:
logger.info(f"Detected {config['name']} at: {game_path}")
success = self._update_registry_path(
system_reg_path,
config["registry_section"],
config["path_key"],
game_path
)
if success:
logger.info(f"Updated registry entry for {config['name']}")
else:
logger.warning(f"Failed to update registry entry for {config['name']}")
else:
logger.debug(f"{config['name']} not found in Steam libraries")
logger.info("Game registry injection completed")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,534 @@
"""Shortcut operation methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional, Tuple, List, Dict
import logging
import os
import time
import vdf
import subprocess
from .automated_prefix_shortcuts_cleanup import AutomatedPrefixShortcutsCleanupMixin
logger = logging.getLogger(__name__)
def debug_print(message):
"""Log debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
logger.debug(message)
class ShortcutOperationsMixin(AutomatedPrefixShortcutsCleanupMixin):
"""Mixin providing shortcut operation methods for AutomatedPrefixService."""
def create_shortcut_with_native_service(self, shortcut_name: str, exe_path: str,
modlist_install_dir: str, custom_launch_options: str = None,
download_dir=None) -> Tuple[bool, Optional[int]]:
"""
Create a Steam shortcut using the native Steam service (no STL).
Args:
shortcut_name: Name for the shortcut
exe_path: Path to the executable
modlist_install_dir: Directory where the modlist is installed
custom_launch_options: Pre-generated launch options (overrides default generation)
download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS
Returns:
(success, unsigned_app_id)
"""
logger.info(f"Creating shortcut with native service: {shortcut_name}")
try:
from ..services.native_steam_service import NativeSteamService
# Initialize native Steam service
steam_service = NativeSteamService()
# Use custom launch options if provided, otherwise generate default
if custom_launch_options:
launch_options = custom_launch_options
logger.info(f"Using pre-generated launch options: {launch_options}")
else:
# Generate STEAM_COMPAT_MOUNTS including install and download mountpoints
launch_options = "%command%"
try:
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
mount_paths = path_handler.get_steam_compat_mount_paths(
install_dir=modlist_install_dir, download_dir=download_dir
)
if mount_paths:
launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%'
logger.info(f"Generated launch options with mounts: {launch_options}")
except Exception as e:
logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}")
launch_options = "%command%"
# Get user's preferred Proton version (with Lorerim-specific override)
proton_version = self._get_user_proton_version(shortcut_name)
# Create shortcut with Proton using native service
success, app_id = steam_service.create_shortcut_with_proton(
app_name=shortcut_name,
exe_path=exe_path,
start_dir=modlist_install_dir,
launch_options=launch_options,
tags=["Jackify"],
proton_version=proton_version
)
if success and app_id:
logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}")
return True, app_id
else:
logger.error("Native Steam service failed to create shortcut")
return False, None
except Exception as e:
logger.error(f"Error creating shortcut with native service: {e}")
return False, None
def verify_shortcut_created(self, shortcut_name: str) -> Optional[int]:
"""
Verify the shortcut was created and get its AppID.
Args:
shortcut_name: Name of the shortcut to look for
Returns:
AppID if found, None otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return None
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Look for our shortcut by name
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name in name:
appid = shortcut.get('appid')
exe_path = shortcut.get('Exe', '').strip('"')
logger.info(f"Found shortcut: {name}")
logger.info(f" AppID: {appid}")
logger.info(f" Exe: {exe_path}")
logger.info(f" CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}")
return appid
logger.error(f"Shortcut '{shortcut_name}' not found")
return None
except Exception as e:
logger.error(f"Error reading shortcuts: {e}")
return None
def create_shortcut_directly(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool:
"""
Create a Steam shortcut directly by modifying shortcuts.vdf.
This is a fallback when STL fails.
Args:
shortcut_name: Name for the shortcut
exe_path: Path to the executable
modlist_install_dir: Directory where the modlist is installed
Returns:
True if successful, False otherwise
"""
try:
debug_print(f"[DEBUG] create_shortcut_directly called for '{shortcut_name}' - this is the fallback method")
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
debug_print("[DEBUG] No shortcuts path found")
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the next available index
next_index = str(len(shortcuts))
# Calculate AppID for the new shortcut (negative for non-Steam shortcuts)
import hashlib
app_name_bytes = shortcut_name.encode('utf-8')
exe_bytes = exe_path.encode('utf-8')
combined = app_name_bytes + exe_bytes
hash_value = int(hashlib.md5(combined).hexdigest()[:8], 16)
appid = -(hash_value & 0x7FFFFFFF) # Make it negative and within 32-bit range
# Create new shortcut entry
new_shortcut = {
'AppName': shortcut_name,
'Exe': f'"{exe_path}"',
'StartDir': f'"{modlist_install_dir}"',
'appid': appid,
'icon': '',
'ShortcutPath': '',
'LaunchOptions': '',
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'openvr': 0,
'Devkit': 0,
'DevkitGameID': '',
'LastPlayTime': 0,
'FlatpakAppID': '',
'tags': {},
'CompatTool': 'proton_experimental', # Set Proton Experimental
'IsInstalled': 1 # Make it appear in "Locally Installed" filter
}
# Add the new shortcut
shortcuts[next_index] = new_shortcut
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Created shortcut directly: {shortcut_name}")
return True
except Exception as e:
logger.error(f"Error creating shortcut directly: {e}")
return False
def create_shortcut_directly_with_proton(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> bool:
"""
Create a Steam shortcut with temporary batch file for invisible prefix creation.
This uses the CRC32-based AppID calculation for predictable results.
Args:
shortcut_name: Name for the shortcut
exe_path: Path to the final ModOrganizer.exe executable
modlist_install_dir: Directory where the modlist is installed
Returns:
True if successful, False otherwise
"""
try:
debug_print(f"[DEBUG] create_shortcut_directly_with_proton called for '{shortcut_name}' - using temporary batch file approach")
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
debug_print("[DEBUG] No shortcuts path found")
return False
# Calculate predictable AppID using CRC32 (based on FINAL exe_path)
from zlib import crc32
combined_string = exe_path + shortcut_name
crc = crc32(combined_string.encode('utf-8'))
appid = -(crc & 0x7FFFFFFF) # Make it negative and within 32-bit range (like other shortcuts)
debug_print(f"[DEBUG] Calculated AppID: {appid} from '{combined_string}'")
# Create temporary batch file for invisible prefix creation
batch_content = """@echo off
echo Creating Proton prefix...
timeout /t 3 /nobreak >nul
echo Prefix creation complete.
"""
from jackify.shared.paths import get_jackify_data_dir
batch_path = get_jackify_data_dir() / "temp_prefix_creation.bat"
batch_path.parent.mkdir(parents=True, exist_ok=True)
with open(batch_path, 'w') as f:
f.write(batch_content)
debug_print(f"[DEBUG] Created temporary batch file: {batch_path}")
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Check if shortcut already exists (idempotent)
found = False
new_shortcuts_list = []
shortcuts_list = list(shortcuts.values())
for shortcut in shortcuts_list:
if shortcut.get('AppName') == shortcut_name:
debug_print(f"[DEBUG] Updating existing shortcut for '{shortcut_name}'")
# Update existing shortcut with temporary batch file
shortcut.update({
'Exe': f'"{batch_path}"', # Point to temporary batch file
'StartDir': f'"{batch_path.parent}"', # Batch file directory
'appid': appid,
'LaunchOptions': '', # Empty like working shortcuts
'tags': {}, # Empty tags like working shortcuts
'CompatTool': 'proton_experimental' # Set Proton version directly in shortcut
})
new_shortcuts_list.append(shortcut)
found = True
else:
new_shortcuts_list.append(shortcut)
if not found:
debug_print(f"[DEBUG] Creating new shortcut for '{shortcut_name}'")
# Create new shortcut entry pointing to temporary batch file
new_shortcut = {
'AppName': shortcut_name,
'Exe': f'"{batch_path}"', # Point to temporary batch file
'StartDir': f'"{batch_path.parent}"', # Batch file directory
'appid': appid,
'icon': '',
'ShortcutPath': '',
'LaunchOptions': '', # Empty like working shortcuts
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'OpenVR': 0,
'Devkit': 0,
'DevkitGameID': '',
'LastPlayTime': 0,
'FlatpakAppID': '',
'tags': {}, # Empty tags like working shortcuts
'sortas': '',
'CompatTool': 'proton_experimental' # Set Proton version directly in shortcut
}
new_shortcuts_list.append(new_shortcut)
# Rebuild shortcuts dict with new order
shortcuts_data['shortcuts'] = {str(i): s for i, s in enumerate(new_shortcuts_list)}
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Created/updated shortcut with temporary batch file: {shortcut_name} with AppID {appid}")
debug_print(f"[DEBUG] Shortcut created/updated with temporary batch file, AppID {appid}")
# Set Proton version in config.vdf BEFORE creating shortcut
if self.set_proton_version_for_shortcut(appid, 'proton_experimental'):
logger.info(f"Set Proton Experimental for shortcut {shortcut_name}")
return True
else:
logger.warning(f"Failed to set Proton version for shortcut {shortcut_name}")
return False
except Exception as e:
logger.error(f"Error creating shortcut with temporary batch file: {e}")
return False
def replace_shortcut_with_final_exe(self, shortcut_name: str, final_exe_path: str, modlist_install_dir: str) -> bool:
"""
Replace the temporary batch file shortcut with the final ModOrganizer.exe.
This should be called after the prefix has been created.
Args:
shortcut_name: Name of the shortcut to update
final_exe_path: Path to the final ModOrganizer.exe executable
modlist_install_dir: Directory where the modlist is installed
Returns:
True if successful, False otherwise
"""
try:
debug_print(f"[DEBUG] replace_shortcut_with_final_exe called for '{shortcut_name}'")
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
debug_print("[DEBUG] No shortcuts path found")
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find and update the shortcut
found = False
new_shortcuts_list = []
shortcuts_list = list(shortcuts.values())
for shortcut in shortcuts_list:
if shortcut.get('AppName') == shortcut_name:
debug_print(f"[DEBUG] Replacing temporary batch file with final exe for '{shortcut_name}'")
# Update shortcut to point to final ModOrganizer.exe
shortcut.update({
'Exe': f'"{final_exe_path}"', # Point to final ModOrganizer.exe
'StartDir': modlist_install_dir, # ModOrganizer directory
'LaunchOptions': '', # Empty like working shortcuts
'tags': {}, # Empty tags like working shortcuts
# Keep existing appid and CompatibilityTool
})
new_shortcuts_list.append(shortcut)
found = True
else:
new_shortcuts_list.append(shortcut)
if not found:
logger.error(f"Shortcut '{shortcut_name}' not found for replacement")
return False
# Rebuild shortcuts dict with new order
shortcuts_data['shortcuts'] = {str(i): s for i, s in enumerate(new_shortcuts_list)}
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Replaced shortcut with final exe: {shortcut_name}")
debug_print(f"[DEBUG] Shortcut replaced with final ModOrganizer.exe")
return True
except Exception as e:
logger.error(f"Error replacing shortcut with final exe: {e}")
return False
def modify_shortcut_to_final_exe(self, shortcut_name: str, final_exe_path: str,
final_start_dir: str) -> bool:
"""
Update the existing batch file shortcut to point to the final executable.
This preserves the AppID and prefix association while changing the target.
Args:
shortcut_name: Name of the shortcut to modify
final_exe_path: Path to the final executable (e.g., ModOrganizer.exe)
final_start_dir: Start directory for the executable
Returns:
True if successful, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the batch file shortcut that created the prefix
logger.info(f"Looking for batch file shortcut '{shortcut_name}' among {len(shortcuts)} shortcuts...")
target_shortcut = None
target_index = None
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
exe = shortcut.get('Exe', '')
# Find the specific shortcut that points to our batch file (handle quoted paths)
if (name == shortcut_name and
exe and 'prefix_creation_' in exe and (exe.endswith('.bat') or exe.endswith('.bat"'))):
target_shortcut = shortcut
target_index = str(i)
logger.info(f"Found batch file shortcut '{shortcut_name}' at index {i}")
logger.info(f" Current Exe: {exe}")
logger.info(f" Current StartDir: {shortcut.get('StartDir', '')}")
logger.info(f" Current CompatTool: {shortcut.get('CompatTool', 'NOT_SET')}")
logger.info(f" AppID: {shortcut.get('appid', 'NOT_SET')}")
break
if target_shortcut is None:
logger.error(f"No batch file shortcut found with name '{shortcut_name}'")
# Debug: show all available shortcuts
logger.debug("Available shortcuts:")
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
exe = shortcut.get('Exe', '')
logger.debug(f" [{i}] {name} -> {exe}")
return False
# Update the existing shortcut IN-PLACE (preserves AppID and all other fields)
logger.info(f"Updating shortcut at index {target_index} IN-PLACE...")
# Only change Exe and StartDir - preserve everything else including AppID
old_exe = target_shortcut.get('Exe', '')
old_start_dir = target_shortcut.get('StartDir', '')
target_shortcut['Exe'] = f'"{final_exe_path}"'
target_shortcut['StartDir'] = f'"{final_start_dir}"'
# Ensure CompatTool is set (STL should have set this, but make sure)
if not target_shortcut.get('CompatTool', '').strip():
target_shortcut['CompatTool'] = 'proton_experimental'
logger.info("Set CompatTool to proton_experimental (was not set)")
logger.info(f" Updated shortcut '{shortcut_name}' at index {target_index}:")
logger.info(f" Exe: {old_exe}{target_shortcut['Exe']}")
logger.info(f" StartDir: {old_start_dir}{target_shortcut['StartDir']}")
logger.info(f" AppID: {target_shortcut.get('appid', 'NOT_SET')} (preserved)")
logger.info(f" CompatTool: {target_shortcut.get('CompatTool', 'NOT_SET')} (preserved)")
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(" Shortcut updated successfully - no duplicates created")
return True
except Exception as e:
logger.error(f"Error modifying shortcut: {e}")
return False
def verify_final_shortcut(self, shortcut_name: str, expected_exe_path: str) -> bool:
"""
Verify the shortcut now points to the final executable.
Args:
shortcut_name: Name of the shortcut to verify
expected_exe_path: Expected executable path
Returns:
True if shortcut is correct, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find our shortcut
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name in name:
exe_path = shortcut.get('Exe', '')
start_dir = shortcut.get('StartDir', '')
logger.info(f"Final shortcut configuration:")
logger.info(f" Name: {name}")
logger.info(f" Exe: {exe_path}")
logger.info(f" StartDir: {start_dir}")
# Verify it points to the final executable
if expected_exe_path in exe_path:
logger.info("Shortcut correctly points to final executable")
return True
else:
logger.error("Shortcut does not point to final executable")
return False
logger.error(f"Shortcut '{shortcut_name}' not found")
return False
except Exception as e:
logger.error(f"Error reading shortcuts: {e}")
return False

View File

@@ -0,0 +1,138 @@
"""Cleanup and replacement logic for shortcut operations (Mixin)."""
from pathlib import Path
from typing import Optional, Tuple
import logging
import os
import vdf
import subprocess
logger = logging.getLogger(__name__)
class AutomatedPrefixShortcutsCleanupMixin:
"""Mixin providing cleanup_old_batch_shortcuts, modify_shortcut_target, replace_existing_shortcut."""
def cleanup_old_batch_shortcuts(self, shortcut_name: str) -> bool:
"""Remove old batch file shortcuts for this modlist to prevent duplicates."""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
indices_to_remove = []
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
exe = shortcut.get('Exe', '')
if (name == shortcut_name and
'prefix_creation_' in exe and
exe.endswith('.bat')):
indices_to_remove.append(str(i))
logger.info(f"Marking old batch shortcut for removal: {name} -> {exe}")
if not indices_to_remove:
logger.debug(f"No old batch shortcuts found for '{shortcut_name}'")
return True
new_shortcuts = {}
new_index = 0
for i in range(len(shortcuts)):
if str(i) not in indices_to_remove:
new_shortcuts[str(new_index)] = shortcuts[str(i)]
new_index += 1
shortcuts_data['shortcuts'] = new_shortcuts
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Cleaned up {len(indices_to_remove)} old batch shortcuts for '{shortcut_name}'")
return True
except Exception as e:
logger.error(f"Error cleaning up old shortcuts: {e}")
return False
def modify_shortcut_target(self, shortcut_name: str, new_exe_path: str, new_start_dir: str) -> bool:
"""Modify an existing shortcut's target and start directory. Preserves launch options."""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
logger.error("No shortcuts.vdf path found")
return False
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
if 'shortcuts' not in shortcuts_data:
logger.error("No shortcuts found in shortcuts.vdf")
return False
shortcuts = shortcuts_data['shortcuts']
shortcut_found = False
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
if shortcut.get('AppName', '') == shortcut_name:
existing_launch_options = shortcut.get('LaunchOptions', '')
shortcut['Exe'] = new_exe_path
shortcut['StartDir'] = new_start_dir
shortcut['LaunchOptions'] = existing_launch_options
shortcut_found = True
logger.info(f"Modified shortcut '{shortcut_name}' to target: {new_exe_path}")
logger.info(f"Preserved launch options: {existing_launch_options}")
break
if not shortcut_found:
logger.error(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf")
return False
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Successfully modified shortcut '{shortcut_name}'")
return True
except Exception as e:
logger.error(f"Error modifying shortcut: {e}")
return False
def replace_existing_shortcut(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Tuple[bool, Optional[int]]:
"""Replace an existing shortcut with a new one using STL, then create via native service."""
try:
logger.info(f"Replacing existing shortcut: {shortcut_name}")
appdir = os.environ.get('APPDIR')
if appdir:
stl_path = Path(appdir) / "opt" / "jackify" / "steamtinkerlaunch"
else:
project_root = Path(__file__).parent.parent.parent.parent.parent
stl_path = project_root / "external_repos/steamtinkerlaunch/steamtinkerlaunch"
if not stl_path.exists():
logger.error(f"STL not found at: {stl_path}")
return False, None
remove_cmd = [str(stl_path), "rnsg", f"--appname={shortcut_name}"]
env = os.environ.copy()
env['STL_QUIET'] = '1'
logger.info(f"Removing existing shortcut: {' '.join(remove_cmd)}")
result = subprocess.run(remove_cmd, capture_output=True, text=True, timeout=30, env=env)
if result.returncode != 0:
logger.warning(f"Failed to remove existing shortcut: {result.stderr}")
success, app_id = self.create_shortcut_with_native_service(shortcut_name, exe_path, modlist_install_dir)
return success, app_id
except Exception as e:
logger.error(f"Error replacing shortcut: {e}")
return False, None

View File

@@ -0,0 +1,190 @@
"""STL algorithm methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional
import logging
import vdf
import binascii
logger = logging.getLogger(__name__)
class STLAlgorithmMixin:
"""Mixin providing Steam Tools Library algorithm methods for AutomatedPrefixService."""
def generate_steam_short_id(self, signed_appid: int) -> int:
"""
Convert signed 32-bit integer to unsigned 32-bit integer (same as STL's generateSteamShortID).
Args:
signed_appid: Signed 32-bit integer AppID
Returns:
Unsigned 32-bit integer AppID
"""
return signed_appid & 0xFFFFFFFF
def find_appid_in_shortcuts_vdf(self, shortcut_name: str) -> Optional[str]:
"""
Find the AppID for a shortcut by name directly in shortcuts.vdf.
This is a fallback method when protontricks detection fails.
Args:
shortcut_name: Name of the shortcut to find
Returns:
AppID as string, or None if not found
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return None
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Look for shortcut by name
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
if shortcut_name == name:
appid = shortcut.get('appid')
if appid:
logger.info(f"Found AppID {appid} for shortcut '{shortcut_name}' in shortcuts.vdf")
return str(appid)
logger.warning(f"Shortcut '{shortcut_name}' not found in shortcuts.vdf")
return None
except Exception as e:
logger.error(f"Error finding AppID in shortcuts.vdf: {e}")
return None
def predict_appid_using_stl_algorithm(self, shortcut_name: str, exe_path: str) -> Optional[int]:
"""
Predict the AppID using SteamTinkerLaunch's exact algorithm.
This implements the same logic as STL's generateShortcutVDFAppId and generateSteamShortID functions:
1. Combine AppName + ExePath
2. Generate MD5 hash, take first 8 characters
3. Convert to decimal, make negative, ensure < 1 billion
4. Convert to unsigned 32-bit integer
Args:
shortcut_name: Name of the shortcut
exe_path: Path to the executable
Returns:
Predicted AppID as integer, or None if failed
"""
try:
import hashlib
# Step 1: Combine AppName + ExePath (exactly like STL)
combined_string = f"{shortcut_name}{exe_path}"
logger.debug(f"Combined string for AppID prediction: '{combined_string}'")
# Step 2: Generate MD5 hash and take first 8 characters
md5_hash = hashlib.md5(combined_string.encode()).hexdigest()
seed_hex = md5_hash[:8]
logger.debug(f"MD5 hash: {md5_hash}, seed hex: {seed_hex}")
# Step 3: Convert to decimal, make negative, ensure < 1 billion
seed_decimal = int(seed_hex, 16)
signed_appid = -(seed_decimal % 1000000000)
logger.debug(f"Seed decimal: {seed_decimal}, signed AppID: {signed_appid}")
# Step 4: Convert to unsigned 32-bit integer (STL's generateSteamShortID)
unsigned_appid = signed_appid & 0xFFFFFFFF
logger.debug(f"Unsigned AppID: {unsigned_appid}")
logger.info(f"Predicted AppID using STL algorithm: {unsigned_appid} (signed: {signed_appid})")
return unsigned_appid
except Exception as e:
logger.error(f"Error predicting AppID using STL algorithm: {e}")
return None
def create_shortcut_with_stl_algorithm(self, shortcut_name: str, exe_path: str, start_dir: str, compatibility_tool: str = None) -> bool:
"""
Create a shortcut using STL's exact algorithm for consistent AppID calculation.
Args:
shortcut_name: Name of the shortcut
exe_path: Path to the executable
start_dir: Start directory
compatibility_tool: Optional compatibility tool to set immediately (like STL does)
Returns:
True if successful, False otherwise
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return False
# Read current shortcuts
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
# Find the next available index
next_index = str(len(shortcuts))
# Calculate AppID using STL's algorithm
predicted_appid = self.predict_appid_using_stl_algorithm(shortcut_name, exe_path)
if not predicted_appid:
logger.error("Failed to predict AppID for shortcut creation")
return False
# Convert to signed AppID (STL stores the signed version in shortcuts.vdf)
signed_appid = predicted_appid
if predicted_appid > 0x7FFFFFFF: # If it's a large positive number, make it negative
signed_appid = predicted_appid - 0x100000000
# Create new shortcut entry
new_shortcut = {
'AppName': shortcut_name,
'Exe': f'"{exe_path}"',
'StartDir': f'"{start_dir}"',
'appid': signed_appid, # Use the signed AppID
'icon': '',
'ShortcutPath': '',
'LaunchOptions': '',
'IsHidden': 0,
'AllowDesktopConfig': 1,
'AllowOverlay': 1,
'openvr': 0,
'Devkit': 0,
'DevkitGameID': '',
'LastPlayTime': 0,
'FlatpakAppID': '',
'tags': {},
'IsInstalled': 1 # Make it appear in "Locally Installed" filter
}
# Add the new shortcut
shortcuts[next_index] = new_shortcut
# Write back to file
with open(shortcuts_path, 'wb') as f:
vdf.binary_dump(shortcuts_data, f)
logger.info(f"Created shortcut with STL algorithm: {shortcut_name} with AppID {signed_appid} (unsigned: {predicted_appid})")
# Set compatibility tool immediately if provided (like STL does)
if compatibility_tool:
logger.info(f"Setting compatibility tool immediately: {compatibility_tool}")
success = self.set_compatibility_tool_complete_stl_style(predicted_appid, compatibility_tool)
if not success:
logger.warning("Failed to set compatibility tool immediately")
return True
except Exception as e:
logger.error(f"Error creating shortcut with STL algorithm: {e}")
return False

View File

@@ -0,0 +1,556 @@
"""Workflow methods for AutomatedPrefixService (Mixin)."""
from pathlib import Path
from typing import Optional, Union, List, Dict, Tuple
import logging
import os
import time
import subprocess
import vdf
logger = logging.getLogger(__name__)
def debug_print(message):
"""Log debug message only if debug mode is enabled"""
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
logger.debug(message)
class WorkflowMixin:
"""Mixin providing workflow methods for AutomatedPrefixService."""
def handle_existing_shortcut_conflict(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Union[bool, List[Dict]]:
"""
Check for existing shortcut with same name and path, prompt user if found.
Args:
shortcut_name: Name of the shortcut to create
exe_path: Path to the executable
modlist_install_dir: Directory where the modlist is installed
Returns:
True if we should proceed (no conflict or user chose to replace), False if user cancelled
"""
try:
shortcuts_path = self._get_shortcuts_path()
if not shortcuts_path:
return True # No shortcuts file, no conflict
with open(shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get('shortcuts', {})
conflicts = []
# Look for shortcuts with the same name AND path
for i in range(len(shortcuts)):
shortcut = shortcuts[str(i)]
name = shortcut.get('AppName', '')
shortcut_exe = shortcut.get('Exe', '').strip('"') # Remove quotes
shortcut_startdir = shortcut.get('StartDir', '').strip('"') # Remove quotes
# Check if name matches AND (exe path matches OR startdir matches)
# Use exact name match instead of partial match to avoid false positives
name_matches = shortcut_name == name
exe_matches = shortcut_exe == exe_path
startdir_matches = shortcut_startdir == modlist_install_dir
if (name_matches and (exe_matches or startdir_matches)):
conflicts.append({
'index': i,
'name': name,
'exe': shortcut_exe,
'startdir': shortcut_startdir
})
if conflicts:
logger.warning(f"Found {len(conflicts)} existing shortcut(s) with same name and path")
# Log details about each conflict for debugging
for i, conflict in enumerate(conflicts):
logger.info(f"Conflict {i+1}: Name='{conflict['name']}', Exe='{conflict['exe']}', StartDir='{conflict['startdir']}'")
# Return the conflict information so the frontend can handle it
return conflicts
else:
logger.debug("No conflicting shortcuts found")
return True
except Exception as e:
logger.error(f"Error handling shortcut conflict: {e}")
return True # Proceed on error to avoid blocking
def format_conflict_message(self, conflicts: List[Dict]) -> str:
"""
Format conflict information into a user-friendly message.
Args:
conflicts: List of conflict dictionaries from handle_existing_shortcut_conflict
Returns:
Formatted message for the user
"""
if not conflicts:
return "No conflicts found."
message = f"Found {len(conflicts)} existing Steam shortcut(s) with the same name and path:\n\n"
for i, conflict in enumerate(conflicts, 1):
message += f"{i}. **Name:** {conflict['name']}\n"
message += f" **Executable:** {conflict['exe']}\n"
message += f" **Start Directory:** {conflict['startdir']}\n\n"
message += "**Options:**\n"
message += "• **Replace** - Remove the existing shortcut and create a new one\n"
message += "• **Cancel** - Keep the existing shortcut and stop the installation\n"
message += "• **Skip** - Continue without creating a Steam shortcut\n\n"
message += "The existing shortcut will be removed if you choose to replace it."
return message
def run_complete_workflow(self, shortcut_name: str, modlist_install_dir: str,
final_exe_path: str, progress_callback=None) -> Tuple[bool, Optional[Path], Optional[int]]:
"""
Run the simple automated prefix creation workflow.
Args:
shortcut_name: Name for the Steam shortcut
modlist_install_dir: Directory where the modlist is installed
final_exe_path: Path to ModOrganizer.exe
Returns:
Tuple of (success, prefix_path, appid)
"""
debug_print(f"[DEBUG] run_complete_workflow called with shortcut_name={shortcut_name}, modlist_install_dir={modlist_install_dir}, final_exe_path={final_exe_path}")
logger.info("Starting simple automated prefix creation workflow")
# Initialize shared timing to continue from jackify-engine
from jackify.shared.timing import initialize_from_console_output
# TODO: Pass console output if available to continue timeline
initialize_from_console_output()
# Show immediate feedback to user
if progress_callback:
progress_callback("Starting automated Steam setup...")
try:
# Step 1: Create shortcut directly (NO STL needed!)
logger.info("Step 1: Creating shortcut directly to ModOrganizer.exe")
if progress_callback:
progress_callback("Creating Steam shortcut...")
if not self.create_shortcut_directly_with_proton(shortcut_name, final_exe_path, modlist_install_dir):
logger.error("Failed to create shortcut directly")
return False, None, None, None
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully")
logger.info("Step 1 completed: Shortcut created directly")
# Step 2: Calculate the predictable AppID and rungameid
logger.info("Step 2: Calculating predictable AppID")
if progress_callback:
progress_callback("Calculating AppID...")
# Calculate AppID using the same method as create_shortcut_directly_with_proton
from zlib import crc32
combined_string = final_exe_path + shortcut_name
crc = crc32(combined_string.encode('utf-8'))
initial_appid = -(crc & 0x7FFFFFFF) # Make it negative and within 32-bit range
# Calculate rungameid for launching
rungameid = (initial_appid << 32) | 0x02000000
# Convert AppID to positive prefix ID
expected_prefix_id = str(abs(initial_appid))
if progress_callback:
progress_callback("AppID calculated")
logger.info(f"Step 2 completed: AppID = {initial_appid}, rungameid = {rungameid}, expected_prefix_id = {expected_prefix_id}")
# Step 3: Restart Steam
logger.info("Step 3: Restarting Steam")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Restarting Steam...")
if not self.restart_steam():
logger.error("Failed to restart Steam")
return False, None, None, None
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully")
logger.info("Step 3 completed: Steam restarted")
# Step 4: Launch temporary batch file to create prefix invisibly
logger.info("Step 4: Launching temporary batch file to create prefix")
debug_print(f"[DEBUG] About to launch temporary batch file with rungameid={rungameid}")
# Launch using rungameid (this will run the batch file invisibly)
try:
result = subprocess.run(['steam', f'steam://rungameid/{rungameid}'],
capture_output=True, text=True, timeout=5)
debug_print(f"[DEBUG] Launch result: return_code={result.returncode}")
if result.returncode != 0:
logger.error(f"Failed to launch temporary batch file: {result.stderr}")
return False, None, None, None
except subprocess.TimeoutExpired:
debug_print("[DEBUG] Launch timed out (expected)")
except Exception as e:
logger.error(f"Error launching temporary batch file: {e}")
return False, None, None, None
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Temporary batch file launched")
logger.info("Step 4 completed: Temporary batch file launched")
# Step 5: Wait for temporary batch file to complete (invisible)
logger.info("Step 5: Waiting for temporary batch file to complete")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix (please wait)...")
# Wait for batch file to complete (3 seconds + buffer)
time.sleep(5)
logger.info("Step 5 completed: Temporary batch file completed")
# Step 6: Verify prefix was created
logger.info("Step 6: Verifying prefix creation")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Verifying prefix creation...")
compatdata_path = Path.home() / ".local/share/Steam/steamapps/compatdata" / expected_prefix_id
if not compatdata_path.exists():
logger.error(f"Prefix not found at {compatdata_path}")
return False, None, None, None
logger.info(f"Step 6 completed: Prefix verified at {compatdata_path}")
# Step 7: Replace temporary batch file with final ModOrganizer.exe
logger.info("Step 7: Replacing temporary batch file with final ModOrganizer.exe")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Updating shortcut...")
if not self.replace_shortcut_with_final_exe(shortcut_name, final_exe_path, modlist_install_dir):
logger.error("Failed to replace shortcut with final exe")
return False, None, None, None
logger.info("Step 7 completed: Shortcut updated with final ModOrganizer.exe")
# Step 8: Detect actual AppID using protontricks -l
logger.info("Step 8: Detecting actual AppID")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Detecting actual AppID...")
actual_appid = self.detect_actual_prefix_appid(initial_appid, shortcut_name)
if actual_appid is None:
logger.error("Failed to detect actual AppID")
return False, None, None, None
logger.info(f"Step 8 completed: Actual AppID = {actual_appid}")
# Step 9: Verify prefix was created successfully
logger.info("Step 9: Verifying prefix creation")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Verifying prefix creation...")
prefix_path = self._get_compatdata_path_for_appid(actual_appid)
if not prefix_path or not prefix_path.exists():
logger.error(f"Prefix path not found: {prefix_path}")
return False, None, None, None
if not self.verify_prefix_creation(prefix_path):
logger.error("Prefix verification failed")
return False, None, None, None
logger.info(f"Step 9 completed: Prefix verified at {prefix_path}")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam Configuration complete!")
# Show Proton override notification if applicable
self._show_proton_override_notification(progress_callback)
logger.info(" Simple automated prefix creation workflow completed successfully")
return True, prefix_path, actual_appid
except Exception as e:
logger.error(f"Error in automated prefix creation workflow: {e}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
return False, None, None, None
def run_working_workflow(self, shortcut_name: str, modlist_install_dir: str,
final_exe_path: str, progress_callback=None, steamdeck: Optional[bool] = None,
download_dir=None, auto_restart: bool = True) -> Tuple[bool, Optional[Path], Optional[int], Optional[str]]:
"""
Run the proven working automated prefix creation workflow.
This implements our tested and working approach:
1. Create shortcut with native Steam service (pointing to ModOrganizer.exe initially)
2. Restart Steam using Jackify's robust method
3. Create Proton prefix invisibly using Proton wrapper with DISPLAY=
4. Verify everything persists
Args:
shortcut_name: Name for the Steam shortcut
modlist_install_dir: Directory where the modlist is installed
final_exe_path: Path to ModOrganizer.exe
progress_callback: Optional callback for progress updates
steamdeck: Optional Steam Deck detection override
download_dir: Optional download path; its mountpoint is added to STEAM_COMPAT_MOUNTS
auto_restart: If True, automatically restart Steam. If False, skip restart step.
Returns:
Tuple of (success, prefix_path, appid, last_timestamp)
"""
logger.info("Starting proven working automated prefix creation workflow")
# Show installation complete and configuration start headers FIRST
if progress_callback:
progress_callback("")
progress_callback("=" * 64)
progress_callback("= Installation phase complete =")
progress_callback("=" * 64)
progress_callback("")
progress_callback("=" * 64)
progress_callback("= Starting Configuration Phase =")
progress_callback("=" * 64)
progress_callback("")
# Reset timing for Steam Integration section (part of Configuration Phase)
from jackify.shared.timing import start_new_phase
start_new_phase()
# Show immediate feedback to user with section header
if progress_callback:
progress_callback("") # Blank line before Steam Integration
progress_callback("=== Steam Integration ===")
progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service")
# Registry injection approach for both FNV and Enderal
from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler()
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
# No launch options needed - both FNV and Enderal use registry injection
custom_launch_options = None
if special_game_type in ["fnv", "enderal"]:
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
else:
logger.debug("Standard modlist - no special game handling needed")
try:
# Step 0: Shut down Steam before modifying VDF files
# Required to safely modify shortcuts.vdf and config.vdf without race conditions
logger.info("Step 0: Shutting down Steam before modifying VDF files")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Shutting down Steam...")
from .steam_restart_service import shutdown_steam
try:
if not shutdown_steam():
logger.warning("Steam shutdown returned False, continuing anyway")
except Exception as e:
logger.warning(f"Steam shutdown failed: {e}, continuing anyway")
logger.info("Step 0 completed: Steam shut down")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam shut down")
# Step 1: Create shortcut with native Steam service (Steam is now shut down)
logger.info("Step 1: Creating shortcut with native Steam service")
# DISABLED: Shortcut conflict detection temporarily disabled pending rework
# Re-enable after conflict resolution workflow refactor
# When re-enabled, this will detect and handle cases where shortcuts with the same
# name and path already exist in Steam, allowing users to resolve conflicts
# Disabled pending workflow improvements - planned for future release
# conflict_result = self.handle_existing_shortcut_conflict(shortcut_name, final_exe_path, modlist_install_dir)
# if isinstance(conflict_result, list): # Conflicts found
# logger.warning(f"Found {len(conflict_result)} existing shortcut(s) with same name and path")
# # Return a special tuple to indicate conflict that needs user resolution
# return ("CONFLICT", conflict_result, None)
# elif not conflict_result: # User cancelled or other failure
# logger.error("User cancelled due to shortcut conflict")
# return False, None, None, None
logger.info("Conflict detection temporarily disabled - proceeding with shortcut creation")
# Create shortcut using native Steam service with special game launch options
success, appid = self.create_shortcut_with_native_service(
shortcut_name, final_exe_path, modlist_install_dir, custom_launch_options, download_dir=download_dir
)
if not success:
logger.error("Failed to create shortcut with native Steam service")
return False, None, None, None
logger.info(f"Step 1 completed: Shortcut created with native service, AppID: {appid}")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam shortcut created successfully")
# Apply Steam artwork if available
try:
from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler()
modlist_handler.set_steam_grid_images(str(appid), modlist_install_dir)
logger.info(f"Applied Steam artwork for shortcut '{shortcut_name}' (AppID: {appid})")
except Exception as e:
logger.warning(f"Failed to apply Steam artwork: {e}")
# Step 2: Start Steam (if auto_restart enabled)
logger.info("Step 2: auto_restart=%s", auto_restart)
if auto_restart:
logger.info("Step 2: Starting Steam using Jackify's robust method")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Starting Steam...")
restart_ok = self.restart_steam()
logger.info("Step 2: restart_steam() returned %s", restart_ok)
if not restart_ok:
logger.error("Failed to start Steam")
return False, None, None, None
logger.info("Step 2 completed: Steam started")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam started successfully")
else:
logger.info("Step 2 skipped: Auto-restart disabled by user")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam restart skipped (auto-restart disabled)")
# Step 3: Create Proton prefix invisibly using Proton wrapper
logger.info("Step 3: Creating Proton prefix invisibly")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...")
if not self.create_prefix_with_proton_wrapper(appid):
logger.error("Failed to create Proton prefix")
return False, None, None, None
logger.info("Step 3 completed: Proton prefix created")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully")
# Step 4: Verify everything persists
logger.info("Step 4: Verifying compatibility tool persists")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Verifying setup...")
if not self.verify_compatibility_tool_persists(appid):
logger.warning("Compatibility tool verification failed, but continuing")
logger.info("Step 4 completed: Verification done")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Setup verification completed")
# Step 5: Inject game registry entries for FNV and Enderal modlists
# Get prefix path (needed for logging regardless of game type)
prefix_path = self.get_prefix_path(appid)
if special_game_type in ["fnv", "enderal"]:
logger.info(f"Step 5: Injecting {special_game_type.upper()} game registry entries")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Injecting {special_game_type.upper()} game registry entries...")
if prefix_path:
self._inject_game_registry_entries(str(prefix_path), special_game_type)
else:
logger.warning("Could not find prefix path for registry injection")
else:
logger.info("Step 5: Skipping registry injection for standard modlist")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} No special game registry injection needed")
# Step 5.5: Pre-create game-specific directories for all modlists
logger.info(f"Step 5.5: Creating game-specific user directories")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Creating game user directories...")
if prefix_path:
self._create_game_user_directories(str(prefix_path), special_game_type)
else:
logger.warning("Could not find prefix path for directory creation")
last_timestamp = self._get_progress_timestamp()
logger.info(f" Working workflow completed successfully! AppID: {appid}, Prefix: {prefix_path}")
if progress_callback:
progress_callback(f"{last_timestamp} Steam integration complete")
progress_callback("") # Blank line after Steam integration complete
# Show Proton override notification if applicable
self._show_proton_override_notification(progress_callback)
if progress_callback:
progress_callback("") # Extra blank line to span across Configuration Summary
progress_callback("") # And one more to create space before Prefix Configuration
return True, prefix_path, appid, last_timestamp
except Exception as e:
logger.error(f"Error in working workflow: {e}")
if progress_callback:
progress_callback(f"Error: {str(e)}")
return False, None, None, None
def continue_workflow_after_conflict_resolution(self, shortcut_name: str, modlist_install_dir: str,
final_exe_path: str, appid: int, progress_callback=None) -> Tuple[bool, Optional[Path], Optional[int]]:
"""
Continue the workflow after a shortcut conflict has been resolved.
Args:
shortcut_name: Name of the shortcut
modlist_install_dir: Directory where the modlist is installed
final_exe_path: Path to the final executable
appid: The AppID of the shortcut that was created/replaced
progress_callback: Optional callback for progress updates
Returns:
Tuple of (success, prefix_path, appid)
"""
try:
logger.info("Continuing workflow after conflict resolution")
# Step 2: Restart Steam using Jackify's robust method
logger.info("Step 2: Restarting Steam using Jackify's robust method")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Restarting Steam...")
if not self.restart_steam():
logger.error("Failed to restart Steam")
return False, None, None, None
logger.info("Step 2 completed: Steam restarted")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam restarted successfully")
# Step 3: Create Proton prefix invisibly using Proton wrapper
logger.info("Step 3: Creating Proton prefix invisibly")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Creating Proton prefix...")
if not self.create_prefix_with_proton_wrapper(appid):
logger.error("Failed to create Proton prefix")
return False, None, None, None
logger.info("Step 3 completed: Proton prefix created")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Proton prefix created successfully")
# Step 4: Verify everything persists
logger.info("Step 4: Verifying compatibility tool persists")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Verifying setup...")
if not self.verify_compatibility_tool_persists(appid):
logger.warning("Compatibility tool verification failed, but continuing")
logger.info("Step 4 completed: Verification done")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Setup verification completed")
# Get the prefix path
prefix_path = self.get_prefix_path(appid)
last_timestamp = self._get_progress_timestamp()
logger.info(f" Workflow completed successfully after conflict resolution! AppID: {appid}, Prefix: {prefix_path}")
if progress_callback:
progress_callback(f"{last_timestamp} Automated Steam setup completed successfully!")
return True, prefix_path, appid, last_timestamp
except Exception as e:
logger.error(f"Error continuing workflow after conflict resolution: {e}")
if progress_callback:
progress_callback(f"Error: {str(e)}")
return False, None, None, None

View File

@@ -7,11 +7,14 @@ import json
import subprocess
import time
import threading
import logging
from pathlib import Path
from typing import Optional, List, Dict
from datetime import datetime, timedelta
import urllib.request
logger = logging.getLogger(__name__)
from jackify.backend.models.modlist_metadata import (
ModlistMetadataResponse,
ModlistMetadata,
@@ -120,7 +123,7 @@ class ModlistGalleryService:
# Execute command
# CRITICAL: Use centralized clean environment to prevent AppImage recursive spawning
# This must happen AFTER engine path resolution
# Must happen AFTER engine path resolution
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
@@ -290,7 +293,7 @@ class ModlistGalleryService:
cmd[0] = str(engine_path)
# CRITICAL: Use centralized clean environment to prevent AppImage recursive spawning
# This must happen AFTER engine path resolution
# Must happen AFTER engine path resolution
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
@@ -394,7 +397,7 @@ class ModlistGalleryService:
data = json.loads(response.read().decode('utf-8'))
return data
except Exception as e:
print(f"Warning: Could not load tag mappings: {e}")
logger.warning(f"Could not load tag mappings: {e}")
return {}
def load_allowed_tags(self) -> set:
@@ -410,7 +413,7 @@ class ModlistGalleryService:
data = json.loads(response.read().decode('utf-8'))
return set(data) # Return as set preserving original case
except Exception as e:
print(f"Warning: Could not load allowed tags: {e}")
logger.warning(f"Could not load allowed tags: {e}")
return set()
def _ensure_tag_metadata(self):

View File

@@ -11,10 +11,12 @@ from pathlib import Path
from ..models.modlist import ModlistContext, ModlistInfo
from ..models.configuration import SystemInfo
from .modlist_service_installation import ModlistServiceInstallationMixin
logger = logging.getLogger(__name__)
class ModlistService:
class ModlistService(ModlistServiceInstallationMixin):
"""Service for managing modlist operations."""
def __init__(self, system_info: SystemInfo):
@@ -143,268 +145,7 @@ class ModlistService:
except Exception as e:
logger.error(f"Failed to list modlists: {e}")
raise
def install_modlist(self, context: ModlistContext,
progress_callback=None,
output_callback=None) -> bool:
"""Install a modlist (ONLY installation, no configuration).
This method only runs the engine installation phase.
Configuration must be called separately after Steam setup.
Args:
context: Modlist installation context
progress_callback: Optional callback for progress updates
output_callback: Optional callback for output/logging
Returns:
True if installation successful, False otherwise
"""
logger.info(f"Installing modlist (INSTALLATION ONLY): {context.name}")
try:
# Validate context
if not self._validate_install_context(context):
logger.error("Invalid installation context")
return False
# Prepare directories
fs_handler = self._get_filesystem_handler()
fs_handler.ensure_directory(context.install_dir)
fs_handler.ensure_directory(context.download_dir)
# Use the working ModlistInstallCLI for discovery phase only
from ..core.modlist_operations import ModlistInstallCLI
# Use new SystemInfo pattern
modlist_cli = ModlistInstallCLI(self.system_info)
# Build context for ModlistInstallCLI
install_context = {
'modlist_name': context.name,
'install_dir': context.install_dir,
'download_dir': context.download_dir,
'nexus_api_key': context.nexus_api_key,
'game_type': context.game_type,
'modlist_value': context.modlist_value,
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True # Service layer should be non-interactive
}
# Set GUI mode for non-interactive operation
import os
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
os.environ['JACKIFY_GUI_MODE'] = '1'
try:
# Run discovery phase with pre-filled context
confirmed_context = modlist_cli.run_discovery_phase(context_override=install_context)
if not confirmed_context:
logger.error("Discovery phase failed or was cancelled")
return False
# Now run ONLY the installation part (NOT configuration)
success = self._run_installation_only(
confirmed_context,
progress_callback=progress_callback,
output_callback=output_callback
)
if success:
logger.info("Modlist installation completed successfully (configuration will be done separately)")
return True
else:
logger.error("Modlist installation failed")
return False
finally:
# Restore original GUI mode
if original_gui_mode is not None:
os.environ['JACKIFY_GUI_MODE'] = original_gui_mode
else:
os.environ.pop('JACKIFY_GUI_MODE', None)
except Exception as e:
error_message = str(e)
logger.error(f"Failed to install modlist {context.name}: {error_message}")
# Check for file descriptor limit issues and attempt to handle them
from .resource_manager import handle_file_descriptor_error
try:
if any(indicator in error_message.lower() for indicator in ['too many open files', 'emfile', 'resource temporarily unavailable']):
result = handle_file_descriptor_error(error_message, "modlist installation")
if result['auto_fix_success']:
logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
logger.warning(f"File descriptor limit issue detected but automatic fix failed. {result['recommendation']}")
if result['manual_instructions']:
distro = result['manual_instructions']['distribution']
logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
logger.debug(f"Error checking for resource limit issues: {resource_error}")
return False
def _run_installation_only(self, context, progress_callback=None, output_callback=None) -> bool:
"""Run only the installation phase using the engine (COPIED FROM WORKING CODE)."""
import subprocess
import os
import sys
from pathlib import Path
from ..core.modlist_operations import get_jackify_engine_path
try:
# COPIED EXACTLY from working Archive_Do_Not_Write/modules/modlist_install_cli.py
# Process paths (copied from working code)
install_dir_context = context['install_dir']
if isinstance(install_dir_context, tuple):
actual_install_path = Path(install_dir_context[0])
if install_dir_context[1]:
actual_install_path.mkdir(parents=True, exist_ok=True)
else:
actual_install_path = Path(install_dir_context)
install_dir_str = str(actual_install_path)
download_dir_context = context['download_dir']
if isinstance(download_dir_context, tuple):
actual_download_path = Path(download_dir_context[0])
if download_dir_context[1]:
actual_download_path.mkdir(parents=True, exist_ok=True)
else:
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
# CRITICAL: Re-check authentication right before launching engine
# This ensures we use current auth state, not stale cached values from context
# (e.g., if user revoked OAuth after context was created)
from ..services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
# Use current auth state, fallback to context values only if current check failed
api_key = current_api_key or context.get('nexus_api_key')
oauth_info = current_oauth_info or context.get('nexus_oauth_info')
# Path to the engine binary (copied from working code)
engine_path = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_path)
if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK):
if output_callback:
output_callback(f"Jackify Install Engine not found or not executable at: {engine_path}")
return False
# Build command (copied from working code)
cmd = [engine_path, 'install', '--show-file-progress']
modlist_value = context.get('modlist_value')
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
cmd += ['-w', modlist_value]
elif modlist_value:
cmd += ['-m', modlist_value]
elif context.get('machineid'):
cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str]
# NOTE: API key is passed via environment variable only, not as command line argument
# Store original environment values (copied from working code)
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
# Environment setup - prefer NEXUS_OAUTH_INFO (supports auto-refresh) over NEXUS_API_KEY
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
# CRITICAL: Set client_id so engine can refresh tokens with correct client_id
# Engine's RefreshToken method reads this to use our "jackify" client_id instead of hardcoded "wabbajack"
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
# Also set NEXUS_API_KEY for backward compatibility
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
else:
# No auth available, clear any inherited values
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
if output_callback:
output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}")
# Temporarily increase file descriptor limit for engine process
from jackify.backend.handlers.subprocess_utils import increase_file_descriptor_limit
success, old_limit, new_limit, message = increase_file_descriptor_limit()
if output_callback:
if success:
output_callback(f"File descriptor limit: {message}")
else:
output_callback(f"File descriptor limit warning: {message}")
# Subprocess call with cleaned environment to prevent AppImage variable inheritance
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
clean_env = get_clean_subprocess_env()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=False, env=clean_env, cwd=engine_dir)
# Output processing (copied from working code)
buffer = b''
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk == b'\n':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
proc.wait()
if proc.returncode != 0:
if output_callback:
output_callback(f"Jackify Install Engine exited with code {proc.returncode}.")
return False
else:
if output_callback:
output_callback("Installation completed successfully")
return True
finally:
# Restore environment (copied from working code)
for key, original_value in original_env_values.items():
if original_value is not None:
os.environ[key] = original_value
else:
if key in os.environ:
del os.environ[key]
except Exception as e:
error_msg = f"Error running Jackify Install Engine: {e}"
logger.error(error_msg)
if output_callback:
output_callback(error_msg)
return False
def configure_modlist_post_steam(self, context: ModlistContext,
progress_callback=None,
manual_steps_callback=None,
@@ -503,7 +244,8 @@ class ModlistService:
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': True, # Manual steps were done in GUI
'appid': getattr(context, 'app_id', None), # Use updated app_id from Steam
'engine_installed': getattr(context, 'engine_installed', False) # Path manipulation flag
'engine_installed': getattr(context, 'engine_installed', False), # Path manipulation flag
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
}
debug_callback(f"Configuration context built: {config_context}")
@@ -682,7 +424,8 @@ class ModlistService:
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': False,
'appid': getattr(context, 'app_id', None) # Fix: Include appid like other configuration paths
'appid': getattr(context, 'app_id', None), # Fix: Include appid like other configuration paths
'download_dir': str(context.download_dir) if getattr(context, 'download_dir', None) else None,
}
# DEBUG: Log what resolution we're passing

View File

@@ -0,0 +1,237 @@
"""
Modlist installation phase for ModlistService (Mixin).
Runs engine installation only; configuration is handled separately after Steam setup.
"""
import logging
import os
import subprocess
from pathlib import Path
from ..models.modlist import ModlistContext
logger = logging.getLogger(__name__)
class ModlistServiceInstallationMixin:
"""Mixin providing install_modlist and _run_installation_only for ModlistService."""
def install_modlist(self, context: ModlistContext,
progress_callback=None,
output_callback=None) -> bool:
"""Install a modlist (installation only, no configuration).
Configuration must be called separately after Steam setup.
"""
logger.info(f"Installing modlist (INSTALLATION ONLY): {context.name}")
try:
if not self._validate_install_context(context):
logger.error("Invalid installation context")
return False
fs_handler = self._get_filesystem_handler()
fs_handler.ensure_directory(context.install_dir)
fs_handler.ensure_directory(context.download_dir)
from ..core.modlist_operations import ModlistInstallCLI
modlist_cli = ModlistInstallCLI(self.system_info)
install_context = {
'modlist_name': context.name,
'install_dir': context.install_dir,
'download_dir': context.download_dir,
'nexus_api_key': context.nexus_api_key,
'game_type': context.game_type,
'modlist_value': context.modlist_value,
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True
}
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
os.environ['JACKIFY_GUI_MODE'] = '1'
try:
confirmed_context = modlist_cli.run_discovery_phase(context_override=install_context)
if not confirmed_context:
logger.error("Discovery phase failed or was cancelled")
return False
success = self._run_installation_only(
confirmed_context,
progress_callback=progress_callback,
output_callback=output_callback
)
if success:
logger.info("Modlist installation completed successfully (configuration done separately)")
return True
logger.error("Modlist installation failed")
return False
finally:
if original_gui_mode is not None:
os.environ['JACKIFY_GUI_MODE'] = original_gui_mode
else:
os.environ.pop('JACKIFY_GUI_MODE', None)
except Exception as e:
error_message = str(e)
logger.error(f"Failed to install modlist {context.name}: {error_message}")
from .resource_manager import handle_file_descriptor_error
try:
if any(indicator in error_message.lower() for indicator in
['too many open files', 'emfile', 'resource temporarily unavailable']):
result = handle_file_descriptor_error(error_message, "modlist installation")
if result['auto_fix_success']:
logger.info(f"File descriptor limit increased automatically. {result['recommendation']}")
elif result['error_detected']:
logger.warning(f"File descriptor issue detected but automatic fix failed. {result['recommendation']}")
if result.get('manual_instructions'):
distro = result['manual_instructions']['distribution']
logger.info(f"Manual ulimit increase instructions available for {distro} distribution")
except Exception as resource_error:
logger.debug(f"Error checking for resource limit issues: {resource_error}")
return False
def _run_installation_only(self, context, progress_callback=None, output_callback=None) -> bool:
"""Run only the installation phase using the engine."""
from ..core.modlist_operations import get_jackify_engine_path
try:
install_dir_context = context['install_dir']
if isinstance(install_dir_context, tuple):
actual_install_path = Path(install_dir_context[0])
if install_dir_context[1]:
actual_install_path.mkdir(parents=True, exist_ok=True)
else:
actual_install_path = Path(install_dir_context)
install_dir_str = str(actual_install_path)
download_dir_context = context['download_dir']
if isinstance(download_dir_context, tuple):
actual_download_path = Path(download_dir_context[0])
if download_dir_context[1]:
actual_download_path.mkdir(parents=True, exist_ok=True)
else:
actual_download_path = Path(download_dir_context)
download_dir_str = str(actual_download_path)
from ..services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
current_api_key, current_oauth_info = auth_service.get_auth_for_engine()
api_key = current_api_key or context.get('nexus_api_key')
oauth_info = current_oauth_info or context.get('nexus_oauth_info')
engine_path = get_jackify_engine_path()
engine_dir = os.path.dirname(engine_path)
if not os.path.isfile(engine_path) or not os.access(engine_path, os.X_OK):
if output_callback:
output_callback(f"Jackify Install Engine not found or not executable at: {engine_path}")
return False
cmd = [engine_path, 'install', '--show-file-progress']
modlist_value = context.get('modlist_value')
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
cmd += ['-w', modlist_value]
elif modlist_value:
cmd += ['-m', modlist_value]
elif context.get('machineid'):
cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str]
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
'NEXUS_OAUTH_INFO': os.environ.get('NEXUS_OAUTH_INFO'),
'DOTNET_SYSTEM_GLOBALIZATION_INVARIANT': os.environ.get('DOTNET_SYSTEM_GLOBALIZATION_INVARIANT')
}
try:
if oauth_info:
os.environ['NEXUS_OAUTH_INFO'] = oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
os.environ['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
if api_key:
os.environ['NEXUS_API_KEY'] = api_key
elif api_key:
os.environ['NEXUS_API_KEY'] = api_key
else:
if 'NEXUS_API_KEY' in os.environ:
del os.environ['NEXUS_API_KEY']
if 'NEXUS_OAUTH_INFO' in os.environ:
del os.environ['NEXUS_OAUTH_INFO']
os.environ['DOTNET_SYSTEM_GLOBALIZATION_INVARIANT'] = "1"
pretty_cmd = ' '.join([f'"{arg}"' if ' ' in arg else arg for arg in cmd])
if output_callback:
output_callback(f"Launching Jackify Install Engine with command: {pretty_cmd}")
from jackify.backend.handlers.subprocess_utils import (
increase_file_descriptor_limit,
get_clean_subprocess_env,
)
success, old_limit, new_limit, message = increase_file_descriptor_limit()
if output_callback:
if success:
output_callback(f"File descriptor limit: {message}")
else:
output_callback(f"File descriptor limit warning: {message}")
clean_env = get_clean_subprocess_env()
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=False, env=clean_env, cwd=engine_dir
)
buffer = b''
while True:
chunk = proc.stdout.read(1)
if not chunk:
break
buffer += chunk
if chunk == b'\n':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
elif chunk == b'\r':
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
buffer = b''
if buffer:
line = buffer.decode('utf-8', errors='replace')
if output_callback:
output_callback(line.rstrip())
proc.wait()
if proc.returncode != 0:
if output_callback:
output_callback(f"Jackify Install Engine exited with code {proc.returncode}.")
return False
if output_callback:
output_callback("Installation completed successfully")
return True
finally:
for key, original_value in original_env_values.items():
if original_value is not None:
os.environ[key] = original_value
elif key in os.environ:
del os.environ[key]
except Exception as e:
error_msg = f"Error running Jackify Install Engine: {e}"
logger.error(error_msg)
if output_callback:
output_callback(error_msg)
return False

View File

@@ -451,7 +451,7 @@ class NativeSteamService:
if app_id_exists:
logger.info(f"AppID {app_id} already exists in CompatToolMapping, will be overwritten")
# Remove the existing entry by finding and removing the entire block
# This is complex, so for now just add at the end
# Complex ordering -- just append for now
# Create the new entry in STL's exact format (tabs between key and value)
new_entry = f'\t\t\t\t\t"{app_id}"\n\t\t\t\t\t{{\n\t\t\t\t\t\t"name"\t\t"{proton_version}"\n\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"250"\n\t\t\t\t\t}}\n'
@@ -574,8 +574,7 @@ class NativeSteamService:
"""
Create symlink to libraryfolders.vdf in Wine prefix for game detection.
This allows Wabbajack running in the prefix to detect Steam games.
Based on Wabbajack-Proton-AuCu implementation.
Allows Wabbajack running in the prefix to detect Steam games.
Args:
app_id: Steam AppID (unsigned)

View File

@@ -259,7 +259,7 @@ class NexusAuthService:
oauth_data = token_data.get('oauth', {})
# Build NexusOAuthState JSON matching upstream Wabbajack format
# This allows engine to auto-refresh tokens during long installations
# Engine auto-refreshes tokens during long installations
nexus_oauth_state = {
"oauth": {
"access_token": oauth_data.get('access_token'),

View File

@@ -184,7 +184,9 @@ class NexusDownloadService:
if file_name_filter:
filtered = [f for f in files if file_name_filter.lower() in f.get('file_name', '').lower()]
if not filtered:
return False, None, f"No files found matching '{file_name_filter}'"
available_files = [f.get('file_name', 'unknown') for f in files]
logger.warning(f"No files matching '{file_name_filter}' in: {available_files}")
return False, None, f"No files found matching '{file_name_filter}'. Available: {', '.join(available_files)}"
files = filtered
# Get the most recent file

View File

@@ -0,0 +1,147 @@
"""
Nexus OAuth callback: _generate_self_signed_cert, _create_callback_handler, _wait_for_callback.
"""
import os
import time
import logging
import tempfile
import urllib.parse
from pathlib import Path
from http.server import BaseHTTPRequestHandler
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
class NexusOAuthCallbackMixin:
"""Mixin providing callback server and wait logic for NexusOAuthService."""
def _generate_self_signed_cert(self) -> Tuple[Optional[str], Optional[str]]:
"""Generate self-signed certificate for HTTPS localhost. Returns (cert_file_path, key_file_path) or (None, None)."""
redirect_host = getattr(self, 'REDIRECT_HOST', '127.0.0.1')
try:
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import datetime
import ipaddress
logger.info("Generating self-signed certificate for OAuth callback")
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Jackify"),
x509.NameAttribute(NameOID.COMMON_NAME, redirect_host),
])
cert = x509.CertificateBuilder().subject_name(subject).issuer_name(issuer).public_key(
private_key.public_key()
).serial_number(x509.random_serial_number()).not_valid_before(
datetime.datetime.now(datetime.UTC)
).not_valid_after(
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([x509.IPAddress(ipaddress.IPv4Address(redirect_host))]),
critical=False,
).sign(private_key, hashes.SHA256())
temp_dir = tempfile.mkdtemp()
cert_file = os.path.join(temp_dir, "oauth_cert.pem")
key_file = os.path.join(temp_dir, "oauth_key.pem")
with open(cert_file, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
with open(key_file, "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
))
return cert_file, key_file
except ImportError:
logger.error("cryptography package not installed - required for OAuth")
return None, None
except Exception as e:
logger.error("Failed to generate SSL certificate: %s", e)
return None, None
def _create_callback_handler(self):
"""Create HTTP request handler class for OAuth callback."""
service = self
class OAuthCallbackHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
logger.debug("OAuth callback: %s", format % args)
def do_GET(self):
logger.info("OAuth callback received: %s", self.path)
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
if parsed.path == '/favicon.ico':
self.send_response(404)
self.end_headers()
return
if 'code' in params:
service._auth_code = params['code'][0]
service._auth_state = params.get('state', [None])[0]
logger.info("OAuth authorization code received: %s...", service._auth_code[:10])
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = """<html><head><title>Authorization Successful</title></head><body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;"><h1>Authorization Successful!</h1><p>You can close this window and return to Jackify.</p><script>setTimeout(function() { window.close(); }, 3000);</script></body></html>"""
self.wfile.write(html.encode())
elif 'error' in params:
service._auth_error = params['error'][0]
error_desc = params.get('error_description', ['Unknown error'])[0]
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = f"<html><head><title>Authorization Failed</title></head><body style='font-family: Arial, sans-serif; text-align: center; padding: 50px;'><h1>Authorization Failed</h1><p>Error: {service._auth_error}</p><p>{error_desc}</p><p>You can close this window and try again in Jackify.</p></body></html>"
self.wfile.write(html.encode())
else:
logger.warning("OAuth callback with no code or error: %s", params)
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = "<html><head><title>Invalid Request</title></head><body style='font-family: Arial, sans-serif; text-align: center; padding: 50px;'><h1>Invalid OAuth Callback</h1><p>You can close this window.</p></body></html>"
self.wfile.write(html.encode())
service._server_done.set()
logger.debug("OAuth callback handler signaled server to shut down")
return OAuthCallbackHandler
def _wait_for_callback(self) -> bool:
"""Wait for OAuth callback via jackify:// protocol handler. Returns True if callback received."""
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
if callback_file.exists():
callback_file.unlink()
logger.info("Waiting for OAuth callback via jackify:// protocol")
start_time = time.time()
last_reminder = 0
while (time.time() - start_time) < self.CALLBACK_TIMEOUT:
if callback_file.exists():
try:
lines = callback_file.read_text().strip().split('\n')
if len(lines) >= 2:
self._auth_code = lines[0]
self._auth_state = lines[1]
logger.info("OAuth callback received: code=%s...", self._auth_code[:10])
callback_file.unlink()
return True
except Exception as e:
logger.error("Failed to read callback file: %s", e)
return False
elapsed = time.time() - start_time
if elapsed - last_reminder > 30:
logger.info("Still waiting for OAuth callback... (%ss elapsed)", int(elapsed))
if elapsed > 60:
logger.warning(
"If you see a blank browser tab, check for browser notifications asking to "
"'Open Jackify', or use 'Paste callback URL' in Jackify to paste the URL from the address bar"
)
last_reminder = elapsed
time.sleep(0.5)
logger.error("OAuth callback timeout after %s seconds", self.CALLBACK_TIMEOUT)
logger.error(
"Protocol handler may not be working. Check:\n"
" 1. Browser asked 'Open Jackify?' and you clicked Allow\n"
" 2. No popup blocker notifications\n"
" 3. Desktop file exists: ~/.local/share/applications/com.jackify.app.desktop"
)
return False

View File

@@ -0,0 +1,127 @@
"""
Nexus OAuth protocol handler registration: _ensure_protocol_registered.
"""
import os
import sys
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class NexusOAuthProtocolMixin:
"""Mixin providing jackify:// protocol registration for NexusOAuthService."""
def _ensure_protocol_registered(self) -> bool:
"""Ensure jackify:// protocol is registered with the OS."""
import subprocess
if not sys.platform.startswith('linux'):
logger.debug("Protocol registration only needed on Linux")
return True
try:
desktop_file = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop"
env = os.environ
is_appimage = (
'APPIMAGE' in env or 'APPDIR' in env or
(sys.argv[0] and sys.argv[0].endswith('.AppImage'))
)
if is_appimage:
if 'APPIMAGE' in env:
exec_path = env['APPIMAGE']
logger.info("Using APPIMAGE env var: %s", exec_path)
elif sys.argv[0] and Path(sys.argv[0]).exists():
exec_path = str(Path(sys.argv[0]).resolve())
logger.info("Using resolved sys.argv[0]: %s", exec_path)
else:
exec_path = sys.argv[0]
logger.warning("Using sys.argv[0] as fallback: %s", exec_path)
else:
src_dir = Path(__file__).resolve().parent.parent.parent.parent
exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --'
logger.info("DEV mode exec path: %s", exec_path)
logger.info("Source directory: %s", src_dir)
needs_update = False
if not desktop_file.exists():
needs_update = True
logger.info("Creating desktop file for protocol handler")
else:
current_content = desktop_file.read_text()
if is_appimage:
expected_exec = f'Exec="{exec_path}" %u'
else:
expected_exec = f"Exec={exec_path} %u"
if expected_exec not in current_content:
needs_update = True
logger.info("Updating desktop file with new Exec path: %s", exec_path)
if is_appimage and ' ' in exec_path:
import re
if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content):
needs_update = True
logger.info("Fixing malformed desktop file (unquoted path with spaces)")
if needs_update:
desktop_file.parent.mkdir(parents=True, exist_ok=True)
if is_appimage:
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec="{exec_path}" %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
"""
else:
src_dir = Path(__file__).resolve().parent.parent.parent.parent
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec={exec_path} %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
Path={src_dir}
"""
desktop_file.write_text(desktop_content)
logger.info("Desktop file written: %s", desktop_file)
logger.info("Exec path: %s", exec_path)
logger.info("AppImage mode: %s", is_appimage)
logger.info("Registering jackify:// protocol handler")
apps_dir = Path.home() / ".local" / "share" / "applications"
subprocess.run(['update-desktop-database', str(apps_dir)], capture_output=True, timeout=10)
subprocess.run(
['xdg-mime', 'default', 'com.jackify.app.desktop', 'x-scheme-handler/jackify'],
capture_output=True, timeout=10
)
subprocess.run(
['xdg-settings', 'set', 'default-url-scheme-handler', 'jackify', 'com.jackify.app.desktop'],
capture_output=True, timeout=10
)
mimeapps_path = Path.home() / ".config" / "mimeapps.list"
try:
if mimeapps_path.exists():
content = mimeapps_path.read_text()
else:
mimeapps_path.parent.mkdir(parents=True, exist_ok=True)
content = "[Default Applications]\n"
if 'x-scheme-handler/jackify=' not in content:
if '[Default Applications]' not in content:
content = "[Default Applications]\n" + content
lines = content.split('\n')
for i, line in enumerate(lines):
if line.strip() == '[Default Applications]':
lines.insert(i + 1, 'x-scheme-handler/jackify=com.jackify.app.desktop')
break
content = '\n'.join(lines)
mimeapps_path.write_text(content)
logger.info("Added jackify handler to mimeapps.list")
except Exception as e:
logger.warning("Failed to update mimeapps.list: %s", e)
logger.info("jackify:// protocol registered successfully")
return True
except Exception as e:
logger.warning("Failed to register jackify:// protocol: %s", e)
return False

View File

@@ -11,21 +11,21 @@ import hashlib
import secrets
import webbrowser
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
import requests
import json
import threading
import ssl
import tempfile
import logging
import time
import subprocess
from typing import Optional, Tuple, Dict
from .nexus_oauth_protocol import NexusOAuthProtocolMixin
from .nexus_oauth_callback import NexusOAuthCallbackMixin
logger = logging.getLogger(__name__)
class NexusOAuthService:
class NexusOAuthService(NexusOAuthProtocolMixin, NexusOAuthCallbackMixin):
"""
Handles OAuth 2.0 authentication with Nexus Mods
Uses PKCE flow with system browser and localhost callback
@@ -77,451 +77,35 @@ class NexusOAuthService:
return code_verifier, code_challenge, state
def _ensure_protocol_registered(self) -> bool:
"""
Ensure jackify:// protocol is registered with the OS
Returns:
True if registration successful or already registered
"""
import subprocess
import sys
from pathlib import Path
if not sys.platform.startswith('linux'):
logger.debug("Protocol registration only needed on Linux")
return True
try:
# Ensure desktop file exists and has correct Exec path
desktop_file = Path.home() / ".local" / "share" / "applications" / "com.jackify.app.desktop"
# Get environment for AppImage detection
env = os.environ
# Determine executable path (DEV mode vs AppImage)
# Check multiple indicators for AppImage execution
is_appimage = (
'APPIMAGE' in env or # AppImage environment variable
'APPDIR' in env or # AppImage directory variable
(sys.argv[0] and sys.argv[0].endswith('.AppImage')) # Executable name
)
if is_appimage:
# Running from AppImage - use the AppImage path directly
# CRITICAL: Never use -m flag in AppImage mode - it causes __main__.py windows
if 'APPIMAGE' in env:
# APPIMAGE env var gives us the exact path to the AppImage
exec_path = env['APPIMAGE']
logger.info(f"Using APPIMAGE env var: {exec_path}")
elif sys.argv[0] and Path(sys.argv[0]).exists():
# Use sys.argv[0] if it's a valid path
exec_path = str(Path(sys.argv[0]).resolve())
logger.info(f"Using resolved sys.argv[0]: {exec_path}")
else:
# Fallback to sys.argv[0] as-is
exec_path = sys.argv[0]
logger.warning(f"Using sys.argv[0] as fallback: {exec_path}")
else:
# Running from source (DEV mode)
# Need to ensure we run from the correct directory
src_dir = Path(__file__).parent.parent.parent.parent # Go up to src/
# Use bash -c with proper quoting for paths with spaces
exec_path = f'bash -c \'cd "{src_dir}" && "{sys.executable}" -m jackify.frontends.gui "$@"\' --'
logger.info(f"DEV mode exec path: {exec_path}")
logger.info(f"Source directory: {src_dir}")
# Check if desktop file needs creation or update
needs_update = False
if not desktop_file.exists():
needs_update = True
logger.info("Creating desktop file for protocol handler")
else:
# Check if Exec path matches current mode
current_content = desktop_file.read_text()
# Check for both quoted (AppImage) and unquoted (DEV mode with bash -c) formats
if is_appimage:
expected_exec = f'Exec="{exec_path}" %u'
else:
expected_exec = f"Exec={exec_path} %u"
if expected_exec not in current_content:
needs_update = True
logger.info(f"Updating desktop file with new Exec path: {exec_path}")
# Explicitly detect and fix malformed entries (unquoted paths with spaces)
# Check if any Exec line exists without quotes but contains spaces
if is_appimage and ' ' in exec_path:
import re
# Look for Exec=<path with spaces> without quotes
if re.search(r'Exec=[^"]\S*\s+\S*\.AppImage', current_content):
needs_update = True
logger.info("Fixing malformed desktop file (unquoted path with spaces)")
if needs_update:
desktop_file.parent.mkdir(parents=True, exist_ok=True)
# Build desktop file content with proper working directory
if is_appimage:
# AppImage - quote path to handle spaces
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec="{exec_path}" %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
"""
else:
# DEV mode - exec_path already contains bash -c with proper quoting
src_dir = Path(__file__).parent.parent.parent.parent # Go up to src/
desktop_content = f"""[Desktop Entry]
Type=Application
Name=Jackify
Comment=Wabbajack modlist manager for Linux
Exec={exec_path} %u
Icon=com.jackify.app
Terminal=false
Categories=Game;Utility;
MimeType=x-scheme-handler/jackify;
Path={src_dir}
"""
desktop_file.write_text(desktop_content)
logger.info(f"Desktop file written: {desktop_file}")
logger.info(f"Exec path: {exec_path}")
logger.info(f"AppImage mode: {is_appimage}")
# Always ensure full registration (don't trust xdg-settings alone)
# PopOS/Ubuntu need mimeapps.list even if xdg-settings says registered
logger.info("Registering jackify:// protocol handler")
# Update MIME cache (required for Firefox dialog)
apps_dir = Path.home() / ".local" / "share" / "applications"
subprocess.run(
['update-desktop-database', str(apps_dir)],
capture_output=True,
timeout=10
)
# Set as default handler using xdg-mime (Firefox compatibility)
subprocess.run(
['xdg-mime', 'default', 'com.jackify.app.desktop', 'x-scheme-handler/jackify'],
capture_output=True,
timeout=10
)
# Also use xdg-settings as backup (some systems need both)
subprocess.run(
['xdg-settings', 'set', 'default-url-scheme-handler', 'jackify', 'com.jackify.app.desktop'],
capture_output=True,
timeout=10
)
# Manually ensure entry in mimeapps.list (PopOS/Ubuntu require this for GIO)
mimeapps_path = Path.home() / ".config" / "mimeapps.list"
try:
# Read existing content
if mimeapps_path.exists():
content = mimeapps_path.read_text()
else:
mimeapps_path.parent.mkdir(parents=True, exist_ok=True)
content = "[Default Applications]\n"
# Add jackify handler if not present
if 'x-scheme-handler/jackify=' not in content:
if '[Default Applications]' not in content:
content = "[Default Applications]\n" + content
# Insert after [Default Applications] line
lines = content.split('\n')
for i, line in enumerate(lines):
if line.strip() == '[Default Applications]':
lines.insert(i + 1, 'x-scheme-handler/jackify=com.jackify.app.desktop')
break
content = '\n'.join(lines)
mimeapps_path.write_text(content)
logger.info("Added jackify handler to mimeapps.list")
except Exception as e:
logger.warning(f"Failed to update mimeapps.list: {e}")
logger.info("jackify:// protocol registered successfully")
return True
except Exception as e:
logger.warning(f"Failed to register jackify:// protocol: {e}")
return False
def _generate_self_signed_cert(self) -> Tuple[Optional[str], Optional[str]]:
"""
Generate self-signed certificate for HTTPS localhost
Returns:
Tuple of (cert_file_path, key_file_path) or (None, None) on failure
"""
try:
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import datetime
import ipaddress
logger.info("Generating self-signed certificate for OAuth callback")
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Create certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Jackify"),
x509.NameAttribute(NameOID.COMMON_NAME, self.REDIRECT_HOST),
])
cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
private_key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
datetime.datetime.now(datetime.UTC)
).not_valid_after(
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([
x509.IPAddress(ipaddress.IPv4Address(self.REDIRECT_HOST)),
]),
critical=False,
).sign(private_key, hashes.SHA256())
# Save to temp files
temp_dir = tempfile.mkdtemp()
cert_file = os.path.join(temp_dir, "oauth_cert.pem")
key_file = os.path.join(temp_dir, "oauth_key.pem")
with open(cert_file, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
with open(key_file, "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
))
return cert_file, key_file
except ImportError:
logger.error("cryptography package not installed - required for OAuth")
return None, None
except Exception as e:
logger.error(f"Failed to generate SSL certificate: {e}")
return None, None
def _build_authorization_url(self, code_challenge: str, state: str) -> str:
"""
Build OAuth authorization URL
Args:
code_challenge: PKCE code challenge
state: CSRF protection state
Returns:
Authorization URL
Build the Nexus OAuth 2.0 authorisation URL with PKCE parameters.
"""
params = {
'response_type': 'code',
'client_id': self.CLIENT_ID,
'redirect_uri': self.REDIRECT_URI,
'scope': self.SCOPES,
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
'state': state
"response_type": "code",
"client_id": self.CLIENT_ID,
"redirect_uri": self.REDIRECT_URI,
"scope": self.SCOPES,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
query = urllib.parse.urlencode(params)
return f"{self.AUTH_URL}?{query}"
return f"{self.AUTH_URL}?{urllib.parse.urlencode(params)}"
def _create_callback_handler(self):
"""Create HTTP request handler class for OAuth callback"""
service = self
class OAuthCallbackHandler(BaseHTTPRequestHandler):
"""HTTP request handler for OAuth callback"""
def log_message(self, format, *args):
"""Log OAuth callback requests"""
logger.debug(f"OAuth callback: {format % args}")
def do_GET(self):
"""Handle GET request from OAuth redirect"""
logger.info(f"OAuth callback received: {self.path}")
# Parse query parameters
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
# Ignore favicon and other non-OAuth requests
if parsed.path == '/favicon.ico':
self.send_response(404)
self.end_headers()
return
if 'code' in params:
service._auth_code = params['code'][0]
service._auth_state = params.get('state', [None])[0]
logger.info(f"OAuth authorization code received: {service._auth_code[:10]}...")
# Send success response
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = """
<html>
<head><title>Authorization Successful</title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1>Authorization Successful!</h1>
<p>You can close this window and return to Jackify.</p>
<script>setTimeout(function() { window.close(); }, 3000);</script>
</body>
</html>
"""
self.wfile.write(html.encode())
elif 'error' in params:
service._auth_error = params['error'][0]
error_desc = params.get('error_description', ['Unknown error'])[0]
# Send error response
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = f"""
<html>
<head><title>Authorization Failed</title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1>Authorization Failed</h1>
<p>Error: {service._auth_error}</p>
<p>{error_desc}</p>
<p>You can close this window and try again in Jackify.</p>
</body>
</html>
"""
self.wfile.write(html.encode())
else:
# Unexpected callback format
logger.warning(f"OAuth callback with no code or error: {params}")
self.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
html = """
<html>
<head><title>Invalid Request</title></head>
<body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
<h1>Invalid OAuth Callback</h1>
<p>You can close this window.</p>
</body>
</html>
"""
self.wfile.write(html.encode())
# Signal server to shut down
service._server_done.set()
logger.debug("OAuth callback handler signaled server to shut down")
return OAuthCallbackHandler
def _wait_for_callback(self) -> bool:
"""
Wait for OAuth callback via jackify:// protocol handler
Returns:
True if callback received, False on timeout
"""
from pathlib import Path
import time
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
# Delete any old callback file
if callback_file.exists():
callback_file.unlink()
logger.info("Waiting for OAuth callback via jackify:// protocol")
# Poll for callback file with periodic user feedback
start_time = time.time()
last_reminder = 0
while (time.time() - start_time) < self.CALLBACK_TIMEOUT:
if callback_file.exists():
try:
# Read callback data
lines = callback_file.read_text().strip().split('\n')
if len(lines) >= 2:
self._auth_code = lines[0]
self._auth_state = lines[1]
logger.info(f"OAuth callback received: code={self._auth_code[:10]}...")
# Clean up
callback_file.unlink()
return True
except Exception as e:
logger.error(f"Failed to read callback file: {e}")
return False
# Show periodic reminder about protocol handler
elapsed = time.time() - start_time
if elapsed - last_reminder > 30: # Every 30 seconds
logger.info(f"Still waiting for OAuth callback... ({int(elapsed)}s elapsed)")
if elapsed > 60:
logger.warning(
"If you see a blank browser tab or popup blocker, "
"check for browser notifications asking to 'Open Jackify'"
)
last_reminder = elapsed
time.sleep(0.5) # Poll every 500ms
logger.error(f"OAuth callback timeout after {self.CALLBACK_TIMEOUT} seconds")
logger.error(
"Protocol handler may not be working. Check:\n"
" 1. Browser asked 'Open Jackify?' and you clicked Allow\n"
" 2. No popup blocker notifications\n"
" 3. Desktop file exists: ~/.local/share/applications/com.jackify.app.desktop"
)
return False
def _send_desktop_notification(self, title: str, message: str):
"""
Send desktop notification if available
Args:
title: Notification title
message: Notification message
"""
def _send_desktop_notification(self, title: str, message: str) -> None:
"""Send a desktop notification via notify-send (Linux). No-op on failure."""
try:
# Try notify-send (Linux)
subprocess.run(
['notify-send', title, message],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2
["notify-send", title, message],
capture_output=True,
timeout=5,
env={k: v for k, v in os.environ.items() if k not in ("LD_LIBRARY_PATH", "PYTHONPATH", "QT_PLUGIN_PATH")},
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
logger.debug("Desktop notification skipped: %s", e)
except Exception as e:
logger.debug("Desktop notification failed: %s", e)
def _exchange_code_for_token(
self,
@@ -742,32 +326,35 @@ Path={src_dir}
f"Please open this URL manually:\n{auth_url}"
)
# Wait for callback via jackify:// protocol
if not self._wait_for_callback():
return None
try:
# Wait for callback via jackify:// protocol
if not self._wait_for_callback():
return None
# Check for errors
if self._auth_error:
logger.error(f"Authorization failed: {self._auth_error}")
return None
# Check for errors
if self._auth_error:
logger.error(f"Authorization failed: {self._auth_error}")
return None
if not self._auth_code:
logger.error("No authorization code received")
return None
if not self._auth_code:
logger.error("No authorization code received")
return None
# Verify state matches
if self._auth_state != state:
logger.error("State mismatch - possible CSRF attack")
return None
# Verify state matches
if self._auth_state != state:
logger.error("State mismatch - possible CSRF attack")
return None
logger.info("Authorization code received, exchanging for token")
logger.info("Authorization code received, exchanging for token")
# Exchange code for token
token_data = self._exchange_code_for_token(self._auth_code, code_verifier)
# Exchange code for token
token_data = self._exchange_code_for_token(self._auth_code, code_verifier)
if token_data:
logger.info("OAuth authorization flow completed successfully")
else:
logger.error("Failed to exchange authorization code for token")
if token_data:
logger.info("OAuth authorization flow completed successfully")
else:
logger.error("Failed to exchange authorization code for token")
return token_data
return token_data
finally:
self._expected_oauth_state = None

View File

@@ -133,7 +133,7 @@ class ProtontricksDetectionService:
return False, error_msg
# Install command - use --user flag for user-level installation (works on Steam Deck)
# This avoids requiring system-wide installation permissions
# Avoids system-wide installation permissions
install_cmd = ["flatpak", "install", "--user", "-y", "--noninteractive", "flathub", "com.github.Matoking.protontricks"]
# Use clean environment
@@ -186,7 +186,7 @@ class ProtontricksDetectionService:
elif "network" in stderr_msg.lower() or "connection" in stderr_msg.lower():
error_msg = f"Network error during installation. Check your internet connection.\n\nDetails: {stderr_msg}"
elif "already installed" in stderr_msg.lower():
# This might actually be success - clear cache and re-detect
# Might be success -- clear cache and re-detect
logger.info("Protontricks appears to already be installed (according to flatpak output)")
self._cached_detection_valid = False
return True, "Protontricks is already installed."

View File

@@ -11,7 +11,7 @@ from typing import Callable, Optional
logger = logging.getLogger(__name__)
STRATEGY_JACKIFY = "jackify"
STRATEGY_NAK_SIMPLE = "nak_simple"
STRATEGY_SIMPLE = "simple"
def _get_restart_strategy() -> str:
@@ -20,7 +20,9 @@ def _get_restart_strategy() -> str:
from jackify.backend.handlers.config_handler import ConfigHandler
strategy = ConfigHandler().get("steam_restart_strategy", STRATEGY_JACKIFY)
if strategy not in (STRATEGY_JACKIFY, STRATEGY_NAK_SIMPLE):
if strategy == "nak_simple":
strategy = STRATEGY_SIMPLE
if strategy not in (STRATEGY_JACKIFY, STRATEGY_SIMPLE):
return STRATEGY_JACKIFY
return strategy
except Exception as exc: # pragma: no cover - defensive logging only
@@ -29,8 +31,8 @@ def _get_restart_strategy() -> str:
def _strategy_label(strategy: str) -> str:
if strategy == STRATEGY_NAK_SIMPLE:
return "NaK simple restart"
if strategy == STRATEGY_SIMPLE:
return "Simple restart"
return "Jackify hardened restart"
def _get_clean_subprocess_env():
@@ -137,31 +139,80 @@ def is_steam_deck() -> bool:
logger.debug(f"Error detecting Steam Deck: {e}")
return False
def is_flatpak_steam() -> bool:
"""Detect if Steam is installed as a Flatpak."""
def steam_path_indicates_flatpak(steam_path) -> bool:
"""True if this Steam path is under the Flatpak Steam app dir (user is running Flatpak Steam)."""
if steam_path is None:
return False
path_str = os.fspath(steam_path)
return ".var" in path_str and "app" in path_str and "com.valvesoftware.Steam" in path_str
def _flatpak_steam_data_path_exists() -> bool:
"""True if the Flatpak Steam data directory exists (fallback when resolved_path is None, e.g. AppImage)."""
try:
# First check if flatpak command exists
if not shutil.which('flatpak'):
from pathlib import Path
base = Path.home() / ".var" / "app" / "com.valvesoftware.Steam"
for rel in ("data/Steam", ".local/share/Steam", "home/.local/share/Steam"):
candidate = base / rel
if (candidate / "config" / "loginusers.vdf").exists():
return True
return False
except Exception as e:
logger.debug("Flatpak Steam path check failed: %s", e)
return False
def _get_flatpak_command():
"""Resolve flatpak executable (for detection when PATH is minimal, e.g. AppImage)."""
exe = shutil.which("flatpak")
if exe:
return exe
for p in ("/usr/bin/flatpak", "/usr/local/bin/flatpak"):
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return None
def is_flatpak_steam() -> bool:
"""Detect if Steam is installed as a Flatpak. Uses flatpak CLI only (no dir heuristic)
so we don't wrongly choose Flatpak when the user has both Flatpak and native Steam."""
try:
flatpak_cmd = _get_flatpak_command()
if not flatpak_cmd:
return False
# Verify the app is actually installed (not just directory exists)
result = subprocess.run(['flatpak', 'list', '--app'],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, # Suppress stderr to avoid error messages
text=True,
timeout=5)
env = _get_clean_subprocess_env()
result = subprocess.run(
[flatpak_cmd, "list", "--app"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
timeout=10,
env=env,
)
if result.returncode == 0:
# Check for exact match - "com.valvesoftware.Steam" as a whole word
# This prevents matching "com.valvesoftware.SteamLink" or similar
for line in result.stdout.splitlines():
parts = line.split()
if parts and parts[0] == 'com.valvesoftware.Steam':
if parts and parts[0] == "com.valvesoftware.Steam":
return True
return False
except Exception as e:
logger.debug(f"Error detecting Flatpak Steam: {e}")
return False
def _get_steam_executable(env=None):
"""Resolve steam executable path for native Steam. Prefer PATH, then common locations."""
env = env or os.environ
path_env = env.get("PATH", "")
exe = shutil.which("steam", path=path_env)
if exe:
return exe
for candidate in ("/usr/games/steam", "/usr/bin/steam"):
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return "steam"
def get_steam_processes() -> list:
"""Return a list of psutil.Process objects for running Steam processes."""
steam_procs = []
@@ -194,53 +245,46 @@ def wait_for_steam_exit(timeout: int = 60, check_interval: float = 0.5) -> bool:
time.sleep(check_interval)
return False
def _start_steam_nak_style(is_steamdeck_flag=False, is_flatpak_flag=False, env_override=None) -> bool:
def _start_steam_simple(is_steamdeck_flag=False, is_flatpak_flag=False, env_override=None) -> bool:
"""
Start Steam using a simplified NaK-style restart (single command, no env cleanup).
CRITICAL: Do NOT use start_new_session - Steam needs to inherit the session
to connect to display/tray. Ensure all GUI environment variables are preserved.
Start Steam using a simplified restart (single command, no env cleanup).
Do NOT use start_new_session - Steam needs to inherit the session for display/tray.
"""
env = env_override if env_override is not None else os.environ.copy()
# Log critical GUI variables for debugging
gui_vars = ['DISPLAY', 'WAYLAND_DISPLAY', 'XDG_SESSION_TYPE', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR']
for var in gui_vars:
if var in env:
logger.debug(f"NaK-style restart: {var}={env[var][:50] if len(str(env[var])) > 50 else env[var]}")
logger.debug(f"Simple restart: {var}={env[var][:50] if len(str(env[var])) > 50 else env[var]}")
else:
logger.warning(f"NaK-style restart: {var} is NOT SET - Steam GUI may fail!")
logger.warning(f"Simple restart: {var} is NOT SET - Steam GUI may fail!")
try:
if is_steamdeck_flag:
logger.info("NaK-style restart: Steam Deck detected, restarting via systemctl.")
logger.info("Simple restart: Steam Deck detected, restarting via systemctl.")
subprocess.Popen(["systemctl", "--user", "restart", "app-steam@autostart.service"], env=env)
elif is_flatpak_flag:
logger.info("NaK-style restart: Flatpak Steam detected, running flatpak command.")
subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam"],
env=env, stderr=subprocess.DEVNULL)
logger.info("Simple restart: Flatpak Steam detected, running flatpak command.")
flatpak_cmd = _get_flatpak_command() or "flatpak"
subprocess.Popen([flatpak_cmd, "run", "com.valvesoftware.Steam"],
env=env, stderr=subprocess.DEVNULL)
else:
logger.info("NaK-style restart: launching Steam directly (inheriting session for GUI).")
# NaK uses simple "steam" command without -foreground flag
# Do NOT use start_new_session - Steam needs session access for GUI
# Use shell=True to ensure proper environment inheritance
# This helps with GUI display access on some systems
logger.info("Simple restart: launching Steam directly (inheriting session for GUI).")
subprocess.Popen("steam", shell=True, env=env)
time.sleep(5)
# Use steamwebhelper for detection (actual Steam process, not steam-powerbuttond)
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=env)
if check_result.returncode == 0:
logger.info("NaK-style restart detected running Steam process.")
logger.info("Simple restart detected running Steam process.")
return True
logger.warning("NaK-style restart did not detect Steam process after launch.")
logger.warning("Simple restart did not detect Steam process after launch.")
return False
except FileNotFoundError as exc:
logger.error(f"NaK-style restart command not found: {exc}")
logger.error(f"Simple restart command not found: {exc}")
return False
except Exception as exc:
logger.error(f"NaK-style restart encountered an error: {exc}")
logger.error(f"Simple restart encountered an error: {exc}")
return False
@@ -254,8 +298,8 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
env_override: Optional environment dictionary for subprocess calls
strategy: Restart strategy identifier
"""
if strategy == STRATEGY_NAK_SIMPLE:
return _start_steam_nak_style(
if strategy == STRATEGY_SIMPLE:
return _start_steam_simple(
is_steamdeck_flag=is_steamdeck_flag,
is_flatpak_flag=is_flatpak_flag,
env_override=env_override or os.environ.copy(),
@@ -284,10 +328,10 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
if _is_flatpak:
logger.info("Flatpak Steam detected - trying flatpak run command first")
try:
# Try without flags first (most reliable for Ubuntu/PopOS)
logger.debug("Executing: flatpak run com.valvesoftware.Steam")
subprocess.Popen(["flatpak", "run", "com.valvesoftware.Steam"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
flatpak_cmd = _get_flatpak_command() or "flatpak"
logger.debug("Executing: %s run com.valvesoftware.Steam", flatpak_cmd)
subprocess.Popen([flatpak_cmd, "run", "com.valvesoftware.Steam"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(7) # Give Flatpak more time to start
# For Flatpak Steam, check for the flatpak process, not steamwebhelper
check_result = subprocess.run(['pgrep', '-f', 'com.valvesoftware.Steam'], capture_output=True, timeout=10, env=env)
@@ -301,11 +345,11 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
logger.error(f"Flatpak Steam start failed: {e}")
return False # Flatpak Steam must use flatpak command, don't fall back
# Use startup methods with -foreground flag to ensure GUI opens
steam_exe = _get_steam_executable(env)
start_methods = [
{"name": "Popen", "cmd": ["steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}},
{"name": "setsid", "cmd": ["setsid", "steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "env": env}},
{"name": "nohup", "cmd": ["nohup", "steam", "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "preexec_fn": os.setpgrp, "env": env}}
{"name": "Popen", "cmd": [steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}},
{"name": "setsid", "cmd": ["setsid", steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "env": env}},
{"name": "nohup", "cmd": ["nohup", steam_exe, "-foreground"], "kwargs": {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL, "stdin": subprocess.DEVNULL, "start_new_session": True, "env": env}}
]
for method in start_methods:
@@ -335,6 +379,106 @@ def start_steam(is_steamdeck_flag=None, is_flatpak_flag=None, env_override=None,
logger.error(f"Error starting Steam: {e}")
return False
def _resolve_steam_path_for_restart():
"""Return the Steam path we're using (for shortcuts/config). Used to decide Flatpak vs native when CLI detection fails."""
try:
from jackify.backend.services.native_steam_service import NativeSteamService
svc = NativeSteamService()
if svc.find_steam_user() and svc.steam_path:
return svc.steam_path
except Exception as e:
logger.debug("Could not resolve Steam path for restart: %s", e)
return None
def shutdown_steam(progress_callback: Optional[Callable[[str], None]] = None, system_info=None) -> bool:
"""
Shut down Steam completely across all distros.
Required before modifying VDF files to prevent race conditions.
Args:
progress_callback: Optional callback for progress updates
system_info: Optional SystemInfo object with pre-detected Steam installation types
Returns:
True if shutdown successful, False otherwise
"""
shutdown_env = _get_clean_subprocess_env()
_is_steam_deck = system_info.is_steamdeck if system_info else is_steam_deck()
resolved_path = _resolve_steam_path_for_restart()
if resolved_path is not None:
_is_flatpak = steam_path_indicates_flatpak(resolved_path)
logger.info("Steam path in use: %s -> flatpak=%s", resolved_path, _is_flatpak)
else:
_is_flatpak = _flatpak_steam_data_path_exists()
if _is_flatpak:
logger.info("Steam path in use: (flatpak data path detected) -> flatpak=True")
else:
_is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam()
def report(msg):
if progress_callback:
progress_callback(msg)
else:
logger.info(msg)
report("Shutting down Steam...")
# Steam Deck: Use systemctl for shutdown
if _is_steam_deck:
try:
report("Steam Deck detected - using systemctl shutdown...")
subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
except Exception as e:
logger.debug(f"systemctl stop failed on Steam Deck: {e}")
# Flatpak Steam: Use flatpak kill command
elif _is_flatpak:
try:
report("Flatpak Steam detected - stopping via flatpak...")
flatpak_cmd = _get_flatpak_command() or "flatpak"
subprocess.run([flatpak_cmd, "kill", "com.valvesoftware.Steam"],
timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env)
time.sleep(2)
except Exception as e:
logger.debug(f"flatpak kill failed: {e}")
# All systems: Use pkill approach
try:
pkill_result = subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
logger.debug(f"pkill steam result: {pkill_result.returncode}")
time.sleep(2)
# Check if Steam is still running
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env)
if check_result.returncode == 0:
# Force kill if still running
report("Steam still running - force terminating...")
force_result = subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
logger.debug(f"pkill -9 steam result: {force_result.returncode}")
time.sleep(2)
# Final check
final_check = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env)
if final_check.returncode != 0:
logger.info("Steam processes successfully force terminated.")
else:
logger.warning("Steam processes may still be running after termination attempts.")
report("Steam shutdown incomplete")
return False
else:
logger.info("Steam processes successfully terminated.")
except Exception as e:
logger.warning(f"Error during Steam shutdown: {e}")
report("Steam shutdown had issues")
return False
report("Steam shut down successfully")
return True
def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = None, timeout: int = 60, system_info=None) -> bool:
"""
Robustly restart Steam across all distros. Returns True on success, False on failure.
@@ -350,14 +494,24 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
strategy = _get_restart_strategy()
start_env = shutdown_env if strategy == STRATEGY_JACKIFY else os.environ.copy()
# Use cached detection from system_info if available, otherwise detect
_is_steam_deck = system_info.is_steamdeck if system_info else is_steam_deck()
_is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam()
resolved_path = _resolve_steam_path_for_restart()
if resolved_path is not None:
_is_flatpak = steam_path_indicates_flatpak(resolved_path)
logger.info("Steam path in use: %s -> flatpak=%s", resolved_path, _is_flatpak)
else:
_is_flatpak = _flatpak_steam_data_path_exists()
if _is_flatpak:
logger.info("Steam path in use: (flatpak data path detected) -> flatpak=True")
else:
_is_flatpak = system_info.is_flatpak_steam if system_info else is_flatpak_steam()
def report(msg):
logger.info(msg)
if progress_callback:
progress_callback(msg)
else:
# Only log directly if no callback (callback chain handles logging)
logger.info(msg)
report("Shutting down Steam...")
report(f"Steam restart strategy: {_strategy_label(strategy)}")
@@ -375,8 +529,9 @@ def robust_steam_restart(progress_callback: Optional[Callable[[str], None]] = No
elif _is_flatpak:
try:
report("Flatpak Steam detected - stopping via flatpak...")
subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'],
timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env)
flatpak_cmd = _get_flatpak_command() or "flatpak"
subprocess.run([flatpak_cmd, "kill", "com.valvesoftware.Steam"],
timeout=15, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=shutdown_env)
time.sleep(2)
except Exception as e:
logger.debug(f"flatpak kill failed: {e}")

View File

@@ -354,7 +354,7 @@ class UpdateService:
script_content = f'''#!/bin/bash
# Jackify Update Helper Script
# This script safely replaces the current AppImage with the new version
# Safely replaces current AppImage with new version
CURRENT_APPIMAGE="{current_appimage}"
NEW_APPIMAGE="{new_appimage}"

View File

@@ -271,7 +271,7 @@ class VNVPostInstallService:
if not patcher_path:
# Try to download from Nexus
# Note: The Linux version is named "FNV4GB for Proton", not "linux"
# Linux version is named "FNV4GB for Proton", not "linux"
success, patcher_path, msg = self.download_service.download_latest_file(
self.GAME_DOMAIN,
self.LINUX_4GB_PATCHER_MOD_ID,
@@ -410,11 +410,12 @@ class VNVPostInstallService:
logger.info(f"Using cached BSA Decompressor MPI: {mpi_path}")
else:
# Try to download from Nexus
# Look for files with .mpi extension (TTW installer format)
success, mpi_path, msg = self.download_service.download_latest_file(
self.GAME_DOMAIN,
self.FNV_BSA_DECOMPRESSOR_MOD_ID,
self.cache_dir,
file_name_filter="mpi",
file_name_filter=".mpi",
progress_callback=progress_callback
)

View File

@@ -0,0 +1,270 @@
"""
Wabbajack Installer Service
Backend service for orchestrating complete Wabbajack installation workflow.
Handles all 12 steps including Steam shortcuts, prefix creation, and configuration.
"""
import logging
import subprocess
import time
from pathlib import Path
from typing import Optional, Callable, Tuple
from ..handlers.wabbajack_installer_handler import WabbajackInstallerHandler
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
from .native_steam_service import NativeSteamService
from .steam_restart_service import (
start_steam, is_flatpak_steam, is_steam_deck, _get_clean_subprocess_env, robust_steam_restart
)
from .automated_prefix_service import AutomatedPrefixService
logger = logging.getLogger(__name__)
class WabbajackInstallerService:
"""Service for orchestrating Wabbajack installation workflow"""
def __init__(self):
self.handler = WabbajackInstallerHandler()
self.steam_service = NativeSteamService()
self.config_handler = ConfigHandler()
self.prefix_service = AutomatedPrefixService()
def _resolve_proton_path_and_name(self) -> Tuple[Optional[Path], Optional[str]]:
"""Resolve user's Install Proton path and Steam compat name. Fallback to Proton Experimental."""
user_path = self.config_handler.get_proton_path()
if user_path and user_path != 'auto':
path = Path(user_path).expanduser()
if path.is_dir():
compat_name = WineUtils.resolve_steam_compat_name(path)
if compat_name:
return path, compat_name
dir_name = path.name
if dir_name.startswith('GE-Proton'):
return path, dir_name
steam_name = dir_name.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
if not steam_name.startswith('proton'):
steam_name = f"proton_{steam_name}"
return path, steam_name
path = self.handler.find_proton_experimental()
return path, "proton_experimental" if path else None
def install_wabbajack(
self,
install_folder: Path,
shortcut_name: str = "Wabbajack",
enable_gog: bool = True,
progress_callback: Optional[Callable[[str, int], None]] = None,
log_callback: Optional[Callable[[str], None]] = None
) -> Tuple[bool, Optional[int], Optional[str], Optional[int], Optional[str], Optional[str]]:
"""
Execute complete Wabbajack installation workflow.
Args:
install_folder: Directory to install Wabbajack.exe
shortcut_name: Name for Steam shortcut
enable_gog: Whether to detect and inject GOG games
progress_callback: Optional callback(status, percentage)
log_callback: Optional callback for log messages
Returns:
Tuple of (success, app_id, launch_options, gog_count, time_taken_str, error_message)
"""
start_time = time.time()
total_steps = 12
app_id = None
launch_options = ""
gog_count = 0
def update_progress(message: str, step: int, percentage: int = None):
if progress_callback:
if percentage is None:
percentage = int((step / total_steps) * 100)
progress_callback(message, percentage)
if log_callback:
log_callback(message)
else:
# Only log directly if no callback (callback already logs)
logger.info(message)
# Detect Steam installation type once at the start for consistent use throughout
_is_steam_deck = is_steam_deck()
_is_flatpak = is_flatpak_steam()
try:
# Step 1: Check requirements
update_progress("Checking requirements...", 1, 5)
proton_path, proton_compat_name = self._resolve_proton_path_and_name()
if not proton_path:
return False, None, None, None, None, "Proton not found. Install a Proton version in Steam or set Install Proton in Settings."
update_progress(f"Using Proton: {proton_path.name}", 1, 5)
userdata = self.handler.find_steam_userdata_path()
if not userdata:
return False, None, None, None, None, "Steam userdata not found. Please ensure Steam is installed and you're logged in."
update_progress(f"Found Steam userdata: {userdata}", 1, 5)
# Step 2: Download Wabbajack.exe
update_progress("Downloading Wabbajack.exe...", 2, 15)
wabbajack_exe = self.handler.download_wabbajack(install_folder)
if not wabbajack_exe:
return False, None, None, None, None, "Failed to download Wabbajack.exe"
update_progress(f"Downloaded to: {wabbajack_exe}", 2, 15)
# Step 3: Create dotnet cache
update_progress("Creating .NET cache directory...", 3, 20)
self.handler.create_dotnet_cache(install_folder)
update_progress(".NET cache created", 3, 20)
# Step 4: Stop Steam briefly (required to safely modify shortcuts.vdf)
# We'll do a full restart after creating the shortcut
update_progress("Stopping Steam to modify shortcuts...", 4, 25)
try:
shutdown_env = _get_clean_subprocess_env()
if _is_steam_deck:
subprocess.run(['systemctl', '--user', 'stop', 'app-steam@autostart.service'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
elif _is_flatpak:
subprocess.run(['flatpak', 'kill', 'com.valvesoftware.Steam'],
timeout=15, check=False, capture_output=True, env=shutdown_env)
subprocess.run(['pkill', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10, env=shutdown_env)
if check_result.returncode == 0:
subprocess.run(['pkill', '-9', 'steam'], timeout=15, check=False, capture_output=True, env=shutdown_env)
time.sleep(2)
update_progress("Steam stopped", 4, 25)
except Exception as e:
update_progress(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...", 4, 25)
# Step 5: Create Steam shortcut using NativeSteamService
update_progress("Adding Wabbajack to Steam shortcuts...", 5, 30)
# Generate launch options with STEAM_COMPAT_MOUNTS
launch_options = ""
try:
from ..handlers.path_handler import PathHandler
path_handler = PathHandler()
mount_paths = path_handler.get_steam_compat_mount_paths(install_dir=str(install_folder))
if mount_paths:
launch_options = f'STEAM_COMPAT_MOUNTS="{":".join(mount_paths)}" %command%'
update_progress(f"Added STEAM_COMPAT_MOUNTS for Steam libraries: {mount_paths}", 5, 30)
else:
update_progress("No additional Steam libraries found - using empty launch options", 5, 30)
except Exception as e:
update_progress(f"Could not generate STEAM_COMPAT_MOUNTS (non-critical): {e}", 5, 30)
success, app_id = self.steam_service.create_shortcut_with_proton(
app_name=shortcut_name,
exe_path=str(wabbajack_exe),
start_dir=str(wabbajack_exe.parent),
launch_options=launch_options,
tags=["Jackify"],
proton_version=proton_compat_name
)
if not success or app_id is None:
return False, None, None, None, None, "Failed to create Steam shortcut"
update_progress(f"Created Steam shortcut with AppID: {app_id}", 5, 30)
# Step 5b: Restart Steam (same pattern as modlist workflows)
update_progress("Restarting Steam...", 5, 35)
def restart_callback(msg):
update_progress(msg, 5, 35)
if not robust_steam_restart(progress_callback=restart_callback):
update_progress("Warning: Steam restart had issues, continuing anyway...", 5, 35)
else:
update_progress("Steam restarted successfully", 5, 40)
# Step 6: Initialize Wine prefix (using same method as modlist workflows)
update_progress("Creating Proton prefix...", 6, 45)
try:
if self.prefix_service.create_prefix_with_proton_wrapper(app_id):
prefix_path = self.prefix_service.get_prefix_path(app_id)
update_progress(f"Proton prefix created: {prefix_path}", 6, 45)
else:
update_progress("Warning: Prefix creation returned False, continuing anyway...", 6, 45)
except Exception as e:
update_progress(f"Warning: Failed to create prefix: {e}", 6, 45)
update_progress("Continuing anyway...", 6, 45)
# Step 7: Install WebView2
update_progress("Installing WebView2 runtime...", 7, 60)
try:
self.handler.install_webview2(app_id, install_folder, proton_path=proton_path)
update_progress("WebView2 installed successfully", 7, 60)
except Exception as e:
update_progress(f"WARNING: WebView2 installation may have failed: {e}", 7, 60)
update_progress("This may prevent Nexus login in Wabbajack. You can manually install WebView2 later.", 7, 60)
# Step 8: Apply Win7 registry
update_progress("Applying Windows 7 registry settings...", 8, 75)
try:
self.handler.apply_win7_registry(app_id, proton_path=proton_path)
update_progress("Registry settings applied", 8, 75)
except Exception as e:
update_progress(f"Warning: Failed to apply registry settings: {e}", 8, 75)
update_progress("Continuing anyway...", 8, 75)
# Step 9: GOG game detection (optional)
if enable_gog:
update_progress("Detecting GOG games from Heroic...", 9, 80)
try:
gog_count = self.handler.inject_gog_registry(app_id)
if gog_count > 0:
update_progress(f"Detected and injected {gog_count} GOG games", 9, 80)
else:
update_progress("No GOG games found in Heroic", 9, 80)
except Exception as e:
update_progress(f"GOG injection failed (non-critical): {e}", 9, 80)
else:
update_progress("Skipping GOG game detection", 9, 80)
# Step 10: Create Steam library symlinks
update_progress("Creating Steam library symlinks...", 10, 85)
try:
self.steam_service.create_steam_library_symlinks(app_id)
update_progress("Steam library symlinks created", 10, 85)
except Exception as e:
update_progress(f"Warning: Failed to create symlinks: {e}", 10, 85)
# Step 11: Verify Proton compatibility (was set at shortcut creation)
update_progress(f"Proton version: {proton_compat_name}", 11, 90)
# Step 12: Verify Steam is running (was restarted after shortcut creation)
update_progress("Verifying Steam is running...", 12, 95)
check_result = subprocess.run(['pgrep', '-f', 'steamwebhelper'], capture_output=True, timeout=10)
if check_result.returncode == 0:
update_progress("Steam is running", 12, 95)
else:
update_progress("Starting Steam...", 12, 95)
if start_steam(is_steamdeck_flag=_is_steam_deck, is_flatpak_flag=_is_flatpak):
update_progress("Steam started successfully", 12, 95)
time.sleep(3)
else:
update_progress("Warning: Please start Steam manually", 12, 95)
# Calculate time taken
time_taken = int(time.time() - start_time)
mins, secs = divmod(time_taken, 60)
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
update_progress("Installation complete!", 12, 100)
update_progress(f"Wabbajack installed to: {install_folder}", 12, 100)
update_progress(f"Steam AppID: {app_id}", 12, 100)
return True, app_id, launch_options, gog_count, time_str, None
except Exception as e:
error_msg = f"Installation failed: {str(e)}"
logger.error(error_msg, exc_info=True)
if log_callback:
log_callback(f"ERROR: {error_msg}")
return False, None, None, None, None, error_msg