mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 11:37:01 +01:00
Sync from development - prepare for v0.1.5
This commit is contained in:
34
CHANGELOG.md
34
CHANGELOG.md
@@ -1,5 +1,39 @@
|
|||||||
# Jackify Changelog
|
# Jackify Changelog
|
||||||
|
|
||||||
|
## v0.1.5 - Winetricks Integration & Enhanced Compatibility
|
||||||
|
**Release Date:** September 26, 2025
|
||||||
|
|
||||||
|
### Major Improvements
|
||||||
|
- **Winetricks Integration**: Replaced protontricks with bundled winetricks for faster, more reliable wine component installation
|
||||||
|
- **Enhanced SD Card Detection**: Dynamic detection of SD card mount points supports both `/run/media/mmcblk0p1` and `/run/media/deck/UUID` patterns
|
||||||
|
- **Smart Proton Detection**: Comprehensive GE-Proton support with detection in both steamapps/common and compatibilitytools.d directories
|
||||||
|
- **Steam Deck SD Card Support**: Fixed path handling for SD card installations on Steam Deck
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- **No Focus Stealing**: Wine component installation runs in background without disrupting user workflow
|
||||||
|
- **Popup Suppression**: Eliminated wine GUI popups while maintaining functionality
|
||||||
|
- **GUI Navigation**: Fixed navigation issues after Tuxborn workflow removal
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- **CLI Configure Existing**: Fixed AppID detection with signed-to-unsigned conversion, removing protontricks dependency
|
||||||
|
- **GE-Proton Validation**: Fixed validation to support both Valve Proton and GE-Proton directory structures
|
||||||
|
- **Resolution Override**: Eliminated hardcoded 2560x1600 fallbacks that overrode user Steam Deck settings
|
||||||
|
- **VDF Case-Sensitivity**: Added case-insensitive parsing for Steam shortcuts fields
|
||||||
|
- **Cabextract Bundling**: Bundled cabextract binary to resolve winetricks dependency issues
|
||||||
|
- **ModOrganizer.ini Path Format**: Fixed missing backslash in gamePath format for proper Windows path structure
|
||||||
|
- **SD Card Binary Paths**: Corrected binary paths to use D: drive mapping instead of raw Linux paths for SD card installs
|
||||||
|
- **Proton Fallback Logic**: Enhanced fallback when user-selected Proton version is missing or invalid
|
||||||
|
- **Settings Persistence**: Improved configuration saving with verification and logging
|
||||||
|
- **System Wine Elimination**: Comprehensive audit ensures Jackify never uses system wine installations
|
||||||
|
- **Winetricks Reliability**: Fixed vcrun2022 installation failures and wine app crashes
|
||||||
|
- **Enderal Registry Injection**: Switched from launch options to registry injection approach
|
||||||
|
- **Proton Path Detection**: Uses actual Steam libraries from libraryfolders.vdf instead of hardcoded paths
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
- **Self-contained Cache**: Relocated winetricks cache to jackify_data_dir for better isolation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.1.4 - GE-Proton Support and Performance Optimization
|
## v0.1.4 - GE-Proton Support and Performance Optimization
|
||||||
**Release Date:** September 22, 2025
|
**Release Date:** September 22, 2025
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
|||||||
Wabbajack modlists natively on Linux systems.
|
Wabbajack modlists natively on Linux systems.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.1.5"
|
||||||
|
|||||||
@@ -730,6 +730,14 @@ class ModlistInstallCLI:
|
|||||||
cmd += ['-m', self.context['machineid']]
|
cmd += ['-m', self.context['machineid']]
|
||||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||||
|
|
||||||
|
# Add debug flag if debug mode is enabled
|
||||||
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||||
|
config_handler = ConfigHandler()
|
||||||
|
debug_mode = config_handler.get('debug_mode', False)
|
||||||
|
if debug_mode:
|
||||||
|
cmd.append('--debug')
|
||||||
|
self.logger.info("Adding --debug flag to jackify-engine")
|
||||||
|
|
||||||
# Store original environment values to restore later
|
# Store original environment values to restore later
|
||||||
original_env_values = {
|
original_env_values = {
|
||||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ class ModlistHandler:
|
|||||||
|
|
||||||
# Initialize Handlers (should happen regardless of how paths were provided)
|
# Initialize Handlers (should happen regardless of how paths were provided)
|
||||||
self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck, logger=self.logger)
|
self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck, logger=self.logger)
|
||||||
|
# Initialize winetricks handler for wine component installation
|
||||||
|
from .winetricks_handler import WinetricksHandler
|
||||||
|
self.winetricks_handler = WinetricksHandler(logger=self.logger)
|
||||||
self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=self.verbose)
|
self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=self.verbose)
|
||||||
self.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler()
|
self.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler()
|
||||||
self.resolution_handler = ResolutionHandler()
|
self.resolution_handler = ResolutionHandler()
|
||||||
@@ -224,44 +227,41 @@ class ModlistHandler:
|
|||||||
discovered_modlists_info = []
|
discovered_modlists_info = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 1. Get ALL non-Steam shortcuts from Protontricks
|
# Get shortcuts pointing to the executable from shortcuts.vdf
|
||||||
# Now calls the renamed method without filtering
|
|
||||||
protontricks_shortcuts = self.protontricks_handler.list_non_steam_shortcuts()
|
|
||||||
if not protontricks_shortcuts:
|
|
||||||
self.logger.warning("Protontricks did not list any non-Steam shortcuts.")
|
|
||||||
return []
|
|
||||||
self.logger.debug(f"Protontricks non-Steam shortcuts found: {protontricks_shortcuts}")
|
|
||||||
|
|
||||||
# 2. Get shortcuts pointing to the executable from shortcuts.vdf
|
|
||||||
matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name)
|
matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name)
|
||||||
if not matching_vdf_shortcuts:
|
if not matching_vdf_shortcuts:
|
||||||
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.")
|
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.")
|
||||||
return []
|
return []
|
||||||
self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}")
|
self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}")
|
||||||
|
|
||||||
# 3. Correlate the two lists and extract required info
|
# Process each matching shortcut and convert signed AppID to unsigned
|
||||||
for vdf_shortcut in matching_vdf_shortcuts:
|
for vdf_shortcut in matching_vdf_shortcuts:
|
||||||
app_name = vdf_shortcut.get('AppName')
|
app_name = vdf_shortcut.get('AppName')
|
||||||
start_dir = vdf_shortcut.get('StartDir')
|
start_dir = vdf_shortcut.get('StartDir')
|
||||||
|
signed_appid = vdf_shortcut.get('appid')
|
||||||
|
|
||||||
if not app_name or not start_dir:
|
if not app_name or not start_dir:
|
||||||
self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}")
|
self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if app_name in protontricks_shortcuts:
|
if signed_appid is None:
|
||||||
app_id = protontricks_shortcuts[app_name]
|
self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}")
|
||||||
|
continue
|
||||||
|
|
||||||
# Append dictionary with all necessary info
|
# Convert signed AppID to unsigned AppID (the format used by Steam prefixes)
|
||||||
|
if signed_appid < 0:
|
||||||
|
unsigned_appid = signed_appid + (2**32)
|
||||||
|
else:
|
||||||
|
unsigned_appid = signed_appid
|
||||||
|
|
||||||
|
# Append dictionary with all necessary info using unsigned AppID
|
||||||
modlist_info = {
|
modlist_info = {
|
||||||
'name': app_name,
|
'name': app_name,
|
||||||
'appid': app_id,
|
'appid': unsigned_appid,
|
||||||
'path': start_dir
|
'path': start_dir
|
||||||
}
|
}
|
||||||
discovered_modlists_info.append(modlist_info)
|
discovered_modlists_info.append(modlist_info)
|
||||||
self.logger.info(f"Validated shortcut: '{app_name}' (AppID: {app_id}, Path: {start_dir})")
|
self.logger.info(f"Discovered shortcut: '{app_name}' (Signed: {signed_appid} → Unsigned: {unsigned_appid}, Path: {start_dir})")
|
||||||
else:
|
|
||||||
# Downgraded from WARNING to INFO
|
|
||||||
self.logger.info(f"Shortcut '{app_name}' found in VDF but not listed by protontricks. Skipping.")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True)
|
self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True)
|
||||||
@@ -685,7 +685,14 @@ class ModlistHandler:
|
|||||||
# All modlists now use their own AppID for wine components
|
# All modlists now use their own AppID for wine components
|
||||||
target_appid = self.appid
|
target_appid = self.appid
|
||||||
|
|
||||||
if not self.protontricks_handler.install_wine_components(target_appid, self.game_var_full, specific_components=components):
|
# Use winetricks for wine component installation (faster than protontricks)
|
||||||
|
wineprefix = self.protontricks_handler.get_wine_prefix_path(target_appid)
|
||||||
|
if not wineprefix:
|
||||||
|
self.logger.error("Failed to get WINEPREFIX path for winetricks.")
|
||||||
|
print("Error: Could not determine wine prefix location.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components):
|
||||||
self.logger.error("Failed to install Wine components. Configuration aborted.")
|
self.logger.error("Failed to install Wine components. Configuration aborted.")
|
||||||
print("Error: Failed to install necessary Wine components.")
|
print("Error: Failed to install necessary Wine components.")
|
||||||
return False # Abort on failure
|
return False # Abort on failure
|
||||||
@@ -760,7 +767,8 @@ class ModlistHandler:
|
|||||||
|
|
||||||
# Conditionally update binary and working directory paths
|
# Conditionally update binary and working directory paths
|
||||||
# Skip for jackify-engine workflows since paths are already correct
|
# Skip for jackify-engine workflows since paths are already correct
|
||||||
if not getattr(self, 'engine_installed', False):
|
# Exception: Always run for SD card installs to fix Z:/run/media/... to D:/... paths
|
||||||
|
if not getattr(self, 'engine_installed', False) or self.modlist_sdcard:
|
||||||
# Convert steamapps/common path to library root path
|
# Convert steamapps/common path to library root path
|
||||||
steam_libraries = None
|
steam_libraries = None
|
||||||
if self.steam_library:
|
if self.steam_library:
|
||||||
@@ -863,7 +871,7 @@ class ModlistHandler:
|
|||||||
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
||||||
if prefix_path_str:
|
if prefix_path_str:
|
||||||
prefix_path = Path(prefix_path_str)
|
prefix_path = Path(prefix_path_str)
|
||||||
fonts_dir = prefix_path / "drive_c" / "windows" / "Fonts"
|
fonts_dir = prefix_path / "pfx" / "drive_c" / "windows" / "Fonts"
|
||||||
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
|
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
|
||||||
font_dest_path = fonts_dir / "seguisym.ttf"
|
font_dest_path = fonts_dir / "seguisym.ttf"
|
||||||
|
|
||||||
|
|||||||
@@ -32,14 +32,21 @@ class PathHandler:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
|
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
|
||||||
"""
|
"""
|
||||||
Removes the '/run/media/mmcblk0p1/' prefix if present.
|
Removes any detected SD card mount prefix dynamically.
|
||||||
|
Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns.
|
||||||
Returns the path as a POSIX-style string (using /).
|
Returns the path as a POSIX-style string (using /).
|
||||||
"""
|
"""
|
||||||
|
from .wine_utils import WineUtils
|
||||||
|
|
||||||
path_str = path_obj.as_posix() # Work with consistent forward slashes
|
path_str = path_obj.as_posix() # Work with consistent forward slashes
|
||||||
if path_str.lower().startswith(SDCARD_PREFIX.lower()):
|
|
||||||
# Return the part *after* the prefix, ensuring no leading slash remains unless root
|
# Use dynamic SD card detection from WineUtils
|
||||||
relative_part = path_str[len(SDCARD_PREFIX):]
|
stripped_path = WineUtils._strip_sdcard_path(path_str)
|
||||||
return relative_part if relative_part else "." # Return '.' if it was exactly the prefix
|
|
||||||
|
if stripped_path != path_str:
|
||||||
|
# Path was stripped, remove leading slash for relative path
|
||||||
|
return stripped_path.lstrip('/') if stripped_path != '/' else '.'
|
||||||
|
|
||||||
return path_str
|
return path_str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -737,7 +744,7 @@ class PathHandler:
|
|||||||
try:
|
try:
|
||||||
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
|
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
drive_letter = "D:" if modlist_sdcard else "Z:"
|
drive_letter = "D:\\\\" if modlist_sdcard else "Z:\\\\"
|
||||||
processed_path = self._strip_sdcard_path_prefix(new_game_path)
|
processed_path = self._strip_sdcard_path_prefix(new_game_path)
|
||||||
windows_style = processed_path.replace('/', '\\')
|
windows_style = processed_path.replace('/', '\\')
|
||||||
windows_style_double = windows_style.replace('\\', '\\\\')
|
windows_style_double = windows_style.replace('\\', '\\\\')
|
||||||
@@ -876,9 +883,10 @@ class PathHandler:
|
|||||||
rel_path = value_part[idx:].lstrip('/')
|
rel_path = value_part[idx:].lstrip('/')
|
||||||
else:
|
else:
|
||||||
rel_path = exe_name
|
rel_path = exe_name
|
||||||
new_binary_path = f"{drive_prefix}/{modlist_dir_path}/{rel_path}".replace('\\', '/').replace('//', '/')
|
processed_modlist_path = PathHandler._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
|
||||||
|
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
|
||||||
formatted_binary_path = PathHandler._format_binary_for_mo2(new_binary_path)
|
formatted_binary_path = PathHandler._format_binary_for_mo2(new_binary_path)
|
||||||
new_binary_line = f"{index}{backslash_style}binary={formatted_binary_path}"
|
new_binary_line = f"{index}{backslash_style}binary = {formatted_binary_path}"
|
||||||
logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}")
|
logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}")
|
||||||
lines[i] = new_binary_line + "\n"
|
lines[i] = new_binary_line + "\n"
|
||||||
binary_paths_updated += 1
|
binary_paths_updated += 1
|
||||||
@@ -893,7 +901,7 @@ class PathHandler:
|
|||||||
wd_path = drive_prefix + wd_path
|
wd_path = drive_prefix + wd_path
|
||||||
formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path)
|
formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path)
|
||||||
key_part = f"{index}{backslash_style}workingDirectory"
|
key_part = f"{index}{backslash_style}workingDirectory"
|
||||||
new_wd_line = f"{key_part}={formatted_wd_path}"
|
new_wd_line = f"{key_part} = {formatted_wd_path}"
|
||||||
logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}")
|
logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}")
|
||||||
lines[j] = new_wd_line + "\n"
|
lines[j] = new_wd_line + "\n"
|
||||||
working_dirs_updated += 1
|
working_dirs_updated += 1
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ logger = logging.getLogger(__name__)
|
|||||||
class ProtontricksHandler:
|
class ProtontricksHandler:
|
||||||
"""
|
"""
|
||||||
Handles operations related to Protontricks detection and usage
|
Handles operations related to Protontricks detection and usage
|
||||||
|
|
||||||
|
This handler now supports native Steam operations as a fallback/replacement
|
||||||
|
for protontricks functionality.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, steamdeck: bool, logger=None):
|
def __init__(self, steamdeck: bool, logger=None):
|
||||||
@@ -29,6 +32,8 @@ class ProtontricksHandler:
|
|||||||
self.protontricks_version = None
|
self.protontricks_version = None
|
||||||
self.protontricks_path = None
|
self.protontricks_path = None
|
||||||
self.steamdeck = steamdeck # Store steamdeck status
|
self.steamdeck = steamdeck # Store steamdeck status
|
||||||
|
self._native_steam_service = None
|
||||||
|
self.use_native_operations = True # Enable native Steam operations by default
|
||||||
|
|
||||||
def _get_clean_subprocess_env(self):
|
def _get_clean_subprocess_env(self):
|
||||||
"""
|
"""
|
||||||
@@ -70,6 +75,13 @@ class ProtontricksHandler:
|
|||||||
|
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
def _get_native_steam_service(self):
|
||||||
|
"""Get native Steam operations service instance"""
|
||||||
|
if self._native_steam_service is None:
|
||||||
|
from ..services.native_steam_operations_service import NativeSteamOperationsService
|
||||||
|
self._native_steam_service = NativeSteamOperationsService(steamdeck=self.steamdeck)
|
||||||
|
return self._native_steam_service
|
||||||
|
|
||||||
def detect_protontricks(self):
|
def detect_protontricks(self):
|
||||||
"""
|
"""
|
||||||
Detect if protontricks is installed and whether it's flatpak or native.
|
Detect if protontricks is installed and whether it's flatpak or native.
|
||||||
@@ -255,9 +267,19 @@ class ProtontricksHandler:
|
|||||||
|
|
||||||
def set_protontricks_permissions(self, modlist_dir, steamdeck=False):
|
def set_protontricks_permissions(self, modlist_dir, steamdeck=False):
|
||||||
"""
|
"""
|
||||||
Set permissions for Protontricks to access the modlist directory
|
Set permissions for Steam operations to access the modlist directory.
|
||||||
|
|
||||||
|
Uses native operations when enabled, falls back to protontricks permissions.
|
||||||
Returns True on success, False on failure
|
Returns True on success, False on failure
|
||||||
"""
|
"""
|
||||||
|
# Use native operations if enabled
|
||||||
|
if self.use_native_operations:
|
||||||
|
logger.debug("Using native Steam operations, permissions handled natively")
|
||||||
|
try:
|
||||||
|
return self._get_native_steam_service().set_steam_permissions(modlist_dir, steamdeck)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Native permissions failed, falling back to protontricks: {e}")
|
||||||
|
|
||||||
if self.which_protontricks != 'flatpak':
|
if self.which_protontricks != 'flatpak':
|
||||||
logger.debug("Using Native protontricks, skip setting permissions")
|
logger.debug("Using Native protontricks, skip setting permissions")
|
||||||
return True
|
return True
|
||||||
@@ -338,15 +360,22 @@ class ProtontricksHandler:
|
|||||||
|
|
||||||
# Renamed from list_non_steam_games for clarity and purpose
|
# Renamed from list_non_steam_games for clarity and purpose
|
||||||
def list_non_steam_shortcuts(self) -> Dict[str, str]:
|
def list_non_steam_shortcuts(self) -> Dict[str, str]:
|
||||||
"""List ALL non-Steam shortcuts recognized by Protontricks.
|
"""List ALL non-Steam shortcuts.
|
||||||
|
|
||||||
Runs 'protontricks -l' and parses the output for lines matching
|
Uses native VDF parsing when enabled, falls back to protontricks -l parsing.
|
||||||
"Non-Steam shortcut: [Name] ([AppID])".
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A dictionary mapping the shortcut name (AppName) to its AppID.
|
A dictionary mapping the shortcut name (AppName) to its AppID.
|
||||||
Returns an empty dictionary if none are found or an error occurs.
|
Returns an empty dictionary if none are found or an error occurs.
|
||||||
"""
|
"""
|
||||||
|
# Use native operations if enabled
|
||||||
|
if self.use_native_operations:
|
||||||
|
logger.info("Listing non-Steam shortcuts via native VDF parsing...")
|
||||||
|
try:
|
||||||
|
return self._get_native_steam_service().list_non_steam_shortcuts()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Native shortcut listing failed, falling back to protontricks: {e}")
|
||||||
|
|
||||||
logger.info("Listing ALL non-Steam shortcuts via protontricks...")
|
logger.info("Listing ALL non-Steam shortcuts via protontricks...")
|
||||||
non_steam_shortcuts = {}
|
non_steam_shortcuts = {}
|
||||||
# --- Ensure protontricks is detected before proceeding ---
|
# --- Ensure protontricks is detected before proceeding ---
|
||||||
@@ -577,12 +606,22 @@ class ProtontricksHandler:
|
|||||||
def get_wine_prefix_path(self, appid) -> Optional[str]:
|
def get_wine_prefix_path(self, appid) -> Optional[str]:
|
||||||
"""Gets the WINEPREFIX path for a given AppID.
|
"""Gets the WINEPREFIX path for a given AppID.
|
||||||
|
|
||||||
|
Uses native path discovery when enabled, falls back to protontricks detection.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
appid (str): The Steam AppID.
|
appid (str): The Steam AppID.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The WINEPREFIX path as a string, or None if detection fails.
|
The WINEPREFIX path as a string, or None if detection fails.
|
||||||
"""
|
"""
|
||||||
|
# Use native operations if enabled
|
||||||
|
if self.use_native_operations:
|
||||||
|
logger.debug(f"Getting WINEPREFIX for AppID {appid} via native path discovery")
|
||||||
|
try:
|
||||||
|
return self._get_native_steam_service().get_wine_prefix_path(appid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Native WINEPREFIX detection failed, falling back to protontricks: {e}")
|
||||||
|
|
||||||
logger.debug(f"Getting WINEPREFIX for AppID {appid}")
|
logger.debug(f"Getting WINEPREFIX for AppID {appid}")
|
||||||
result = self.run_protontricks("-c", "echo $WINEPREFIX", appid)
|
result = self.run_protontricks("-c", "echo $WINEPREFIX", appid)
|
||||||
if result and result.returncode == 0 and result.stdout.strip():
|
if result and result.returncode == 0 and result.stdout.strip():
|
||||||
|
|||||||
@@ -1180,18 +1180,21 @@ class ShortcutHandler:
|
|||||||
self.logger.warning(f"Skipping invalid shortcut entry at index {index} in {vdf_path}")
|
self.logger.warning(f"Skipping invalid shortcut entry at index {index} in {vdf_path}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
exe_path = shortcut_details.get('Exe', '').strip('"') # Get Exe path, remove quotes
|
exe_path = shortcut_details.get('Exe', shortcut_details.get('exe', '')).strip('"') # Get Exe path, remove quotes
|
||||||
app_name = shortcut_details.get('AppName', 'Unknown Shortcut')
|
app_name = shortcut_details.get('AppName', shortcut_details.get('appname', 'Unknown Shortcut'))
|
||||||
|
|
||||||
# Check if the executable_name is present in the Exe path
|
# Check if the executable_name is present in the Exe path
|
||||||
if executable_name in os.path.basename(exe_path):
|
if executable_name in os.path.basename(exe_path):
|
||||||
self.logger.info(f"Found matching shortcut '{app_name}' in {vdf_path}")
|
self.logger.info(f"Found matching shortcut '{app_name}' in {vdf_path}")
|
||||||
# Extract relevant details
|
# Extract relevant details with case-insensitive fallbacks
|
||||||
|
app_id = shortcut_details.get('appid', shortcut_details.get('AppID', shortcut_details.get('appId', None)))
|
||||||
|
start_dir = shortcut_details.get('StartDir', shortcut_details.get('startdir', '')).strip('"')
|
||||||
|
|
||||||
match = {
|
match = {
|
||||||
'AppName': app_name,
|
'AppName': app_name,
|
||||||
'Exe': exe_path, # Store unquoted path
|
'Exe': exe_path, # Store unquoted path
|
||||||
'StartDir': shortcut_details.get('StartDir', '').strip('"') # Unquoted
|
'StartDir': start_dir,
|
||||||
# Add other useful fields if needed, e.g., 'ShortcutPath'
|
'appid': app_id # Include the AppID for conversion to unsigned
|
||||||
}
|
}
|
||||||
matching_shortcuts.append(match)
|
matching_shortcuts.append(match)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -197,16 +197,43 @@ class WineUtils:
|
|||||||
logger.error(f"Error editing binary working paths: {e}")
|
logger.error(f"Error editing binary working paths: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_sd_card_mounts():
|
||||||
|
"""
|
||||||
|
Dynamically detect all current SD card mount points
|
||||||
|
Returns list of mount point paths
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
result = subprocess.run(['df', '-h'], capture_output=True, text=True, timeout=5)
|
||||||
|
sd_mounts = []
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
# Look for common SD card mount patterns
|
||||||
|
if '/run/media' in line or ('/mnt' in line and 'sdcard' in line.lower()):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 6: # df output has 6+ columns
|
||||||
|
mount_point = parts[-1] # Last column is mount point
|
||||||
|
if mount_point.startswith(('/run/media', '/mnt')):
|
||||||
|
sd_mounts.append(mount_point)
|
||||||
|
return sd_mounts
|
||||||
|
except Exception:
|
||||||
|
# Fallback to common patterns if df fails
|
||||||
|
return ['/run/media/mmcblk0p1', '/run/media/deck']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_sdcard_path(path):
|
def _strip_sdcard_path(path):
|
||||||
"""
|
"""
|
||||||
Strip /run/media/deck/UUID from SD card paths
|
Strip any detected SD card mount prefix from paths
|
||||||
Internal helper method
|
Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns
|
||||||
"""
|
"""
|
||||||
if path.startswith("/run/media/deck/"):
|
sd_mounts = WineUtils._get_sd_card_mounts()
|
||||||
parts = path.split("/", 5)
|
|
||||||
if len(parts) >= 6:
|
for mount in sd_mounts:
|
||||||
return "/" + parts[5]
|
if path.startswith(mount):
|
||||||
|
# Strip the mount prefix and ensure proper leading slash
|
||||||
|
relative_path = path[len(mount):].lstrip('/')
|
||||||
|
return "/" + relative_path if relative_path else "/"
|
||||||
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -609,12 +636,46 @@ class WineUtils:
|
|||||||
"""
|
"""
|
||||||
# Clean up the version string for directory matching
|
# Clean up the version string for directory matching
|
||||||
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
|
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
|
||||||
# Standard Steam library locations
|
|
||||||
|
# Get actual Steam library paths from libraryfolders.vdf (smart detection)
|
||||||
|
steam_common_paths = []
|
||||||
|
compatibility_paths = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .path_handler import PathHandler
|
||||||
|
# Get root Steam library paths (without /steamapps/common suffix)
|
||||||
|
root_steam_libs = PathHandler.get_all_steam_library_paths()
|
||||||
|
for lib_path in root_steam_libs:
|
||||||
|
lib = Path(lib_path)
|
||||||
|
if lib.exists():
|
||||||
|
# Valve Proton: {library}/steamapps/common
|
||||||
|
common_path = lib / "steamapps/common"
|
||||||
|
if common_path.exists():
|
||||||
|
steam_common_paths.append(common_path)
|
||||||
|
# GE-Proton: same Steam installation root + compatibilitytools.d
|
||||||
|
compatibility_paths.append(lib / "compatibilitytools.d")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not detect Steam libraries from libraryfolders.vdf: {e}")
|
||||||
|
|
||||||
|
# Fallback locations if dynamic detection fails
|
||||||
|
if not steam_common_paths:
|
||||||
steam_common_paths = [
|
steam_common_paths = [
|
||||||
Path.home() / ".steam/steam/steamapps/common",
|
Path.home() / ".steam/steam/steamapps/common",
|
||||||
Path.home() / ".local/share/Steam/steamapps/common",
|
Path.home() / ".local/share/Steam/steamapps/common",
|
||||||
Path.home() / ".steam/root/steamapps/common"
|
Path.home() / ".steam/root/steamapps/common"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if not compatibility_paths:
|
||||||
|
compatibility_paths = [
|
||||||
|
Path.home() / ".steam/steam/compatibilitytools.d",
|
||||||
|
Path.home() / ".local/share/Steam/compatibilitytools.d"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add standard compatibility tool locations (covers edge cases like Flatpak)
|
||||||
|
compatibility_paths.extend([
|
||||||
|
Path.home() / ".steam/root/compatibilitytools.d",
|
||||||
|
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d"
|
||||||
|
])
|
||||||
# Special handling for Proton 9: try all possible directory names
|
# Special handling for Proton 9: try all possible directory names
|
||||||
if proton_version.strip().startswith("Proton 9"):
|
if proton_version.strip().startswith("Proton 9"):
|
||||||
proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"]
|
proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"]
|
||||||
@@ -628,8 +689,9 @@ class WineUtils:
|
|||||||
wine_bin = subdir / "files/bin/wine"
|
wine_bin = subdir / "files/bin/wine"
|
||||||
if wine_bin.is_file():
|
if wine_bin.is_file():
|
||||||
return str(wine_bin)
|
return str(wine_bin)
|
||||||
# General case: try version patterns
|
# General case: try version patterns in both steamapps and compatibilitytools.d
|
||||||
for base_path in steam_common_paths:
|
all_paths = steam_common_paths + compatibility_paths
|
||||||
|
for base_path in all_paths:
|
||||||
if not base_path.is_dir():
|
if not base_path.is_dir():
|
||||||
continue
|
continue
|
||||||
for pattern in version_patterns:
|
for pattern in version_patterns:
|
||||||
@@ -716,19 +778,32 @@ class WineUtils:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_steam_library_paths() -> List[Path]:
|
def get_steam_library_paths() -> List[Path]:
|
||||||
"""
|
"""
|
||||||
Get all Steam library paths including standard locations.
|
Get all Steam library paths from libraryfolders.vdf (handles Flatpak, custom locations, etc.).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of Path objects for Steam library directories
|
List of Path objects for Steam library directories
|
||||||
"""
|
"""
|
||||||
steam_paths = [
|
try:
|
||||||
|
from .path_handler import PathHandler
|
||||||
|
# Use existing PathHandler that reads libraryfolders.vdf
|
||||||
|
library_paths = PathHandler.get_all_steam_library_paths()
|
||||||
|
# Convert to steamapps/common paths for Proton scanning
|
||||||
|
steam_common_paths = []
|
||||||
|
for lib_path in library_paths:
|
||||||
|
common_path = lib_path / "steamapps" / "common"
|
||||||
|
if common_path.exists():
|
||||||
|
steam_common_paths.append(common_path)
|
||||||
|
logger.debug(f"Found Steam library paths: {steam_common_paths}")
|
||||||
|
return steam_common_paths
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get Steam library paths from libraryfolders.vdf: {e}")
|
||||||
|
# Fallback to hardcoded paths if PathHandler fails
|
||||||
|
fallback_paths = [
|
||||||
Path.home() / ".steam/steam/steamapps/common",
|
Path.home() / ".steam/steam/steamapps/common",
|
||||||
Path.home() / ".local/share/Steam/steamapps/common",
|
Path.home() / ".local/share/Steam/steamapps/common",
|
||||||
Path.home() / ".steam/root/steamapps/common"
|
Path.home() / ".steam/root/steamapps/common"
|
||||||
]
|
]
|
||||||
|
return [path for path in fallback_paths if path.exists()]
|
||||||
# Return only existing paths
|
|
||||||
return [path for path in steam_paths if path.exists()]
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_compatibility_tool_paths() -> List[Path]:
|
def get_compatibility_tool_paths() -> List[Path]:
|
||||||
|
|||||||
263
jackify/backend/handlers/winetricks_handler.py
Normal file
263
jackify/backend/handlers/winetricks_handler.py
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Winetricks Handler Module
|
||||||
|
Handles wine component installation using bundled winetricks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WinetricksHandler:
|
||||||
|
"""
|
||||||
|
Handles wine component installation using bundled winetricks
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, logger=None):
|
||||||
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
|
self.winetricks_path = self._get_bundled_winetricks_path()
|
||||||
|
|
||||||
|
def _get_bundled_winetricks_path(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the path to the bundled winetricks script following AppImage best practices
|
||||||
|
"""
|
||||||
|
possible_paths = []
|
||||||
|
|
||||||
|
# AppImage environment - use APPDIR (standard AppImage best practice)
|
||||||
|
if os.environ.get('APPDIR'):
|
||||||
|
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'winetricks')
|
||||||
|
possible_paths.append(appdir_path)
|
||||||
|
|
||||||
|
# Development environment - relative to module location
|
||||||
|
module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/
|
||||||
|
dev_path = module_dir / 'tools' / 'winetricks'
|
||||||
|
possible_paths.append(str(dev_path))
|
||||||
|
|
||||||
|
# Try each path until we find one that works
|
||||||
|
for path in possible_paths:
|
||||||
|
if os.path.exists(path) and os.access(path, os.X_OK):
|
||||||
|
self.logger.debug(f"Found bundled winetricks at: {path}")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
self.logger.error(f"Bundled winetricks not found. Tried paths: {possible_paths}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_bundled_cabextract(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the path to the bundled cabextract binary, checking same locations as winetricks
|
||||||
|
"""
|
||||||
|
possible_paths = []
|
||||||
|
|
||||||
|
# AppImage environment - same pattern as winetricks detection
|
||||||
|
if os.environ.get('APPDIR'):
|
||||||
|
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract')
|
||||||
|
possible_paths.append(appdir_path)
|
||||||
|
|
||||||
|
# Development environment - relative to module location, same as winetricks
|
||||||
|
module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/
|
||||||
|
dev_path = module_dir / 'tools' / 'cabextract'
|
||||||
|
possible_paths.append(str(dev_path))
|
||||||
|
|
||||||
|
# Try each path until we find one that works
|
||||||
|
for path in possible_paths:
|
||||||
|
if os.path.exists(path) and os.access(path, os.X_OK):
|
||||||
|
self.logger.debug(f"Found bundled cabextract at: {path}")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
# Fallback to system PATH
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
system_cabextract = shutil.which('cabextract')
|
||||||
|
if system_cabextract:
|
||||||
|
self.logger.debug(f"Using system cabextract: {system_cabextract}")
|
||||||
|
return system_cabextract
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.logger.warning("Bundled cabextract not found in tools directory")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if winetricks is available and ready to use
|
||||||
|
"""
|
||||||
|
if not self.winetricks_path:
|
||||||
|
self.logger.error("Bundled winetricks not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
env = os.environ.copy()
|
||||||
|
result = subprocess.run(
|
||||||
|
[self.winetricks_path, '--version'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
self.logger.debug(f"Winetricks version: {result.stdout.strip()}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Winetricks --version failed: {result.stderr}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error testing winetricks: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def install_wine_components(self, wineprefix: str, game_var: str, specific_components: Optional[List[str]] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Install the specified Wine components into the given prefix using winetricks.
|
||||||
|
If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022).
|
||||||
|
"""
|
||||||
|
if not self.is_available():
|
||||||
|
self.logger.error("Winetricks is not available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
|
||||||
|
env['WINEPREFIX'] = wineprefix
|
||||||
|
env['WINETRICKS_GUI'] = 'none' # Suppress GUI popups
|
||||||
|
# Less aggressive popup suppression - don't completely disable display
|
||||||
|
if 'DISPLAY' in env:
|
||||||
|
# Keep DISPLAY but add window manager hints to prevent focus stealing
|
||||||
|
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d' # Disable Wine menu integration
|
||||||
|
else:
|
||||||
|
# No display available anyway
|
||||||
|
env['DISPLAY'] = ''
|
||||||
|
|
||||||
|
# Force winetricks to use Proton wine binary - NEVER fall back to system wine
|
||||||
|
try:
|
||||||
|
from ..handlers.config_handler import ConfigHandler
|
||||||
|
from ..handlers.wine_utils import WineUtils
|
||||||
|
|
||||||
|
config = ConfigHandler()
|
||||||
|
user_proton_path = config.get('proton_path', 'auto')
|
||||||
|
|
||||||
|
# If user selected a specific Proton, try that first
|
||||||
|
wine_binary = None
|
||||||
|
if user_proton_path != 'auto':
|
||||||
|
# Check if user-selected Proton still exists
|
||||||
|
if os.path.exists(user_proton_path):
|
||||||
|
# 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 = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
|
||||||
|
ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine')
|
||||||
|
|
||||||
|
if os.path.exists(valve_proton_wine):
|
||||||
|
wine_binary = valve_proton_wine
|
||||||
|
self.logger.info(f"Using user-selected Proton: {user_proton_path}")
|
||||||
|
elif os.path.exists(ge_proton_wine):
|
||||||
|
wine_binary = ge_proton_wine
|
||||||
|
self.logger.info(f"Using user-selected GE-Proton: {user_proton_path}")
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"User-selected Proton path invalid: {user_proton_path}")
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}")
|
||||||
|
|
||||||
|
# Fall back to auto-detection if user selection failed or is 'auto'
|
||||||
|
if not wine_binary:
|
||||||
|
self.logger.info("Falling back to automatic Proton detection")
|
||||||
|
best_proton = WineUtils.select_best_proton()
|
||||||
|
if best_proton:
|
||||||
|
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
|
||||||
|
self.logger.info(f"Auto-selected Proton: {best_proton['name']} at {best_proton['path']}")
|
||||||
|
|
||||||
|
if not wine_binary:
|
||||||
|
self.logger.error("Cannot run winetricks: No compatible Proton version found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)):
|
||||||
|
self.logger.error(f"Cannot run winetricks: Wine binary not found or not executable: {wine_binary}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
env['WINE'] = str(wine_binary)
|
||||||
|
self.logger.info(f"Using Proton wine binary for winetricks: {wine_binary}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Set up bundled cabextract for winetricks
|
||||||
|
bundled_cabextract = self._get_bundled_cabextract()
|
||||||
|
if bundled_cabextract:
|
||||||
|
env['PATH'] = f"{os.path.dirname(bundled_cabextract)}:{env.get('PATH', '')}"
|
||||||
|
self.logger.info(f"Using bundled cabextract: {bundled_cabextract}")
|
||||||
|
else:
|
||||||
|
self.logger.warning("Bundled cabextract not found, relying on system PATH")
|
||||||
|
|
||||||
|
# Set winetricks cache to jackify_data_dir for self-containment
|
||||||
|
from jackify.shared.paths import get_jackify_data_dir
|
||||||
|
jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache'
|
||||||
|
jackify_cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
|
||||||
|
|
||||||
|
if specific_components is not None:
|
||||||
|
components_to_install = specific_components
|
||||||
|
self.logger.info(f"Installing specific components: {components_to_install}")
|
||||||
|
else:
|
||||||
|
components_to_install = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
|
||||||
|
self.logger.info(f"Installing default components: {components_to_install}")
|
||||||
|
|
||||||
|
if not components_to_install:
|
||||||
|
self.logger.info("No Wine components to install.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.logger.info(f"WINEPREFIX: {wineprefix}, Game: {game_var}, Components: {components_to_install}")
|
||||||
|
|
||||||
|
max_attempts = 3
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
if attempt > 1:
|
||||||
|
self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...")
|
||||||
|
self._cleanup_wine_processes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build winetricks command - using --unattended for silent installation
|
||||||
|
cmd = [self.winetricks_path, '--unattended'] + components_to_install
|
||||||
|
|
||||||
|
self.logger.debug(f"Running: {' '.join(cmd)}")
|
||||||
|
self.logger.debug(f"Environment WINE={env.get('WINE', 'NOT SET')}")
|
||||||
|
self.logger.debug(f"Environment DISPLAY={env.get('DISPLAY', 'NOT SET')}")
|
||||||
|
self.logger.debug(f"Environment WINEPREFIX={env.get('WINEPREFIX', 'NOT SET')}")
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=600
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.debug(f"Winetricks output: {result.stdout}")
|
||||||
|
if result.returncode == 0:
|
||||||
|
self.logger.info("Wine Component installation command completed successfully.")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.logger.error(f"Winetricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode}")
|
||||||
|
self.logger.error(f"Stdout: {result.stdout.strip()}")
|
||||||
|
self.logger.error(f"Stderr: {result.stderr.strip()}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error during winetricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True)
|
||||||
|
|
||||||
|
self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _cleanup_wine_processes(self):
|
||||||
|
"""
|
||||||
|
Internal method to clean up wine processes during component installation
|
||||||
|
Only cleanup winetricks processes - NEVER kill all wine processes
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Only cleanup winetricks processes - do NOT kill other wine apps
|
||||||
|
subprocess.run("pkill -f winetricks", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
self.logger.debug("Cleaned up winetricks processes only")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error cleaning up winetricks processes: {e}")
|
||||||
@@ -448,7 +448,7 @@ exit"""
|
|||||||
|
|
||||||
if shortcut_name in name:
|
if shortcut_name in name:
|
||||||
appid = shortcut.get('appid')
|
appid = shortcut.get('appid')
|
||||||
exe_path = shortcut.get('Exe', '')
|
exe_path = shortcut.get('Exe', '').strip('"')
|
||||||
|
|
||||||
logger.info(f"Found shortcut: {name}")
|
logger.info(f"Found shortcut: {name}")
|
||||||
logger.info(f" AppID: {appid}")
|
logger.info(f" AppID: {appid}")
|
||||||
@@ -1759,21 +1759,15 @@ echo Prefix creation complete.
|
|||||||
progress_callback("=== Steam Integration ===")
|
progress_callback("=== Steam Integration ===")
|
||||||
progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service")
|
progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service")
|
||||||
|
|
||||||
# Dual approach: Registry injection for FNV, launch options for Enderal
|
# Registry injection approach for both FNV and Enderal
|
||||||
from ..handlers.modlist_handler import ModlistHandler
|
from ..handlers.modlist_handler import ModlistHandler
|
||||||
modlist_handler = ModlistHandler()
|
modlist_handler = ModlistHandler()
|
||||||
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
|
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
|
||||||
|
|
||||||
# Generate launch options only for Enderal (FNV uses registry injection)
|
# No launch options needed - both FNV and Enderal use registry injection
|
||||||
custom_launch_options = None
|
custom_launch_options = None
|
||||||
if special_game_type == "enderal":
|
if special_game_type in ["fnv", "enderal"]:
|
||||||
custom_launch_options = self._generate_special_game_launch_options(special_game_type, modlist_install_dir)
|
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
|
||||||
if not custom_launch_options:
|
|
||||||
logger.error(f"Failed to generate launch options for Enderal modlist")
|
|
||||||
return False, None, None, None
|
|
||||||
logger.info("Using launch options approach for Enderal modlist")
|
|
||||||
elif special_game_type == "fnv":
|
|
||||||
logger.info("Using registry injection approach for FNV modlist")
|
|
||||||
else:
|
else:
|
||||||
logger.debug("Standard modlist - no special game handling needed")
|
logger.debug("Standard modlist - no special game handling needed")
|
||||||
|
|
||||||
@@ -1849,23 +1843,19 @@ echo Prefix creation complete.
|
|||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(f"{self._get_progress_timestamp()} Setup verification completed")
|
progress_callback(f"{self._get_progress_timestamp()} Setup verification completed")
|
||||||
|
|
||||||
# Step 5: Inject game registry entries for FNV modlists (Enderal uses launch options)
|
# Step 5: Inject game registry entries for FNV and Enderal modlists
|
||||||
# Get prefix path (needed for logging regardless of game type)
|
# Get prefix path (needed for logging regardless of game type)
|
||||||
prefix_path = self.get_prefix_path(appid)
|
prefix_path = self.get_prefix_path(appid)
|
||||||
|
|
||||||
if special_game_type == "fnv":
|
if special_game_type in ["fnv", "enderal"]:
|
||||||
logger.info("Step 5: Injecting FNV game registry entries")
|
logger.info(f"Step 5: Injecting {special_game_type.upper()} game registry entries")
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback(f"{self._get_progress_timestamp()} Injecting FNV game registry entries...")
|
progress_callback(f"{self._get_progress_timestamp()} Injecting {special_game_type.upper()} game registry entries...")
|
||||||
|
|
||||||
if prefix_path:
|
if prefix_path:
|
||||||
self._inject_game_registry_entries(str(prefix_path))
|
self._inject_game_registry_entries(str(prefix_path))
|
||||||
else:
|
else:
|
||||||
logger.warning("Could not find prefix path for registry injection")
|
logger.warning("Could not find prefix path for registry injection")
|
||||||
elif special_game_type == "enderal":
|
|
||||||
logger.info("Step 5: Skipping registry injection for Enderal (using launch options)")
|
|
||||||
if progress_callback:
|
|
||||||
progress_callback(f"{self._get_progress_timestamp()} Skipping registry injection for Enderal")
|
|
||||||
else:
|
else:
|
||||||
logger.info("Step 5: Skipping registry injection for standard modlist")
|
logger.info("Step 5: Skipping registry injection for standard modlist")
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
@@ -2690,7 +2680,36 @@ echo Prefix creation complete.
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]:
|
def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]:
|
||||||
"""Locate a Proton wrapper script to use (prefer Experimental)."""
|
"""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('proton_path', 'auto')
|
||||||
|
|
||||||
|
# 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 = []
|
candidates = []
|
||||||
preferred = [
|
preferred = [
|
||||||
"Proton - Experimental",
|
"Proton - Experimental",
|
||||||
@@ -2713,9 +2732,13 @@ echo Prefix creation complete.
|
|||||||
logger.error("No Proton wrapper found under steamapps/common")
|
logger.error("No Proton wrapper found under steamapps/common")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(f"Using Proton wrapper: {candidates[0]}")
|
logger.info(f"Using auto-detected Proton wrapper: {candidates[0]}")
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding Proton binary: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def replace_existing_shortcut(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Tuple[bool, Optional[int]]:
|
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.
|
Replace an existing shortcut with a new one using STL.
|
||||||
@@ -2948,6 +2971,15 @@ echo Prefix creation complete.
|
|||||||
)
|
)
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"Updated registry entry for {config['name']}")
|
logger.info(f"Updated registry entry for {config['name']}")
|
||||||
|
|
||||||
|
# Special handling for Enderal: Create required user directory
|
||||||
|
if app_id == "976620": # Enderal Special Edition
|
||||||
|
try:
|
||||||
|
enderal_docs_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser", "Documents", "My Games", "Enderal Special Edition")
|
||||||
|
os.makedirs(enderal_docs_path, exist_ok=True)
|
||||||
|
logger.info(f"Created Enderal user directory: {enderal_docs_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to create Enderal user directory: {e}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Failed to update registry entry for {config['name']}")
|
logger.warning(f"Failed to update registry entry for {config['name']}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -294,14 +294,6 @@ class ModlistService:
|
|||||||
cmd += ['-m', context['machineid']]
|
cmd += ['-m', context['machineid']]
|
||||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||||
|
|
||||||
# Check for debug mode and add --debug flag
|
|
||||||
from ..handlers.config_handler import ConfigHandler
|
|
||||||
config_handler = ConfigHandler()
|
|
||||||
debug_mode = config_handler.get('debug_mode', False)
|
|
||||||
if debug_mode:
|
|
||||||
cmd.append('--debug')
|
|
||||||
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
|
|
||||||
|
|
||||||
# NOTE: API key is passed via environment variable only, not as command line argument
|
# NOTE: API key is passed via environment variable only, not as command line argument
|
||||||
|
|
||||||
# Store original environment values (copied from working code)
|
# Store original environment values (copied from working code)
|
||||||
|
|||||||
185
jackify/backend/services/native_steam_operations_service.py
Normal file
185
jackify/backend/services/native_steam_operations_service.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Native Steam Operations Service
|
||||||
|
|
||||||
|
This service provides direct Steam operations using VDF parsing and path discovery.
|
||||||
|
Replaces protontricks dependencies with native Steam functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import vdf
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NativeSteamOperationsService:
|
||||||
|
"""
|
||||||
|
Service providing native Steam operations for shortcut discovery and prefix management.
|
||||||
|
|
||||||
|
Replaces protontricks functionality with:
|
||||||
|
- Direct VDF parsing for shortcut discovery
|
||||||
|
- Native compatdata path construction
|
||||||
|
- Direct Steam library detection
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, steamdeck: bool = False):
|
||||||
|
self.steamdeck = steamdeck
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
def list_non_steam_shortcuts(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
List non-Steam shortcuts via direct VDF parsing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping shortcut name to AppID string
|
||||||
|
"""
|
||||||
|
logger.info("Listing non-Steam shortcuts via native VDF parsing...")
|
||||||
|
shortcuts = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find all possible shortcuts.vdf locations
|
||||||
|
shortcuts_paths = self._find_shortcuts_vdf_paths()
|
||||||
|
|
||||||
|
for shortcuts_path in shortcuts_paths:
|
||||||
|
logger.debug(f"Checking shortcuts.vdf at: {shortcuts_path}")
|
||||||
|
|
||||||
|
if not shortcuts_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(shortcuts_path, 'rb') as f:
|
||||||
|
data = vdf.binary_load(f)
|
||||||
|
|
||||||
|
shortcuts_data = data.get('shortcuts', {})
|
||||||
|
for shortcut_key, shortcut_data in shortcuts_data.items():
|
||||||
|
if isinstance(shortcut_data, dict):
|
||||||
|
app_name = shortcut_data.get('AppName', '').strip()
|
||||||
|
app_id = shortcut_data.get('appid', '')
|
||||||
|
|
||||||
|
if app_name and app_id:
|
||||||
|
# Convert to positive AppID string (compatible format)
|
||||||
|
positive_appid = str(abs(int(app_id)))
|
||||||
|
shortcuts[app_name] = positive_appid
|
||||||
|
logger.debug(f"Found non-Steam shortcut: '{app_name}' with AppID {positive_appid}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading shortcuts.vdf at {shortcuts_path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not shortcuts:
|
||||||
|
logger.warning("No non-Steam shortcuts found in any shortcuts.vdf")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing non-Steam shortcuts: {e}")
|
||||||
|
|
||||||
|
return shortcuts
|
||||||
|
|
||||||
|
def set_steam_permissions(self, modlist_dir: str, steamdeck: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Handle Steam access permissions for native operations.
|
||||||
|
|
||||||
|
Since we're using direct file access, no special permissions needed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
modlist_dir: Modlist directory path (for future use)
|
||||||
|
steamdeck: Steam Deck flag (for future use)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Always True (no permissions needed for native operations)
|
||||||
|
"""
|
||||||
|
logger.debug("Using native Steam operations, no permission setting needed")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_wine_prefix_path(self, appid: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get WINEPREFIX path via direct compatdata discovery.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
appid: Steam AppID string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WINEPREFIX path string or None if not found
|
||||||
|
"""
|
||||||
|
logger.debug(f"Getting WINEPREFIX for AppID {appid} using native path discovery")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find all possible compatdata locations
|
||||||
|
compatdata_paths = self._find_compatdata_paths()
|
||||||
|
|
||||||
|
for compatdata_base in compatdata_paths:
|
||||||
|
prefix_path = compatdata_base / appid / "pfx"
|
||||||
|
logger.debug(f"Checking prefix path: {prefix_path}")
|
||||||
|
|
||||||
|
if prefix_path.exists():
|
||||||
|
logger.debug(f"Found WINEPREFIX: {prefix_path}")
|
||||||
|
return str(prefix_path)
|
||||||
|
|
||||||
|
logger.error(f"WINEPREFIX not found for AppID {appid} in any compatdata location")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting WINEPREFIX for AppID {appid}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_shortcuts_vdf_paths(self) -> List[Path]:
|
||||||
|
"""Find all possible shortcuts.vdf file locations"""
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
# Standard Steam locations
|
||||||
|
steam_locations = [
|
||||||
|
Path.home() / ".steam/steam",
|
||||||
|
Path.home() / ".local/share/Steam",
|
||||||
|
Path.home() / ".var/app/com.valvesoftware.Steam/home/.steam/steam",
|
||||||
|
Path.home() / ".var/app/com.valvesoftware.Steam/home/.local/share/Steam"
|
||||||
|
]
|
||||||
|
|
||||||
|
for steam_root in steam_locations:
|
||||||
|
if not steam_root.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find userdata directories
|
||||||
|
userdata_path = steam_root / "userdata"
|
||||||
|
if userdata_path.exists():
|
||||||
|
for user_dir in userdata_path.iterdir():
|
||||||
|
if user_dir.is_dir() and user_dir.name.isdigit():
|
||||||
|
shortcuts_path = user_dir / "config" / "shortcuts.vdf"
|
||||||
|
paths.append(shortcuts_path)
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
def _find_compatdata_paths(self) -> List[Path]:
|
||||||
|
"""Find all possible compatdata directory locations"""
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
# Standard compatdata locations
|
||||||
|
standard_locations = [
|
||||||
|
Path.home() / ".steam/steam/steamapps/compatdata",
|
||||||
|
Path.home() / ".local/share/Steam/steamapps/compatdata",
|
||||||
|
Path.home() / ".var/app/com.valvesoftware.Steam/home/.steam/steam/steamapps/compatdata",
|
||||||
|
Path.home() / ".var/app/com.valvesoftware.Steam/home/.local/share/Steam/steamapps/compatdata"
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in standard_locations:
|
||||||
|
if path.exists():
|
||||||
|
paths.append(path)
|
||||||
|
|
||||||
|
# Also check additional Steam libraries via libraryfolders.vdf
|
||||||
|
try:
|
||||||
|
from jackify.shared.paths import PathHandler
|
||||||
|
all_steam_libs = PathHandler.get_all_steam_library_paths()
|
||||||
|
|
||||||
|
for lib_path in all_steam_libs:
|
||||||
|
compatdata_path = lib_path / "steamapps" / "compatdata"
|
||||||
|
if compatdata_path.exists():
|
||||||
|
paths.append(compatdata_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not get additional Steam library paths: {e}")
|
||||||
|
|
||||||
|
return paths
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
"""
|
|
||||||
Tuxborn Command
|
|
||||||
|
|
||||||
CLI command for the Tuxborn Automatic Installer.
|
|
||||||
Extracted from the original jackify-cli.py.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# Import the backend services we'll need
|
|
||||||
from jackify.backend.models.modlist import ModlistContext
|
|
||||||
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class TuxbornCommand:
|
|
||||||
"""Handler for the tuxborn-auto CLI command."""
|
|
||||||
|
|
||||||
def __init__(self, backend_services, system_info):
|
|
||||||
"""Initialize with backend services.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
backend_services: Dictionary of backend service instances
|
|
||||||
system_info: System information (steamdeck flag, etc.)
|
|
||||||
"""
|
|
||||||
self.backend_services = backend_services
|
|
||||||
self.system_info = system_info
|
|
||||||
|
|
||||||
def add_args(self, parser):
|
|
||||||
"""Add tuxborn-auto arguments to the main parser.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parser: The main ArgumentParser
|
|
||||||
"""
|
|
||||||
parser.add_argument(
|
|
||||||
"--tuxborn-auto",
|
|
||||||
action="store_true",
|
|
||||||
help="Run the Tuxborn Automatic Installer non-interactively (for GUI integration)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--install-dir",
|
|
||||||
type=str,
|
|
||||||
help="Install directory for Tuxborn (required with --tuxborn-auto)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--download-dir",
|
|
||||||
type=str,
|
|
||||||
help="Downloads directory for Tuxborn (required with --tuxborn-auto)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--modlist-name",
|
|
||||||
type=str,
|
|
||||||
default="Tuxborn",
|
|
||||||
help="Modlist name (optional, defaults to 'Tuxborn')"
|
|
||||||
)
|
|
||||||
|
|
||||||
def execute(self, args) -> int:
|
|
||||||
"""Execute the tuxborn-auto command.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
args: Parsed command-line arguments
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Exit code (0 for success, 1 for failure)
|
|
||||||
"""
|
|
||||||
logger.info("Starting Tuxborn Automatic Installer (GUI integration mode)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Set up logging redirection (copied from original)
|
|
||||||
self._setup_tee_logging()
|
|
||||||
|
|
||||||
# Build context from args
|
|
||||||
context = self._build_context_from_args(args)
|
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if not self._validate_context(context):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Use legacy implementation for now - will migrate to backend services later
|
|
||||||
result = self._execute_legacy_tuxborn(context)
|
|
||||||
|
|
||||||
logger.info("Finished Tuxborn Automatic Installer")
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to run Tuxborn installer: {e}")
|
|
||||||
print(f"{COLOR_ERROR}Tuxborn installation failed: {e}{COLOR_RESET}")
|
|
||||||
return 1
|
|
||||||
finally:
|
|
||||||
# Restore stdout/stderr
|
|
||||||
self._restore_stdout_stderr()
|
|
||||||
|
|
||||||
def _build_context_from_args(self, args) -> dict:
|
|
||||||
"""Build context dictionary from command arguments.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
args: Parsed command-line arguments
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Context dictionary
|
|
||||||
"""
|
|
||||||
install_dir = getattr(args, 'install_dir', None)
|
|
||||||
download_dir = getattr(args, 'download_dir', None)
|
|
||||||
modlist_name = getattr(args, 'modlist_name', 'Tuxborn')
|
|
||||||
machineid = 'Tuxborn/Tuxborn'
|
|
||||||
|
|
||||||
# Try to get API key from saved config first, then environment variable
|
|
||||||
from jackify.backend.services.api_key_service import APIKeyService
|
|
||||||
api_key_service = APIKeyService()
|
|
||||||
api_key = api_key_service.get_saved_api_key()
|
|
||||||
if not api_key:
|
|
||||||
api_key = os.environ.get('NEXUS_API_KEY')
|
|
||||||
|
|
||||||
resolution = getattr(args, 'resolution', None)
|
|
||||||
mo2_exe_path = getattr(args, 'mo2_exe_path', None)
|
|
||||||
skip_confirmation = True # Always true in GUI mode
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'machineid': machineid,
|
|
||||||
'modlist_name': modlist_name,
|
|
||||||
'install_dir': install_dir,
|
|
||||||
'download_dir': download_dir,
|
|
||||||
'nexus_api_key': api_key,
|
|
||||||
'skip_confirmation': skip_confirmation,
|
|
||||||
'resolution': resolution,
|
|
||||||
'mo2_exe_path': mo2_exe_path,
|
|
||||||
}
|
|
||||||
|
|
||||||
# PATCH: Always set modlist_value and modlist_source for Tuxborn workflow
|
|
||||||
context['modlist_value'] = 'Tuxborn/Tuxborn'
|
|
||||||
context['modlist_source'] = 'identifier'
|
|
||||||
|
|
||||||
return context
|
|
||||||
|
|
||||||
def _validate_context(self, context: dict) -> bool:
|
|
||||||
"""Validate Tuxborn context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Tuxborn context dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if valid, False otherwise
|
|
||||||
"""
|
|
||||||
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key']
|
|
||||||
missing = [k for k in required_keys if not context.get(k)]
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
print(f"{COLOR_ERROR}Missing required arguments for --tuxborn-auto.\\n"
|
|
||||||
f"--install-dir, --download-dir, and NEXUS_API_KEY (env, 32+ chars) are required.{COLOR_RESET}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _setup_tee_logging(self):
|
|
||||||
"""Set up TEE logging (copied from original implementation)."""
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
# TEE logging setup & log rotation (copied from original)
|
|
||||||
class TeeStdout:
|
|
||||||
def __init__(self, *files):
|
|
||||||
self.files = files
|
|
||||||
def write(self, data):
|
|
||||||
for f in self.files:
|
|
||||||
f.write(data)
|
|
||||||
f.flush()
|
|
||||||
def flush(self):
|
|
||||||
for f in self.files:
|
|
||||||
f.flush()
|
|
||||||
|
|
||||||
log_dir = Path.home() / "Jackify" / "logs"
|
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
workflow_log_path = log_dir / "tuxborn_workflow.log"
|
|
||||||
|
|
||||||
# Log rotation: keep last 3 logs, 1KB each (for testing)
|
|
||||||
max_logs = 3
|
|
||||||
max_size = 1024 # 1KB for testing
|
|
||||||
if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size:
|
|
||||||
for i in range(max_logs, 0, -1):
|
|
||||||
prev = log_dir / f"tuxborn_workflow.log.{i-1}" if i > 1 else workflow_log_path
|
|
||||||
dest = log_dir / f"tuxborn_workflow.log.{i}"
|
|
||||||
if prev.exists():
|
|
||||||
if dest.exists():
|
|
||||||
dest.unlink()
|
|
||||||
prev.rename(dest)
|
|
||||||
|
|
||||||
self.workflow_log = open(workflow_log_path, 'a')
|
|
||||||
self.orig_stdout, self.orig_stderr = sys.stdout, sys.stderr
|
|
||||||
sys.stdout = TeeStdout(sys.stdout, self.workflow_log)
|
|
||||||
sys.stderr = TeeStdout(sys.stderr, self.workflow_log)
|
|
||||||
|
|
||||||
def _restore_stdout_stderr(self):
|
|
||||||
"""Restore original stdout/stderr."""
|
|
||||||
if hasattr(self, 'orig_stdout'):
|
|
||||||
sys.stdout = self.orig_stdout
|
|
||||||
sys.stderr = self.orig_stderr
|
|
||||||
if hasattr(self, 'workflow_log'):
|
|
||||||
self.workflow_log.close()
|
|
||||||
|
|
||||||
def _execute_legacy_tuxborn(self, context: dict) -> int:
|
|
||||||
"""Execute Tuxborn using legacy implementation.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Tuxborn context dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Exit code
|
|
||||||
"""
|
|
||||||
# Import backend services
|
|
||||||
from jackify.backend.core.modlist_operations import ModlistInstallCLI
|
|
||||||
from jackify.backend.handlers.menu_handler import MenuHandler
|
|
||||||
|
|
||||||
# Create legacy handler instances
|
|
||||||
menu_handler = MenuHandler()
|
|
||||||
modlist_cli = ModlistInstallCLI(
|
|
||||||
menu_handler=menu_handler,
|
|
||||||
steamdeck=self.system_info.get('is_steamdeck', False)
|
|
||||||
)
|
|
||||||
|
|
||||||
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
|
|
||||||
if confirmed_context:
|
|
||||||
menu_handler.logger.info("Tuxborn discovery confirmed by GUI. Proceeding to configuration/installation.")
|
|
||||||
modlist_cli.configuration_phase()
|
|
||||||
|
|
||||||
# Handle GUI integration prompts (copied from original)
|
|
||||||
print('[PROMPT:RESTART_STEAM]')
|
|
||||||
if os.environ.get('JACKIFY_GUI_MODE'):
|
|
||||||
input() # Wait for GUI to send confirmation, no CLI prompt
|
|
||||||
else:
|
|
||||||
answer = input('Restart Steam automatically now? (Y/n): ')
|
|
||||||
# ... handle answer as before ...
|
|
||||||
|
|
||||||
print('[PROMPT:MANUAL_STEPS]')
|
|
||||||
if os.environ.get('JACKIFY_GUI_MODE'):
|
|
||||||
input() # Wait for GUI to send confirmation, no CLI prompt
|
|
||||||
else:
|
|
||||||
input('Once you have completed ALL the steps above, press Enter to continue...')
|
|
||||||
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
menu_handler.logger.info("Tuxborn discovery/confirmation cancelled or failed (GUI mode).")
|
|
||||||
print(f"{COLOR_INFO}Tuxborn installation cancelled or not confirmed.{COLOR_RESET}")
|
|
||||||
return 1
|
|
||||||
@@ -21,11 +21,9 @@ from jackify import __version__ as jackify_version
|
|||||||
# Import our command handlers
|
# Import our command handlers
|
||||||
from .commands.configure_modlist import ConfigureModlistCommand
|
from .commands.configure_modlist import ConfigureModlistCommand
|
||||||
from .commands.install_modlist import InstallModlistCommand
|
from .commands.install_modlist import InstallModlistCommand
|
||||||
from .commands.tuxborn import TuxbornCommand
|
|
||||||
|
|
||||||
# Import our menu handlers
|
# Import our menu handlers
|
||||||
from .menus.main_menu import MainMenuHandler
|
from .menus.main_menu import MainMenuHandler
|
||||||
from .menus.tuxborn_menu import TuxbornMenuHandler
|
|
||||||
from .menus.wabbajack_menu import WabbajackMenuHandler
|
from .menus.wabbajack_menu import WabbajackMenuHandler
|
||||||
from .menus.hoolamike_menu import HoolamikeMenuHandler
|
from .menus.hoolamike_menu import HoolamikeMenuHandler
|
||||||
from .menus.additional_menu import AdditionalMenuHandler
|
from .menus.additional_menu import AdditionalMenuHandler
|
||||||
@@ -280,7 +278,6 @@ class JackifyCLI:
|
|||||||
commands = {
|
commands = {
|
||||||
'configure_modlist': ConfigureModlistCommand(self.backend_services),
|
'configure_modlist': ConfigureModlistCommand(self.backend_services),
|
||||||
'install_modlist': InstallModlistCommand(self.backend_services, self.system_info),
|
'install_modlist': InstallModlistCommand(self.backend_services, self.system_info),
|
||||||
'tuxborn': TuxbornCommand(self.backend_services, self.system_info)
|
|
||||||
}
|
}
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
@@ -292,7 +289,6 @@ class JackifyCLI:
|
|||||||
"""
|
"""
|
||||||
menus = {
|
menus = {
|
||||||
'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)),
|
'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)),
|
||||||
'tuxborn': TuxbornMenuHandler(),
|
|
||||||
'wabbajack': WabbajackMenuHandler(),
|
'wabbajack': WabbajackMenuHandler(),
|
||||||
'hoolamike': HoolamikeMenuHandler(),
|
'hoolamike': HoolamikeMenuHandler(),
|
||||||
'additional': AdditionalMenuHandler()
|
'additional': AdditionalMenuHandler()
|
||||||
@@ -371,10 +367,6 @@ class JackifyCLI:
|
|||||||
self._debug_print('Entering restart_steam workflow')
|
self._debug_print('Entering restart_steam workflow')
|
||||||
return self._handle_restart_steam()
|
return self._handle_restart_steam()
|
||||||
|
|
||||||
# Handle Tuxborn auto mode
|
|
||||||
if getattr(self.args, 'tuxborn_auto', False):
|
|
||||||
self._debug_print('Entering Tuxborn workflow')
|
|
||||||
return self.commands['tuxborn'].execute(self.args)
|
|
||||||
|
|
||||||
# Handle install-modlist top-level functionality
|
# Handle install-modlist top-level functionality
|
||||||
if getattr(self.args, 'install_modlist', False):
|
if getattr(self.args, 'install_modlist', False):
|
||||||
@@ -404,7 +396,6 @@ class JackifyCLI:
|
|||||||
parser.add_argument('--update', action='store_true', help='Check for and install updates')
|
parser.add_argument('--update', action='store_true', help='Check for and install updates')
|
||||||
|
|
||||||
# Add command-specific arguments
|
# Add command-specific arguments
|
||||||
self.commands['tuxborn'].add_args(parser)
|
|
||||||
self.commands['install_modlist'].add_top_level_args(parser)
|
self.commands['install_modlist'].add_top_level_args(parser)
|
||||||
|
|
||||||
# Add subcommands
|
# Add subcommands
|
||||||
@@ -459,8 +450,6 @@ class JackifyCLI:
|
|||||||
return 0
|
return 0
|
||||||
elif choice == "wabbajack":
|
elif choice == "wabbajack":
|
||||||
self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
|
self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
|
||||||
elif choice == "tuxborn":
|
|
||||||
self.menus['tuxborn'].show_tuxborn_installer_menu(self)
|
|
||||||
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
|
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
|
||||||
# elif choice == "hoolamike":
|
# elif choice == "hoolamike":
|
||||||
# self.menus['hoolamike'].show_hoolamike_menu(self)
|
# self.menus['hoolamike'].show_hoolamike_menu(self)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Extracted from the legacy monolithic CLI system
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .main_menu import MainMenuHandler
|
from .main_menu import MainMenuHandler
|
||||||
from .tuxborn_menu import TuxbornMenuHandler
|
|
||||||
from .wabbajack_menu import WabbajackMenuHandler
|
from .wabbajack_menu import WabbajackMenuHandler
|
||||||
from .hoolamike_menu import HoolamikeMenuHandler
|
from .hoolamike_menu import HoolamikeMenuHandler
|
||||||
from .additional_menu import AdditionalMenuHandler
|
from .additional_menu import AdditionalMenuHandler
|
||||||
@@ -12,7 +11,6 @@ from .recovery_menu import RecoveryMenuHandler
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'MainMenuHandler',
|
'MainMenuHandler',
|
||||||
'TuxbornMenuHandler',
|
|
||||||
'WabbajackMenuHandler',
|
'WabbajackMenuHandler',
|
||||||
'HoolamikeMenuHandler',
|
'HoolamikeMenuHandler',
|
||||||
'AdditionalMenuHandler',
|
'AdditionalMenuHandler',
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
"""
|
|
||||||
Tuxborn Menu Handler for Jackify CLI Frontend
|
|
||||||
Extracted from src.modules.menu_handler.MenuHandler.show_tuxborn_installer_menu()
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from jackify.shared.colors import (
|
|
||||||
COLOR_SELECTION, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_WARNING
|
|
||||||
)
|
|
||||||
from jackify.shared.ui_utils import print_jackify_banner
|
|
||||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
|
||||||
|
|
||||||
class TuxbornMenuHandler:
|
|
||||||
"""
|
|
||||||
Handles the Tuxborn Automatic Installer workflow
|
|
||||||
Extracted from legacy MenuHandler class
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.logger = None # Will be set by CLI when needed
|
|
||||||
|
|
||||||
def show_tuxborn_installer_menu(self, cli_instance):
|
|
||||||
"""
|
|
||||||
Implements the Tuxborn Automatic Installer workflow.
|
|
||||||
Prompts for install path, downloads path, and Nexus API key, then runs the one-shot install from start to finish
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cli_instance: Reference to main CLI instance for access to handlers
|
|
||||||
"""
|
|
||||||
# Import backend service
|
|
||||||
from jackify.backend.core.modlist_operations import ModlistInstallCLI
|
|
||||||
|
|
||||||
print_jackify_banner()
|
|
||||||
print(f"{COLOR_SELECTION}Tuxborn Automatic Installer{COLOR_RESET}")
|
|
||||||
print(f"{COLOR_SELECTION}{'-'*32}{COLOR_RESET}")
|
|
||||||
print(f"{COLOR_INFO}This will install the Tuxborn modlist using the custom Jackify Install Engine in one automated flow.{COLOR_RESET}")
|
|
||||||
print(f"{COLOR_INFO}You will be prompted for the install location, downloads directory, and your Nexus API key.{COLOR_RESET}\n")
|
|
||||||
|
|
||||||
tuxborn_machineid = "Tuxborn/Tuxborn"
|
|
||||||
tuxborn_modlist_name = "Tuxborn"
|
|
||||||
|
|
||||||
# Prompt for install directory
|
|
||||||
print("----------------------------")
|
|
||||||
config_handler = ConfigHandler()
|
|
||||||
base_install_dir = Path(config_handler.get_modlist_install_base_dir())
|
|
||||||
default_install_dir = base_install_dir / "Skyrim" / "Tuxborn"
|
|
||||||
print(f"{COLOR_PROMPT}Please enter the directory you wish to use for Tuxborn installation.{COLOR_RESET}")
|
|
||||||
print(f"(Default: {default_install_dir})")
|
|
||||||
install_dir_result = self._get_directory_path_legacy(
|
|
||||||
cli_instance,
|
|
||||||
prompt_message=f"{COLOR_PROMPT}Install directory (Enter for default, 'q' to cancel): {COLOR_RESET}",
|
|
||||||
default_path=default_install_dir,
|
|
||||||
create_if_missing=True,
|
|
||||||
no_header=True
|
|
||||||
)
|
|
||||||
if not install_dir_result:
|
|
||||||
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
|
|
||||||
input("Press Enter to return to the main menu...")
|
|
||||||
return
|
|
||||||
if isinstance(install_dir_result, tuple):
|
|
||||||
install_dir, _ = install_dir_result # We'll use the path, creation handled by engine or later
|
|
||||||
else:
|
|
||||||
install_dir = install_dir_result
|
|
||||||
|
|
||||||
# Prompt for download directory
|
|
||||||
print("----------------------------")
|
|
||||||
base_download_dir = Path(config_handler.get_modlist_downloads_base_dir())
|
|
||||||
default_download_dir = base_download_dir / "Tuxborn"
|
|
||||||
print(f"{COLOR_PROMPT}Please enter the directory you wish to use for Tuxborn downloads.{COLOR_RESET}")
|
|
||||||
print(f"(Default: {default_download_dir})")
|
|
||||||
download_dir_result = self._get_directory_path_legacy(
|
|
||||||
cli_instance,
|
|
||||||
prompt_message=f"{COLOR_PROMPT}Download directory (Enter for default, 'q' to cancel): {COLOR_RESET}",
|
|
||||||
default_path=default_download_dir,
|
|
||||||
create_if_missing=True,
|
|
||||||
no_header=True
|
|
||||||
)
|
|
||||||
if not download_dir_result:
|
|
||||||
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
|
|
||||||
input("Press Enter to return to the main menu...")
|
|
||||||
return
|
|
||||||
if isinstance(download_dir_result, tuple):
|
|
||||||
download_dir, _ = download_dir_result # We'll use the path, creation handled by engine or later
|
|
||||||
else:
|
|
||||||
download_dir = download_dir_result
|
|
||||||
|
|
||||||
# Prompt for Nexus API key
|
|
||||||
print("----------------------------")
|
|
||||||
from jackify.backend.services.api_key_service import APIKeyService
|
|
||||||
api_key_service = APIKeyService()
|
|
||||||
saved_key = api_key_service.get_saved_api_key()
|
|
||||||
api_key = None
|
|
||||||
|
|
||||||
if saved_key:
|
|
||||||
print(f"{COLOR_INFO}A Nexus API Key is already saved.{COLOR_RESET}")
|
|
||||||
use_saved = input(f"{COLOR_PROMPT}Use the saved API key? [Y/n]: {COLOR_RESET}").strip().lower()
|
|
||||||
if use_saved in ('', 'y', 'yes'):
|
|
||||||
api_key = saved_key
|
|
||||||
else:
|
|
||||||
new_key = input(f"{COLOR_PROMPT}Enter a new Nexus API Key (or press Enter to keep the saved one): {COLOR_RESET}").strip()
|
|
||||||
if new_key:
|
|
||||||
api_key = new_key
|
|
||||||
replace = input(f"{COLOR_PROMPT}Replace the saved key with this one? [y/N]: {COLOR_RESET}").strip().lower()
|
|
||||||
if replace == 'y':
|
|
||||||
if api_key_service.save_api_key(api_key):
|
|
||||||
print(f"{COLOR_INFO}API key saved successfully.{COLOR_RESET}")
|
|
||||||
else:
|
|
||||||
print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}")
|
|
||||||
else:
|
|
||||||
print(f"{COLOR_INFO}Using new key for this session only. Saved key unchanged.{COLOR_RESET}")
|
|
||||||
else:
|
|
||||||
api_key = saved_key
|
|
||||||
else:
|
|
||||||
print(f"{COLOR_PROMPT}A Nexus Mods API key is required for downloading mods.{COLOR_RESET}")
|
|
||||||
print(f"{COLOR_INFO}You can get your personal key at: {COLOR_SELECTION}https://www.nexusmods.com/users/myaccount?tab=api{COLOR_RESET}")
|
|
||||||
print(f"{COLOR_WARNING}Your API Key is NOT saved locally. It is used only for this session unless you choose to save it.{COLOR_RESET}")
|
|
||||||
api_key = input(f"{COLOR_PROMPT}Enter Nexus API Key (or 'q' to cancel): {COLOR_RESET}").strip()
|
|
||||||
if not api_key or api_key.lower() == 'q':
|
|
||||||
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
|
|
||||||
input("Press Enter to return to the main menu...")
|
|
||||||
return
|
|
||||||
save = input(f"{COLOR_PROMPT}Would you like to save this API key for future use? [y/N]: {COLOR_RESET}").strip().lower()
|
|
||||||
if save == 'y':
|
|
||||||
if api_key_service.save_api_key(api_key):
|
|
||||||
print(f"{COLOR_INFO}API key saved successfully.{COLOR_RESET}")
|
|
||||||
else:
|
|
||||||
print(f"{COLOR_WARNING}Failed to save API key. Using for this session only.{COLOR_RESET}")
|
|
||||||
else:
|
|
||||||
print(f"{COLOR_INFO}Using API key for this session only. It will not be saved.{COLOR_RESET}")
|
|
||||||
|
|
||||||
# Context for ModlistInstallCLI
|
|
||||||
context = {
|
|
||||||
'machineid': tuxborn_machineid,
|
|
||||||
'modlist_name': tuxborn_modlist_name, # Will be used for shortcut name
|
|
||||||
'install_dir': install_dir_result, # Pass tuple (path, create_flag) or path
|
|
||||||
'download_dir': download_dir_result, # Pass tuple (path, create_flag) or path
|
|
||||||
'nexus_api_key': api_key,
|
|
||||||
'resolution': None
|
|
||||||
}
|
|
||||||
|
|
||||||
modlist_cli = ModlistInstallCLI(self, getattr(cli_instance, 'steamdeck', False))
|
|
||||||
|
|
||||||
# run_discovery_phase will use context_override, display summary, and ask for confirmation.
|
|
||||||
# If user confirms, it returns the context, otherwise None.
|
|
||||||
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
|
|
||||||
|
|
||||||
if confirmed_context:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.info("Tuxborn discovery confirmed by user. Proceeding to configuration/installation.")
|
|
||||||
# The modlist_cli instance now holds the confirmed context.
|
|
||||||
# configuration_phase will use modlist_cli.context
|
|
||||||
modlist_cli.configuration_phase()
|
|
||||||
# After configuration_phase, messages about success or next steps are handled within it or by _configure_new_modlist
|
|
||||||
else:
|
|
||||||
if self.logger:
|
|
||||||
self.logger.info("Tuxborn discovery/confirmation cancelled or failed.")
|
|
||||||
print(f"{COLOR_INFO}Tuxborn installation cancelled or not confirmed.{COLOR_RESET}")
|
|
||||||
input(f"{COLOR_PROMPT}Press Enter to return to the main menu...{COLOR_RESET}")
|
|
||||||
return
|
|
||||||
|
|
||||||
def _get_directory_path_legacy(self, cli_instance, prompt_message: str, default_path: Optional[Path],
|
|
||||||
create_if_missing: bool = True, no_header: bool = False) -> Optional[Path]:
|
|
||||||
"""
|
|
||||||
LEGACY BRIDGE: Delegate to legacy menu handler until full backend migration
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cli_instance: Reference to main CLI instance
|
|
||||||
prompt_message: The prompt to show user
|
|
||||||
default_path: Default path if user presses Enter
|
|
||||||
create_if_missing: Whether to create directory if it doesn't exist
|
|
||||||
no_header: Whether to skip header display
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path object or None if cancelled
|
|
||||||
"""
|
|
||||||
# LEGACY BRIDGE: Use the original menu handler's method
|
|
||||||
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'get_directory_path'):
|
|
||||||
return cli_instance.menu.get_directory_path(
|
|
||||||
prompt_message=prompt_message,
|
|
||||||
default_path=default_path,
|
|
||||||
create_if_missing=create_if_missing,
|
|
||||||
no_header=no_header
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback: simple input for now (will be replaced in future phases)
|
|
||||||
response = input(prompt_message).strip()
|
|
||||||
if response.lower() == 'q':
|
|
||||||
return None
|
|
||||||
elif response == '':
|
|
||||||
return default_path
|
|
||||||
else:
|
|
||||||
return Path(response)
|
|
||||||
@@ -131,7 +131,6 @@ DISCLAIMER_TEXT = (
|
|||||||
|
|
||||||
MENU_ITEMS = [
|
MENU_ITEMS = [
|
||||||
("Modlist Tasks", "modlist_tasks"),
|
("Modlist Tasks", "modlist_tasks"),
|
||||||
("Tuxborn Automatic Installer", "tuxborn_installer"),
|
|
||||||
("Hoolamike Tasks", "hoolamike_tasks"),
|
("Hoolamike Tasks", "hoolamike_tasks"),
|
||||||
("Additional Tasks", "additional_tasks"),
|
("Additional Tasks", "additional_tasks"),
|
||||||
("Exit Jackify", "exit_jackify"),
|
("Exit Jackify", "exit_jackify"),
|
||||||
@@ -162,6 +161,8 @@ class SettingsDialog(QDialog):
|
|||||||
try:
|
try:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||||
|
import logging
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
self.config_handler = ConfigHandler()
|
self.config_handler = ConfigHandler()
|
||||||
self._original_debug_mode = self.config_handler.get('debug_mode', False)
|
self._original_debug_mode = self.config_handler.get('debug_mode', False)
|
||||||
self.setWindowTitle("Settings")
|
self.setWindowTitle("Settings")
|
||||||
@@ -627,7 +628,18 @@ class SettingsDialog(QDialog):
|
|||||||
self.config_handler.set("proton_path", resolved_path)
|
self.config_handler.set("proton_path", resolved_path)
|
||||||
self.config_handler.set("proton_version", resolved_version)
|
self.config_handler.set("proton_version", resolved_version)
|
||||||
|
|
||||||
self.config_handler.save_config()
|
# Force immediate save and verify
|
||||||
|
save_result = self.config_handler.save_config()
|
||||||
|
if not save_result:
|
||||||
|
self.logger.error("Failed to save Proton configuration")
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Saved Proton config: path={resolved_path}, version={resolved_version}")
|
||||||
|
# Verify the save worked by reading it back
|
||||||
|
saved_path = self.config_handler.get("proton_path")
|
||||||
|
if saved_path != resolved_path:
|
||||||
|
self.logger.error(f"Config save verification failed: expected {resolved_path}, got {saved_path}")
|
||||||
|
else:
|
||||||
|
self.logger.debug("Config save verified successfully")
|
||||||
|
|
||||||
# Refresh cached paths in GUI screens if Jackify directory changed
|
# Refresh cached paths in GUI screens if Jackify directory changed
|
||||||
self._refresh_gui_paths()
|
self._refresh_gui_paths()
|
||||||
@@ -664,7 +676,6 @@ class SettingsDialog(QDialog):
|
|||||||
getattr(main_window, 'install_modlist_screen', None),
|
getattr(main_window, 'install_modlist_screen', None),
|
||||||
getattr(main_window, 'configure_new_modlist_screen', None),
|
getattr(main_window, 'configure_new_modlist_screen', None),
|
||||||
getattr(main_window, 'configure_existing_modlist_screen', None),
|
getattr(main_window, 'configure_existing_modlist_screen', None),
|
||||||
getattr(main_window, 'tuxborn_screen', None),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for screen in screens_to_refresh:
|
for screen in screens_to_refresh:
|
||||||
@@ -773,7 +784,7 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Create screens using refactored codebase
|
# Create screens using refactored codebase
|
||||||
from jackify.frontends.gui.screens import (
|
from jackify.frontends.gui.screens import (
|
||||||
MainMenu, TuxbornInstallerScreen, ModlistTasksScreen,
|
MainMenu, ModlistTasksScreen,
|
||||||
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen
|
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -785,31 +796,26 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
main_menu_index=0,
|
main_menu_index=0,
|
||||||
dev_mode=dev_mode
|
dev_mode=dev_mode
|
||||||
)
|
)
|
||||||
self.tuxborn_screen = TuxbornInstallerScreen(
|
self.install_modlist_screen = InstallModlistScreen(
|
||||||
stacked_widget=self.stacked_widget,
|
stacked_widget=self.stacked_widget,
|
||||||
main_menu_index=0
|
main_menu_index=0
|
||||||
)
|
)
|
||||||
self.install_modlist_screen = InstallModlistScreen(
|
|
||||||
stacked_widget=self.stacked_widget,
|
|
||||||
main_menu_index=3
|
|
||||||
)
|
|
||||||
self.configure_new_modlist_screen = ConfigureNewModlistScreen(
|
self.configure_new_modlist_screen = ConfigureNewModlistScreen(
|
||||||
stacked_widget=self.stacked_widget,
|
stacked_widget=self.stacked_widget,
|
||||||
main_menu_index=3
|
main_menu_index=0
|
||||||
)
|
)
|
||||||
self.configure_existing_modlist_screen = ConfigureExistingModlistScreen(
|
self.configure_existing_modlist_screen = ConfigureExistingModlistScreen(
|
||||||
stacked_widget=self.stacked_widget,
|
stacked_widget=self.stacked_widget,
|
||||||
main_menu_index=3
|
main_menu_index=0
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add screens to stacked widget
|
# Add screens to stacked widget
|
||||||
self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu
|
self.stacked_widget.addWidget(self.main_menu) # Index 0: Main Menu
|
||||||
self.stacked_widget.addWidget(self.tuxborn_screen) # Index 1: Tuxborn Installer
|
self.stacked_widget.addWidget(self.feature_placeholder) # Index 1: Placeholder
|
||||||
self.stacked_widget.addWidget(self.feature_placeholder) # Index 2: Placeholder
|
self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 2: Modlist Tasks
|
||||||
self.stacked_widget.addWidget(self.modlist_tasks_screen) # Index 3: Modlist Tasks
|
self.stacked_widget.addWidget(self.install_modlist_screen) # Index 3: Install Modlist
|
||||||
self.stacked_widget.addWidget(self.install_modlist_screen) # Index 4: Install Modlist
|
self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 4: Configure New
|
||||||
self.stacked_widget.addWidget(self.configure_new_modlist_screen) # Index 5: Configure New
|
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 5: Configure Existing
|
||||||
self.stacked_widget.addWidget(self.configure_existing_modlist_screen) # Index 6: Configure Existing
|
|
||||||
|
|
||||||
# Add debug tracking for screen changes
|
# Add debug tracking for screen changes
|
||||||
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
|
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
|
||||||
@@ -887,12 +893,11 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
|
|
||||||
screen_names = {
|
screen_names = {
|
||||||
0: "Main Menu",
|
0: "Main Menu",
|
||||||
1: "Tuxborn Installer",
|
1: "Feature Placeholder",
|
||||||
2: "Feature Placeholder",
|
2: "Modlist Tasks Menu",
|
||||||
3: "Modlist Tasks Menu",
|
3: "Install Modlist Screen",
|
||||||
4: "Install Modlist Screen",
|
4: "Configure New Modlist",
|
||||||
5: "Configure New Modlist",
|
5: "Configure Existing Modlist"
|
||||||
6: "Configure Existing Modlist"
|
|
||||||
}
|
}
|
||||||
screen_name = screen_names.get(index, f"Unknown Screen (Index {index})")
|
screen_name = screen_names.get(index, f"Unknown Screen (Index {index})")
|
||||||
widget = self.stacked_widget.widget(index)
|
widget = self.stacked_widget.widget(index)
|
||||||
@@ -1002,7 +1007,7 @@ class JackifyMainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Clean up screen processes
|
# Clean up screen processes
|
||||||
screens = [
|
screens = [
|
||||||
self.modlist_tasks_screen, self.tuxborn_screen, self.install_modlist_screen,
|
self.modlist_tasks_screen, self.install_modlist_screen,
|
||||||
self.configure_new_modlist_screen, self.configure_existing_modlist_screen
|
self.configure_new_modlist_screen, self.configure_existing_modlist_screen
|
||||||
]
|
]
|
||||||
for screen in screens:
|
for screen in screens:
|
||||||
@@ -1072,7 +1077,18 @@ def main():
|
|||||||
# Command-line --debug always takes precedence
|
# Command-line --debug always takes precedence
|
||||||
if '--debug' in sys.argv or '-d' in sys.argv:
|
if '--debug' in sys.argv or '-d' in sys.argv:
|
||||||
debug_mode = True
|
debug_mode = True
|
||||||
|
# Temporarily save CLI debug flag to config so engine can see it
|
||||||
|
config_handler.set('debug_mode', True)
|
||||||
|
print("[DEBUG] CLI --debug flag detected, saved debug_mode=True to config")
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
# Initialize file logging on root logger so all modules inherit it
|
||||||
|
from jackify.shared.logging import LoggingHandler
|
||||||
|
logging_handler = LoggingHandler()
|
||||||
|
# Rotate log file before setting up new logger
|
||||||
|
logging_handler.rotate_log_for_logger('jackify_gui', 'jackify-gui.log')
|
||||||
|
root_logger = logging_handler.setup_logger('', 'jackify-gui.log', is_general=True) # Empty name = root logger
|
||||||
|
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
print("[Jackify] Debug mode enabled (from config or CLI)")
|
print("[Jackify] Debug mode enabled (from config or CLI)")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Contains all the GUI screen components for Jackify.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .main_menu import MainMenu
|
from .main_menu import MainMenu
|
||||||
from .tuxborn_installer import TuxbornInstallerScreen
|
|
||||||
from .modlist_tasks import ModlistTasksScreen
|
from .modlist_tasks import ModlistTasksScreen
|
||||||
from .install_modlist import InstallModlistScreen
|
from .install_modlist import InstallModlistScreen
|
||||||
from .configure_new_modlist import ConfigureNewModlistScreen
|
from .configure_new_modlist import ConfigureNewModlistScreen
|
||||||
@@ -13,7 +12,6 @@ from .configure_existing_modlist import ConfigureExistingModlistScreen
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'MainMenu',
|
'MainMenu',
|
||||||
'TuxbornInstallerScreen',
|
|
||||||
'ModlistTasksScreen',
|
'ModlistTasksScreen',
|
||||||
'InstallModlistScreen',
|
'InstallModlistScreen',
|
||||||
'ConfigureNewModlistScreen',
|
'ConfigureNewModlistScreen',
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
self.shortcut_combo.addItem("Please Select...")
|
self.shortcut_combo.addItem("Please Select...")
|
||||||
self.shortcut_map = []
|
self.shortcut_map = []
|
||||||
for shortcut in self.mo2_shortcuts:
|
for shortcut in self.mo2_shortcuts:
|
||||||
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})"
|
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
|
||||||
self.shortcut_combo.addItem(display)
|
self.shortcut_combo.addItem(display)
|
||||||
self.shortcut_map.append(shortcut)
|
self.shortcut_map.append(shortcut)
|
||||||
|
|
||||||
@@ -427,8 +427,8 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
self._enable_controls_after_operation()
|
self._enable_controls_after_operation()
|
||||||
return
|
return
|
||||||
shortcut = self.shortcut_map[idx]
|
shortcut = self.shortcut_map[idx]
|
||||||
modlist_name = shortcut.get('AppName', '')
|
modlist_name = shortcut.get('AppName', shortcut.get('appname', ''))
|
||||||
install_dir = shortcut.get('StartDir', '')
|
install_dir = shortcut.get('StartDir', shortcut.get('startdir', ''))
|
||||||
if not modlist_name or not install_dir:
|
if not modlist_name or not install_dir:
|
||||||
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
|
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
|
||||||
self._enable_controls_after_operation()
|
self._enable_controls_after_operation()
|
||||||
@@ -710,7 +710,7 @@ class ConfigureExistingModlistScreen(QWidget):
|
|||||||
self.shortcut_map.clear()
|
self.shortcut_map.clear()
|
||||||
|
|
||||||
for shortcut in self.mo2_shortcuts:
|
for shortcut in self.mo2_shortcuts:
|
||||||
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})"
|
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
|
||||||
self.shortcut_combo.addItem(display)
|
self.shortcut_combo.addItem(display)
|
||||||
self.shortcut_map.append(shortcut)
|
self.shortcut_map.append(shortcut)
|
||||||
|
|
||||||
|
|||||||
@@ -481,7 +481,7 @@ class ConfigureNewModlistScreen(QWidget):
|
|||||||
|
|
||||||
def go_back(self):
|
def go_back(self):
|
||||||
if self.stacked_widget:
|
if self.stacked_widget:
|
||||||
self.stacked_widget.setCurrentIndex(3) # Return to Modlist Tasks menu
|
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||||
|
|
||||||
def update_top_panel(self):
|
def update_top_panel(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1057,7 +1057,7 @@ class InstallModlistScreen(QWidget):
|
|||||||
|
|
||||||
def go_back(self):
|
def go_back(self):
|
||||||
if self.stacked_widget:
|
if self.stacked_widget:
|
||||||
self.stacked_widget.setCurrentIndex(3) # Return to Modlist Tasks menu
|
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||||
|
|
||||||
def update_top_panel(self):
|
def update_top_panel(self):
|
||||||
try:
|
try:
|
||||||
@@ -1746,7 +1746,7 @@ class InstallModlistScreen(QWidget):
|
|||||||
|
|
||||||
# Save resolution for later use in configuration
|
# Save resolution for later use in configuration
|
||||||
resolution = self.resolution_combo.currentText()
|
resolution = self.resolution_combo.currentText()
|
||||||
self._current_resolution = resolution.split()[0] if resolution != "Leave unchanged" else "2560x1600"
|
self._current_resolution = resolution.split()[0] if resolution != "Leave unchanged" else None
|
||||||
|
|
||||||
# Use automated prefix creation instead of manual steps
|
# Use automated prefix creation instead of manual steps
|
||||||
debug_print("DEBUG: Starting automated prefix creation workflow")
|
debug_print("DEBUG: Starting automated prefix creation workflow")
|
||||||
@@ -2321,7 +2321,7 @@ class InstallModlistScreen(QWidget):
|
|||||||
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
||||||
'modlist_value': None,
|
'modlist_value': None,
|
||||||
'modlist_source': None,
|
'modlist_source': None,
|
||||||
'resolution': getattr(self, '_current_resolution', '2560x1600'),
|
'resolution': getattr(self, '_current_resolution', None),
|
||||||
'skip_confirmation': True,
|
'skip_confirmation': True,
|
||||||
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
||||||
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
||||||
@@ -2360,7 +2360,7 @@ class InstallModlistScreen(QWidget):
|
|||||||
nexus_api_key='', # Not needed for configuration
|
nexus_api_key='', # Not needed for configuration
|
||||||
modlist_value=self.context.get('modlist_value'),
|
modlist_value=self.context.get('modlist_value'),
|
||||||
modlist_source=self.context.get('modlist_source', 'identifier'),
|
modlist_source=self.context.get('modlist_source', 'identifier'),
|
||||||
resolution=self.context.get('resolution', '2560x1600'),
|
resolution=self.context.get('resolution'),
|
||||||
skip_confirmation=True,
|
skip_confirmation=True,
|
||||||
engine_installed=True # Skip path manipulation for engine workflows
|
engine_installed=True # Skip path manipulation for engine workflows
|
||||||
)
|
)
|
||||||
@@ -2419,7 +2419,7 @@ class InstallModlistScreen(QWidget):
|
|||||||
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
||||||
'modlist_value': None,
|
'modlist_value': None,
|
||||||
'modlist_source': None,
|
'modlist_source': None,
|
||||||
'resolution': getattr(self, '_current_resolution', '2560x1600'),
|
'resolution': getattr(self, '_current_resolution', None),
|
||||||
'skip_confirmation': True,
|
'skip_confirmation': True,
|
||||||
'manual_steps_completed': True, # Mark as completed
|
'manual_steps_completed': True, # Mark as completed
|
||||||
'appid': new_appid # Use the NEW AppID from Steam
|
'appid': new_appid # Use the NEW AppID from Steam
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class MainMenu(QWidget):
|
|||||||
msg.setIcon(QMessageBox.Information)
|
msg.setIcon(QMessageBox.Information)
|
||||||
msg.exec()
|
msg.exec()
|
||||||
elif action_id == "modlist_tasks" and self.stacked_widget:
|
elif action_id == "modlist_tasks" and self.stacked_widget:
|
||||||
self.stacked_widget.setCurrentIndex(3)
|
self.stacked_widget.setCurrentIndex(2)
|
||||||
elif action_id == "return_main_menu":
|
elif action_id == "return_main_menu":
|
||||||
# This is the main menu, so do nothing
|
# This is the main menu, so do nothing
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -198,11 +198,11 @@ class ModlistTasksScreen(QWidget):
|
|||||||
if action_id == "return_main_menu":
|
if action_id == "return_main_menu":
|
||||||
self.stacked_widget.setCurrentIndex(0)
|
self.stacked_widget.setCurrentIndex(0)
|
||||||
elif action_id == "install_modlist":
|
elif action_id == "install_modlist":
|
||||||
self.stacked_widget.setCurrentIndex(4)
|
self.stacked_widget.setCurrentIndex(3)
|
||||||
elif action_id == "configure_new_modlist":
|
elif action_id == "configure_new_modlist":
|
||||||
self.stacked_widget.setCurrentIndex(5)
|
self.stacked_widget.setCurrentIndex(4)
|
||||||
elif action_id == "configure_existing_modlist":
|
elif action_id == "configure_existing_modlist":
|
||||||
self.stacked_widget.setCurrentIndex(6)
|
self.stacked_widget.setCurrentIndex(5)
|
||||||
|
|
||||||
def go_back(self):
|
def go_back(self):
|
||||||
"""Return to main menu"""
|
"""Return to main menu"""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
BIN
jackify/tools/cabextract
Executable file
BIN
jackify/tools/cabextract
Executable file
Binary file not shown.
19627
jackify/tools/winetricks
Executable file
19627
jackify/tools/winetricks
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user