mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:07:45 +02:00
Sync from development - prepare for v0.3.0
This commit is contained in:
500
jackify/backend/services/automated_prefix_creation.py
Normal file
500
jackify/backend/services/automated_prefix_creation.py
Normal 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
|
||||
|
||||
272
jackify/backend/services/automated_prefix_game_utils.py
Normal file
272
jackify/backend/services/automated_prefix_game_utils.py
Normal 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}")
|
||||
|
||||
673
jackify/backend/services/automated_prefix_proton.py
Normal file
673
jackify/backend/services/automated_prefix_proton.py
Normal 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
|
||||
|
||||
276
jackify/backend/services/automated_prefix_registry.py
Normal file
276
jackify/backend/services/automated_prefix_registry.py
Normal 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
534
jackify/backend/services/automated_prefix_shortcuts.py
Normal file
534
jackify/backend/services/automated_prefix_shortcuts.py
Normal 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
|
||||
|
||||
138
jackify/backend/services/automated_prefix_shortcuts_cleanup.py
Normal file
138
jackify/backend/services/automated_prefix_shortcuts_cleanup.py
Normal 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
|
||||
190
jackify/backend/services/automated_prefix_stl.py
Normal file
190
jackify/backend/services/automated_prefix_stl.py
Normal 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
|
||||
|
||||
556
jackify/backend/services/automated_prefix_workflow.py
Normal file
556
jackify/backend/services/automated_prefix_workflow.py
Normal 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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
237
jackify/backend/services/modlist_service_installation.py
Normal file
237
jackify/backend/services/modlist_service_installation.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
147
jackify/backend/services/nexus_oauth_callback.py
Normal file
147
jackify/backend/services/nexus_oauth_callback.py
Normal 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
|
||||
127
jackify/backend/services/nexus_oauth_protocol.py
Normal file
127
jackify/backend/services/nexus_oauth_protocol.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
270
jackify/backend/services/wabbajack_installer_service.py
Normal file
270
jackify/backend/services/wabbajack_installer_service.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user