15 Commits

Author SHA1 Message Date
Omni
9680814bbb Sync from development - prepare for v0.1.7 2025-11-04 12:54:15 +00:00
Omni
91ac08afb2 Sync from development - prepare for v0.1.6.6 2025-10-29 10:28:33 +00:00
Omni
06bd94d119 Sync from development - prepare for v0.1.6.5 2025-10-28 21:18:54 +00:00
Omni
52806f4116 Sync from development - prepare for v0.1.6.4 2025-10-24 20:12:21 +01:00
Omni
956ea24465 Sync from development - prepare for v0.1.6.3 2025-10-23 23:53:18 +01:00
Omni
f039cf9c24 Sync from development - prepare for v0.1.6.2 2025-10-23 21:50:28 +01:00
Omni
d9ea1be347 Sync from development - prepare for v0.1.6.1 2025-10-21 21:11:48 +01:00
Omni
a8862475d4 Sync from development - prepare for v0.1.6.1 2025-10-21 21:07:42 +01:00
Omni
430d085287 Sync from development - prepare for v0.1.6 2025-10-16 14:44:49 +01:00
Omni
7212a58480 Sync from development - prepare for v0.1.5.3 2025-10-02 21:59:01 +01:00
Omni
80914bc76f Sync from development - prepare for v0.1.5.2 2025-10-01 22:11:14 +01:00
Omni
8661f8963e Sync from development - prepare for v0.1.5.1 2025-09-28 12:15:44 +01:00
Omni
f46ed2c0fe Sync from development - prepare for v0.1.5 2025-09-26 12:45:21 +01:00
Omni
c9bd6f60e6 Sync from development - prepare for v0.1.4 2025-09-22 20:39:58 +01:00
Omni
28cde64887 Sync from development - prepare for v0.1.2 2025-09-18 10:41:16 +01:00
93 changed files with 28520 additions and 3467 deletions

2
.gitignore vendored
View File

@@ -35,7 +35,7 @@ Thumbs.db
docs/
testing/
# PyInstaller build files (development only)
# Build files (development only)
*.spec
hook-*.py
requirements-packaging.txt

View File

@@ -1,5 +1,221 @@
# Jackify Changelog
## v0.1.7 - TTW Automation & Bug Fixes
**Release Date:** November 1, 2025
### Major Features
- **TTW (Tale of Two Wastelands) Installation and Automation**
- TTW Installation function using Hoolamike application - https://github.com/Niedzwiedzw/hoolamike
- Automated workflow for TTW installation and integration into FNV modlists, where possible
- Automatic detection of TTW-compatible modlists
- User prompt after modlist installation with option to install TTW
- Automated integration: file copying, load order updates, modlist.txt updates
- Available in both CLI and GUI workflows
### Bug Fixes
- **Registry UTF-8 Decode Error**: Fixed crash during dotnet4.x installation when Wine outputs binary data
- **Python 3.10 Compatibility**: Fixed startup crash on Python 3.10 systems
- **TTW Steam Deck Layout**: Fixed window sizing issues on Steam Deck when entering/exiting TTW screen
- **TTW Integration Status**: Added visible status banner updates during modlist integration for collapsed mode
- **TTW Accidental Input Protection**: Added 3-second countdown to TTW installation prompt to prevent accidental dismissal
- **Settings Persistence**: Settings changes now persist correctly across workflows
- **Steam Deck Keyboard Input**: Fixed keyboard input failure on Steam Deck
- **Application Close Crash**: Fixed crash when closing application on Steam Deck
- **Winetricks Diagnostics**: Enhanced error detection with automatic fallback
---
## v0.1.6.6 - AppImage Bundling Fix
**Release Date:** October 29, 2025
### Bug Fixes
- **Fixed AppImage bundling issue** causing legacy code to be retained in rare circumstances
---
## v0.1.6.5 - Steam Deck SD Card Path Fix
**Release Date:** October 27, 2025
### Bug Fixes
- **Fixed Steam Deck SD card path manipulation** when jackify-engine installed
- **Fixed Ubuntu Qt platform plugin errors** by bundling XCB libraries
- **Added Flatpak GE-Proton detection** and protontricks installation choices
- **Extended Steam Deck SD card timeouts** for slower I/O operations
---
## v0.1.6.4 - Flatpak Steam Detection Hotfix
**Release Date:** October 24, 2025
### Critical Bug Fixes
- **FIXED: Flatpak Steam Detection**: Added support for `/data/Steam/` directory structure used by some Flatpak Steam installations
- **IMPROVED: Steam Path Detection**: Now checks all known Flatpak Steam directory structures for maximum compatibility
---
## v0.1.6.3 - Emergency Hotfix
**Release Date:** October 23, 2025
### Critical Bug Fixes
- **FIXED: Proton Detection for Custom Steam Libraries**: Now properly reads all Steam libraries from libraryfolders.vdf
- **IMPROVED: Registry Wine Binary Detection**: Uses user's configured Proton for better compatibility
- **IMPROVED: Error Handling**: Registry fixes now provide clear warnings if they fail instead of breaking entire workflow
---
## v0.1.6.2 - Minor Bug Fixes
**Release Date:** October 23, 2025
### Bug Fixes
- **Improved dotnet4.x Compatibility**: Universal registry fixes for better modlist compatibility
- **Fixed Proton 9 Override**: A bug meant that modlists with spaces in the name weren't being overridden correctly
- **Removed PageFileManager Plugin**: Eliminates Linux PageFile warnings
---
## v0.1.6.1 - Fix dotnet40 install and expand Game Proton override
**Release Date:** October 21, 2025
### Bug Fixes
- **Fixed dotnet40 Installation Failures**: Resolved widespread .NET Framework installation issues affecting multiple modlists
- **Added Lost Legacy Proton 9 Override**: Automatic ENB compatibility for Lost Legacy modlist
- **Fixed Symlinked Downloads**: Automatically handles symlinked download directories to avoid Wine compatibility issues
---
## v0.1.6 - Lorerim Proton Support
**Release Date:** October 16, 2025
### New Features
- **Lorerim Proton Override**: Automatically selects Proton 9 for Lorerim installations (GE-Proton9-27 preferred)
- **Engine Update**: jackify-engine v0.3.17
---
## v0.1.5.3 - Critical Bug Fixes
**Release Date:** October 2, 2025
### Critical Bug Fixes
- **Fixed Multi-User Steam Detection**: Properly reads loginusers.vdf and converts SteamID64 to SteamID3 for accurate user identification
- **Fixed dotnet40 Installation Failures**: Hybrid approach uses protontricks for dotnet40 (reliable), winetricks for other components (fast)
- **Fixed dotnet8 Installation**: Now properly handled by winetricks instead of unimplemented pass statement
- **Fixed D: Drive Detection**: SD card detection now only applies to Steam Deck systems, not regular Linux systems
- **Fixed SD Card Mount Patterns**: Replaced hardcoded mmcblk0p1 references with dynamic path detection
- **Fixed Debug Restart UX**: Replaced PyInstaller detection with AppImage detection for proper restart behavior
---
## v0.1.5.2 - Proton Configuration & Engine Updates
**Release Date:** September 30, 2025
### Critical Bug Fixes
- **Fixed Proton Version Selection**: Wine component installation now properly honors user-selected Proton version from Settings dialog
- Previously, changing from GE-Proton to Proton Experimental in settings would still use the old version for component installation
- Fixed ConfigHandler to reload fresh configuration from disk instead of using stale cache
- Updated all Proton path retrieval across codebase to use fresh-reading methods
### Engine Updates
- **jackify-engine v0.3.16**: Updated to latest engine version with important reliability improvements
- **Sanity Check Fallback**: Added Proton 7z.exe fallback for case sensitivity extraction failures
- **Enhanced Error Messages**: Improved texconv/texdiag error messages to include original texture file names and conversion parameters
### Technical Improvements
- Enhanced configuration system reliability for multi-instance scenarios
- Improved error diagnostics for texture processing operations
- Fix Qt platform plugin discovery in AppImage distribution for improved compatibility
---
## v0.1.5.1 - Bug Fixes
**Release Date:** September 28, 2025
### Bug Fixes
- Fixed Steam user detection in multi-user environments
- Fixed controls not re-enabling after workflow errors
- Fixed screen state persistence between workflows
---
## 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
#Y- **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
**Release Date:** September 22, 2025
### New Features
- **GE-Proton Detection**: Automatic detection and prioritization of GE-Proton versions
- **User-selectable Proton version**: Settings dialog displays all available Proton versions with type indicators
### Engine Updates
- **jackify-engine v0.3.15**: Reads Proton configuration from config.json, adds degree symbol handling for special characters, removes Wine fallback (Proton now required)
### Technical Improvements
- **Smart Priority**: GE-Proton 10+ → Proton Experimental → Proton 10 → Proton 9
- **Auto-Configuration**: Fresh installations automatically select optimal Proton version
### Bug Fixes
- **Steam VDF Compatibility**: Fixed case-sensitivity issues with Steam shortcuts.vdf parsing for Configure Existing Modlist workflows
---
## v0.1.3 - Enhanced Proton Support and System Compatibility
**Release Date:** September 21, 2025
### New Features
- **Enhanced Proton Detection**: Automatic fallback system with priority: Experimental → Proton 10 → Proton 9
- **Guided Proton Installation**: Professional auto-install dialog with Steam protocol integration for missing Proton versions
- **Enderal Game Support**: Added Enderal to supported games list with special handling for Somnium modlist structure
- **Proton Version Leniency**: Accept any Proton version 9+ instead of requiring Experimental
### UX Improvements
- **Resolution System Overhaul**: Eliminated hardcoded 2560x1600 fallbacks across all screens
- **Steam Deck Detection**: Proper 1280x800 default resolution with 1920x1080 fallback for desktop
- **Leave Unchanged Logic**: Fixed resolution setting to actually preserve existing user configurations
### Technical Improvements
- **Resolution Utilities**: New `shared/resolution_utils.py` with centralized resolution management
- **Protontricks Detection**: Enhanced detection for both native and Flatpak protontricks installations
- **Real-time Monitoring**: Progress tracking for Proton installation with directory stability detection
### Bug Fixes
- **Somnium Support**: Automatic detection of `files/ModOrganizer.exe` structure in edge-case modlists
- **Steam Protocol Integration**: Reliable triggering of Proton installation via `steam://install/` URLs
- **Manual Fallback**: Clear instructions and recheck functionality when auto-install fails
---
## v0.1.2 - About Dialog and System Information
**Release Date:** September 16, 2025

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.1.2"
__version__ = "0.1.7"

View File

@@ -23,6 +23,44 @@ from jackify.backend.handlers.config_handler import ConfigHandler
# UI Colors already imported above
def _get_user_proton_version():
"""Get user's preferred Proton version from config, with fallback to auto-detection"""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
config_handler = ConfigHandler()
user_proton_path = config_handler.get_proton_path()
if user_proton_path == 'auto':
# Use enhanced fallback logic with GE-Proton preference
logging.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence")
return WineUtils.select_best_proton()
else:
# User has selected a specific Proton version
# Use the exact directory name for Steam config.vdf
try:
proton_version = os.path.basename(user_proton_path)
# GE-Proton uses exact directory name, Valve Proton needs lowercase conversion
if proton_version.startswith('GE-Proton'):
# Keep GE-Proton name exactly as-is
steam_proton_name = proton_version
else:
# Convert Valve Proton names to Steam's format
steam_proton_name = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
if not steam_proton_name.startswith('proton'):
steam_proton_name = f"proton_{steam_proton_name}"
logging.info(f"Using user-selected Proton: {steam_proton_name}")
return steam_proton_name
except Exception as e:
logging.warning(f"Invalid user Proton path '{user_proton_path}', falling back to auto: {e}")
return WineUtils.select_best_proton()
except Exception as e:
logging.error(f"Failed to get user Proton preference, using default: {e}")
return "proton_experimental"
# Attempt to import readline for tab completion
READLINE_AVAILABLE = False
try:
@@ -118,7 +156,7 @@ class ModlistInstallCLI:
from ..models.configuration import SystemInfo
self.system_info = SystemInfo(is_steamdeck=steamdeck)
self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck)
self.protontricks_handler = ProtontricksHandler(self.steamdeck)
self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck)
self.context = {}
# Use standard logging (no file handler)
@@ -692,6 +730,14 @@ class ModlistInstallCLI:
cmd += ['-m', self.context['machineid']]
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
original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
@@ -1248,13 +1294,16 @@ class ModlistInstallCLI:
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
# Get user's preferred Proton version
proton_version = _get_user_proton_version()
success, app_id = steam_service.create_shortcut_with_proton(
app_name=config_context['name'],
exe_path=config_context['mo2_exe_path'],
start_dir=os.path.dirname(config_context['mo2_exe_path']),
launch_options="%command%",
tags=["Jackify"],
proton_version="proton_experimental"
proton_version=proton_version
)
if not success or not app_id:

View File

@@ -0,0 +1,3 @@
"""
Data package for static configuration and reference data.
"""

View File

@@ -0,0 +1,46 @@
"""
TTW-Compatible Modlists Configuration
Defines which Fallout New Vegas modlists support Tale of Two Wastelands.
This whitelist determines when Jackify should offer TTW installation after
a successful modlist installation.
"""
TTW_COMPATIBLE_MODLISTS = {
# Exact modlist names that support/require TTW
"exact_matches": [
"Begin Again",
"Uranium Fever",
"The Badlands",
"Wild Card TTW",
],
# Pattern matching for modlist names (regex)
"patterns": [
r".*TTW.*", # Any modlist with TTW in name
r".*Tale.*Two.*Wastelands.*",
]
}
def is_ttw_compatible(modlist_name: str) -> bool:
"""Check if modlist name matches TTW compatibility criteria
Args:
modlist_name: Name of the modlist to check
Returns:
bool: True if modlist is TTW-compatible, False otherwise
"""
import re
# Check exact matches
if modlist_name in TTW_COMPATIBLE_MODLISTS['exact_matches']:
return True
# Check pattern matches
for pattern in TTW_COMPATIBLE_MODLISTS['patterns']:
if re.match(pattern, modlist_name, re.IGNORECASE):
return True
return False

View File

@@ -20,10 +20,23 @@ logger = logging.getLogger(__name__)
class ConfigHandler:
"""
Handles application configuration and settings
Singleton pattern ensures all code shares the same instance
"""
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super(ConfigHandler, cls).__new__(cls)
return cls._instance
def __init__(self):
"""Initialize configuration handler with default settings"""
# Only initialize once (singleton pattern)
if ConfigHandler._initialized:
return
ConfigHandler._initialized = True
self.config_dir = os.path.expanduser("~/.config/jackify")
self.config_file = os.path.join(self.config_dir, "config.json")
self.settings = {
@@ -38,17 +51,22 @@ class ConfigHandler:
"default_download_parent_dir": None, # Parent directory for downloads
"modlist_install_base_dir": os.path.expanduser("~/Games"), # Configurable base directory for modlist installations
"modlist_downloads_base_dir": os.path.expanduser("~/Games/Modlist_Downloads"), # Configurable base directory for downloads
"jackify_data_dir": None # Configurable Jackify data directory (default: ~/Jackify)
"jackify_data_dir": None, # Configurable Jackify data directory (default: ~/Jackify)
"use_winetricks_for_components": True, # True = use winetricks (faster), False = use protontricks for all (legacy)
"game_proton_path": None # Proton version for game shortcuts (can be any Proton 9+), separate from install proton
}
# Load configuration if exists
self._load_config()
# If steam_path is not set, detect it
if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path()
# Save the updated settings
self.save_config()
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
# Do NOT overwrite user's saved settings!
if not os.path.exists(self.config_file) and not self.settings.get("proton_path"):
self._auto_detect_proton()
# If jackify_data_dir is not set, initialize it to default
if not self.settings.get("jackify_data_dir"):
@@ -109,6 +127,10 @@ class ConfigHandler:
self._create_config_dir()
except Exception as e:
logger.error(f"Error loading configuration: {e}")
def reload_config(self):
"""Reload configuration from disk to pick up external changes"""
self._load_config()
def _create_config_dir(self):
"""Create configuration directory if it doesn't exist"""
@@ -494,4 +516,88 @@ class ConfigHandler:
logger.error(f"Error saving modlist downloads base directory: {e}")
return False
def get_proton_path(self):
"""
Retrieve the saved Install Proton path from configuration (for jackify-engine)
Always reads fresh from disk to pick up changes from Settings dialog
Returns:
str: Saved Install Proton path or 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
proton_path = self.settings.get("proton_path", "auto")
logger.debug(f"Retrieved fresh install proton_path from config: {proton_path}")
return proton_path
except Exception as e:
logger.error(f"Error retrieving install proton_path: {e}")
return "auto"
def get_game_proton_path(self):
"""
Retrieve the saved Game Proton path from configuration (for game shortcuts)
Falls back to install Proton path if game Proton not set
Always reads fresh from disk to pick up changes from Settings dialog
Returns:
str: Saved Game Proton path, Install Proton path, or 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
game_proton_path = self.settings.get("game_proton_path")
# If game proton not set or set to same_as_install, use install proton
if not game_proton_path or game_proton_path == "same_as_install":
game_proton_path = self.settings.get("proton_path", "auto")
logger.debug(f"Retrieved fresh game proton_path from config: {game_proton_path}")
return game_proton_path
except Exception as e:
logger.error(f"Error retrieving game proton_path: {e}")
return "auto"
def get_proton_version(self):
"""
Retrieve the saved Proton version from configuration
Always reads fresh from disk to pick up changes from Settings dialog
Returns:
str: Saved Proton version or 'auto' if not saved
"""
try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
proton_version = self.settings.get("proton_version", "auto")
logger.debug(f"Retrieved fresh proton_version from config: {proton_version}")
return proton_version
except Exception as e:
logger.error(f"Error retrieving proton_version: {e}")
return "auto"
def _auto_detect_proton(self):
"""Auto-detect and set best Proton version (includes GE-Proton and Valve Proton)"""
try:
from .wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
self.settings["proton_path"] = str(best_proton['path'])
self.settings["proton_version"] = best_proton['name']
proton_type = best_proton.get('type', 'Unknown')
logger.info(f"Auto-detected Proton: {best_proton['name']} ({proton_type})")
self.save_config()
else:
# Fallback to auto-detect mode
self.settings["proton_path"] = "auto"
self.settings["proton_version"] = "auto"
logger.info("No compatible Proton versions found, using auto-detect mode")
self.save_config()
except Exception as e:
logger.error(f"Failed to auto-detect Proton: {e}")
self.settings["proton_path"] = "auto"
self.settings["proton_version"] = "auto"

View File

@@ -15,7 +15,7 @@ from .filesystem_handler import FileSystemHandler
from .config_handler import ConfigHandler
# Import color constants needed for print statements in this module
from .ui_colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING, COLOR_RESET, COLOR_INFO, COLOR_PROMPT, COLOR_SELECTION
# Standard logging (no file handler) - LoggingHandler import removed
from .logging_handler import LoggingHandler
from .status_utils import show_status, clear_status
from .subprocess_utils import get_clean_subprocess_env
@@ -55,8 +55,10 @@ class HoolamikeHandler:
self.filesystem_handler = filesystem_handler
self.config_handler = config_handler
self.menu_handler = menu_handler
# Use standard logging (no file handler)
self.logger = logging.getLogger(__name__)
# Set up dedicated log file for TTW operations
logging_handler = LoggingHandler()
logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log')
self.logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log')
# --- Discovered/Managed State ---
self.game_install_paths: Dict[str, Path] = {}
@@ -213,7 +215,7 @@ class HoolamikeHandler:
if not self.hoolamike_config.get("games"):
f.write("# No games were detected by Jackify. Add game paths manually if needed.\n")
# Dump the actual YAML
yaml.dump(self.hoolamike_config, f, default_flow_style=False, sort_keys=False)
yaml.dump(self.hoolamike_config, f, default_flow_style=False, sort_keys=False, width=float('inf'))
self.logger.info("Configuration saved successfully.")
return True
except Exception as e:
@@ -224,9 +226,12 @@ class HoolamikeHandler:
"""Execute all discovery steps."""
self.logger.info("Starting Hoolamike feature discovery phase...")
# Check if Hoolamike is installed
self._check_hoolamike_installation()
# Detect game paths and update internal state + config
self._detect_and_update_game_paths()
self.logger.info("Hoolamike discovery phase complete.")
def _detect_and_update_game_paths(self):
@@ -242,22 +247,143 @@ class HoolamikeHandler:
self.logger.debug("Updating loaded hoolamike.yaml with detected game paths.")
if "games" not in self.hoolamike_config or not isinstance(self.hoolamike_config.get("games"), dict):
self.hoolamike_config["games"] = {} # Ensure games section exists
# Define a unified format for game names in config - no spaces
# Clear existing entries first to avoid duplicates
self.hoolamike_config["games"] = {}
# Add detected paths with proper formatting - no spaces
for game_name, detected_path in detected_paths.items():
formatted_name = self._format_game_name(game_name)
self.hoolamike_config["games"][formatted_name] = {"root_directory": str(detected_path)}
self.logger.info(f"Updated config with {len(detected_paths)} game paths using correct naming format (no spaces)")
# Save the updated config to disk so Hoolamike can read it
if detected_paths:
self.logger.info("Saving updated game paths to hoolamike.yaml")
self.save_hoolamike_config()
else:
self.logger.warning("Cannot update game paths in config because config is not loaded.")
# --- Methods for Hoolamike Tasks (To be implemented later) ---
# TODO: Update these methods to accept necessary parameters and update/save config
# --- Methods for Hoolamike Tasks ---
# GUI-safe, non-interactive installer used by Install TTW screen
def install_hoolamike(self, install_dir: Optional[Path] = None) -> tuple[bool, str]:
"""Non-interactive install/update of Hoolamike for GUI usage.
Downloads the latest Linux x86_64 release from GitHub, extracts it to the
Jackify-managed directory (~/Jackify/Hoolamike by default or provided install_dir),
sets executable permissions, and saves the install path to Jackify config.
Returns:
(success, message)
"""
try:
self._ensure_hoolamike_dirs_exist()
# Determine target install directory
target_dir = Path(install_dir) if install_dir else self.hoolamike_app_install_path
target_dir.mkdir(parents=True, exist_ok=True)
# Fetch latest release info
release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest"
self.logger.info(f"Fetching latest Hoolamike release info from {release_url}")
resp = requests.get(release_url, timeout=15, verify=True)
resp.raise_for_status()
data = resp.json()
release_tag = data.get("tag_name") or data.get("name")
linux_asset = None
for asset in data.get("assets", []):
name = asset.get("name", "").lower()
if "linux" in name and (name.endswith(".tar.gz") or name.endswith(".tgz") or name.endswith(".zip")) and ("x86_64" in name or "amd64" in name):
linux_asset = asset
break
if not linux_asset:
return False, "No suitable Linux x86_64 Hoolamike asset found in latest release"
download_url = linux_asset.get("browser_download_url")
asset_name = linux_asset.get("name")
if not download_url or not asset_name:
return False, "Latest release is missing required asset metadata"
# Download to target directory
temp_path = target_dir / asset_name
if not self.filesystem_handler.download_file(download_url, temp_path, overwrite=True, quiet=True):
return False, "Failed to download Hoolamike asset"
# Extract
try:
if asset_name.lower().endswith((".tar.gz", ".tgz")):
with tarfile.open(temp_path, "r:*") as tar:
tar.extractall(path=target_dir)
elif asset_name.lower().endswith(".zip"):
with zipfile.ZipFile(temp_path, "r") as zf:
zf.extractall(target_dir)
else:
return False, f"Unknown archive format: {asset_name}"
finally:
try:
temp_path.unlink(missing_ok=True) # cleanup
except Exception:
pass
# Ensure executable bit on binary
exe_path = target_dir / HOOLAMIKE_EXECUTABLE_NAME
if not exe_path.is_file():
# Some archives may include a subfolder; try to locate the binary
for p in target_dir.rglob(HOOLAMIKE_EXECUTABLE_NAME):
if p.is_file():
exe_path = p
break
if not exe_path.is_file():
return False, "Hoolamike binary not found after extraction"
try:
os.chmod(exe_path, 0o755)
except Exception as e:
self.logger.warning(f"Failed to chmod +x on {exe_path}: {e}")
# Mark installed and persist path
self.hoolamike_app_install_path = target_dir
self.hoolamike_executable_path = exe_path
self.hoolamike_installed = True
self.config_handler.set('hoolamike_install_path', str(target_dir))
if release_tag:
self.config_handler.set('hoolamike_version', str(release_tag))
self.config_handler.save_config()
return True, f"Hoolamike installed at {target_dir}"
except Exception as e:
self.logger.error("Hoolamike installation failed", exc_info=True)
return False, f"Error installing Hoolamike: {e}"
def get_installed_hoolamike_version(self) -> Optional[str]:
"""Return the installed Hoolamike version stored in Jackify config, if any."""
try:
v = self.config_handler.get('hoolamike_version')
return str(v) if v else None
except Exception:
return None
def is_hoolamike_update_available(self) -> tuple[bool, Optional[str], Optional[str]]:
"""
Check GitHub for the latest Hoolamike release and compare with installed version.
Returns (update_available, installed_version, latest_version).
"""
installed = self.get_installed_hoolamike_version()
try:
release_url = "https://api.github.com/repos/Niedzwiedzw/hoolamike/releases/latest"
resp = requests.get(release_url, timeout=10, verify=True)
resp.raise_for_status()
latest = resp.json().get('tag_name') or resp.json().get('name')
if not latest:
return (False, installed, None)
if not installed:
# No version recorded but installed may exist; treat as update available
return (True, None, latest)
return (installed != str(latest), installed, str(latest))
except Exception:
return (False, installed, None)
def install_update_hoolamike(self, context=None) -> bool:
"""Install or update Hoolamike application.
@@ -654,18 +780,89 @@ class HoolamikeHandler:
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return False
def install_ttw(self, ttw_mpi_path=None, ttw_output_path=None, context=None):
"""Install Tale of Two Wastelands (TTW) using Hoolamike.
def install_ttw_backend(self, ttw_mpi_path, ttw_output_path):
"""Clean backend function for TTW installation - no user interaction.
Args:
ttw_mpi_path: Path to the TTW installer .mpi file
ttw_output_path: Target installation directory for TTW
ttw_mpi_path: Path to the TTW installer .mpi file (required)
ttw_output_path: Target installation directory for TTW (required)
Returns:
tuple: (success: bool, message: str)
"""
self.logger.info(f"Starting Tale of Two Wastelands installation via Hoolamike")
# Validate required parameters
if not ttw_mpi_path or not ttw_output_path:
return False, "Missing required parameters: ttw_mpi_path and ttw_output_path are required"
# Convert to Path objects
ttw_mpi_path = Path(ttw_mpi_path)
ttw_output_path = Path(ttw_output_path)
# Validate paths exist
if not ttw_mpi_path.exists():
return False, f"TTW .mpi file not found: {ttw_mpi_path}"
if not ttw_output_path.exists():
try:
ttw_output_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
return False, f"Failed to create output directory: {e}"
# Check Hoolamike installation
self._check_hoolamike_installation()
# Ensure config is loaded
if self.hoolamike_config is None:
loaded = self._load_hoolamike_config()
if not loaded or self.hoolamike_config is None:
self.logger.error("Failed to load or generate hoolamike.yaml configuration.")
return False, "Failed to load or generate Hoolamike configuration"
# Verify required games are detected
required_games = ['Fallout 3', 'Fallout New Vegas']
detected_games = self.path_handler.find_vanilla_game_paths()
missing_games = [game for game in required_games if game not in detected_games]
if missing_games:
self.logger.error(f"Missing required games for TTW installation: {', '.join(missing_games)}")
return False, f"Missing required games: {', '.join(missing_games)}. TTW requires both Fallout 3 and Fallout New Vegas."
# Update TTW configuration
self._update_hoolamike_config_for_ttw(ttw_mpi_path, ttw_output_path)
if not self.save_hoolamike_config():
self.logger.error("Failed to save hoolamike.yaml configuration.")
return False, "Failed to save Hoolamike configuration"
# Construct and execute command
cmd = [
str(self.hoolamike_executable_path),
"tale-of-two-wastelands"
]
self.logger.info(f"Executing Hoolamike command: {' '.join(cmd)}")
try:
ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env())
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
else:
self.logger.error(f"TTW installation process returned non-zero exit code: {ret}")
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error(f"Error executing Hoolamike TTW installation: {e}", exc_info=True)
return False, f"Error executing Hoolamike TTW installation: {e}"
def install_ttw(self, ttw_mpi_path=None, ttw_output_path=None, context=None):
"""CLI interface for TTW installation - handles user interaction and calls backend.
Args:
ttw_mpi_path: Path to the TTW installer .mpi file (optional for CLI)
ttw_output_path: Target installation directory for TTW (optional for CLI)
Returns:
bool: True if successful, False otherwise
"""
self.logger.info(f"Starting Tale of Two Wastelands installation via Hoolamike")
self._check_hoolamike_installation()
menu = self.menu_handler
print(f"\n{'='*60}")
print(f"{COLOR_INFO}Hoolamike: Tale of Two Wastelands Installation{COLOR_RESET}")
@@ -676,123 +873,90 @@ class HoolamikeHandler:
print(f" • You must provide the path to your TTW .mpi installer file.")
print(f" • You must select an output directory for the TTW install.\n")
# Ensure config is loaded
if self.hoolamike_config is None:
loaded = self._load_hoolamike_config()
if not loaded or self.hoolamike_config is None:
self.logger.error("Failed to load or generate hoolamike.yaml configuration.")
print(f"{COLOR_ERROR}Error: Could not load or generate Hoolamike configuration. Aborting TTW install.{COLOR_RESET}")
return False
# Verify required games are in configuration
required_games = ['Fallout 3', 'Fallout New Vegas']
detected_games = self.path_handler.find_vanilla_game_paths()
missing_games = [game for game in required_games if game not in detected_games]
if missing_games:
self.logger.error(f"Missing required games for TTW installation: {', '.join(missing_games)}")
print(f"{COLOR_ERROR}Error: The following required games were not found: {', '.join(missing_games)}{COLOR_RESET}")
print("TTW requires both Fallout 3 and Fallout New Vegas to be installed.")
return False
# Prompt for TTW .mpi file
print(f"{COLOR_INFO}Please provide the path to your TTW .mpi installer file.{COLOR_RESET}")
print(f"You can download this from: {COLOR_INFO}https://mod.pub/ttw/133/files{COLOR_RESET}")
print(f"(Extract the .mpi file from the downloaded archive.)\n")
while not ttw_mpi_path:
candidate = menu.get_existing_file_path(
prompt_message="Enter the path to your TTW .mpi file (or 'q' to cancel):",
extension_filter=".mpi",
no_header=True
)
if candidate is None:
# If parameters provided, use them directly
if ttw_mpi_path and ttw_output_path:
print(f"{COLOR_INFO}Using provided parameters:{COLOR_RESET}")
print(f"- TTW .mpi file: {ttw_mpi_path}")
print(f"- Output directory: {ttw_output_path}")
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
if confirm and not confirm.startswith('y'):
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if str(candidate).strip().lower() == 'q':
else:
# Interactive mode - collect user input
print(f"{COLOR_INFO}Please provide the path to your TTW .mpi installer file.{COLOR_RESET}")
print(f"You can download this from: {COLOR_INFO}https://mod.pub/ttw/133/files{COLOR_RESET}")
print(f"(Extract the .mpi file from the downloaded archive.)\n")
while not ttw_mpi_path:
candidate = menu.get_existing_file_path(
prompt_message="Enter the path to your TTW .mpi file (or 'q' to cancel):",
extension_filter=".mpi",
no_header=True
)
if candidate is None:
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if str(candidate).strip().lower() == 'q':
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
ttw_mpi_path = candidate
# Prompt for output directory
print(f"\n{COLOR_INFO}Please select the output directory where TTW will be installed.{COLOR_RESET}")
print(f"(This should be an empty or new directory.)\n")
while not ttw_output_path:
ttw_output_path = menu.get_directory_path(
prompt_message="Select the TTW output directory:",
default_path=self.hoolamike_app_install_path / "TTW_Output",
create_if_missing=True,
no_header=False
)
if not ttw_output_path:
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if ttw_output_path.exists() and any(ttw_output_path.iterdir()):
print(f"{COLOR_WARNING}Warning: The selected directory '{ttw_output_path}' already exists and is not empty. Its contents may be overwritten!{COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}This directory is not empty and may be overwritten. Proceed? (y/N): {COLOR_RESET}").strip().lower()
if not confirm.startswith('y'):
print(f"{COLOR_INFO}Please select a different directory.\n{COLOR_RESET}")
ttw_output_path = None
continue
# Summary & Confirmation
print(f"\n{'-'*60}")
print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}")
print(f"- TTW .mpi file: {ttw_mpi_path}")
print(f"- Output directory: {ttw_output_path}")
print(f"{'-'*60}")
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
if confirm and not confirm.startswith('y'):
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
ttw_mpi_path = candidate
# Prompt for output directory
print(f"\n{COLOR_INFO}Please select the output directory where TTW will be installed.{COLOR_RESET}")
print(f"(This should be an empty or new directory.)\n")
while not ttw_output_path:
ttw_output_path = menu.get_directory_path(
prompt_message="Select the TTW output directory:",
default_path=self.hoolamike_app_install_path / "TTW_Output",
create_if_missing=True,
no_header=False
)
if not ttw_output_path:
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if ttw_output_path.exists() and any(ttw_output_path.iterdir()):
print(f"{COLOR_WARNING}Warning: The selected directory '{ttw_output_path}' already exists and is not empty. Its contents may be overwritten!{COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}This directory is not empty and may be overwritten. Proceed? (y/N): {COLOR_RESET}").strip().lower()
if not confirm.startswith('y'):
print(f"{COLOR_INFO}Please select a different directory.\n{COLOR_RESET}")
ttw_output_path = None
continue
# Call the clean backend function
success, message = self.install_ttw_backend(ttw_mpi_path, ttw_output_path)
# --- Summary & Confirmation ---
print(f"\n{'-'*60}")
print(f"{COLOR_INFO}Summary of configuration:{COLOR_RESET}")
print(f"- TTW .mpi file: {ttw_mpi_path}")
print(f"- Output directory: {ttw_output_path}")
print("- Games:")
for game in required_games:
found = detected_games.get(game)
print(f" {game}: {found if found else 'Not Found'}")
print(f"{'-'*60}")
print(f"{COLOR_WARNING}Proceed with these settings and start TTW installation? (This can take MANY HOURS){COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}[Y/n]: {COLOR_RESET}").strip().lower()
if confirm and not confirm.startswith('y'):
print(f"{COLOR_WARNING}Cancelled by user.{COLOR_RESET}")
return False
if success:
print(f"\n{COLOR_SUCCESS}{message}{COLOR_RESET}")
# --- Always re-detect games before updating config ---
detected_games = self.path_handler.find_vanilla_game_paths()
if not detected_games:
print(f"{COLOR_ERROR}No supported games were detected on your system. TTW requires Fallout 3 and Fallout New Vegas to be installed.{COLOR_RESET}")
return False
# Update the games section with correct keys
if self.hoolamike_config is None:
self.hoolamike_config = {}
self.hoolamike_config['games'] = {
self._format_game_name(game): {"root_directory": str(path)}
for game, path in detected_games.items()
}
# Offer to create MO2 zip archive
print(f"\n{COLOR_INFO}Would you like to create a zipped mod archive for MO2?{COLOR_RESET}")
print(f"This will package the TTW files for easy installation into Mod Organizer 2.")
create_zip = input(f"{COLOR_PROMPT}Create zip archive? [Y/n]: {COLOR_RESET}").strip().lower()
# Update TTW configuration
self._update_hoolamike_config_for_ttw(ttw_mpi_path, ttw_output_path)
if not self.save_hoolamike_config():
self.logger.error("Failed to save hoolamike.yaml configuration.")
print(f"{COLOR_ERROR}Error: Failed to save Hoolamike configuration.{COLOR_RESET}")
print("Attempting to continue anyway...")
# Construct command to execute
cmd = [
str(self.hoolamike_executable_path),
"tale-of-two-wastelands"
]
self.logger.info(f"Executing Hoolamike command: {' '.join(cmd)}")
print(f"\n{COLOR_INFO}Executing Hoolamike for TTW Installation...{COLOR_RESET}")
print(f"Command: {' '.join(cmd)}")
print(f"{COLOR_INFO}Streaming output below. Press Ctrl+C to cancel and return to Jackify menu.{COLOR_RESET}\n")
try:
ret = subprocess.call(cmd, cwd=str(self.hoolamike_app_install_path), env=get_clean_subprocess_env())
if ret == 0:
self.logger.info("TTW installation completed successfully.")
print(f"\n{COLOR_SUCCESS}TTW installation completed successfully!{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return True
if not create_zip or create_zip.startswith('y'):
zip_success = self._create_ttw_mod_archive_cli(ttw_mpi_path, ttw_output_path)
if not zip_success:
print(f"\n{COLOR_WARNING}Archive creation failed, but TTW installation completed successfully.{COLOR_RESET}")
else:
self.logger.error(f"TTW installation process returned non-zero exit code: {ret}")
print(f"\n{COLOR_ERROR}Error: TTW installation failed with exit code {ret}.{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return False
except Exception as e:
self.logger.error(f"Error executing Hoolamike TTW installation: {e}", exc_info=True)
print(f"\n{COLOR_ERROR}Error executing Hoolamike TTW installation: {e}{COLOR_RESET}")
print(f"\n{COLOR_INFO}Skipping archive creation. You can manually use the TTW files from the output directory.{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return True
else:
print(f"\n{COLOR_ERROR}{message}{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to return to the Hoolamike menu...{COLOR_RESET}")
return False
@@ -818,27 +982,125 @@ class HoolamikeHandler:
# Set destination variable
ttw_config["variables"]["DESTINATION"] = str(ttw_output_path)
# Set USERPROFILE to a Jackify-managed directory for TTW
userprofile_path = str(self.hoolamike_app_install_path / "USERPROFILE")
# Set USERPROFILE to Fallout New Vegas Wine prefix Documents folder
userprofile_path = self._detect_fallout_nv_userprofile()
if "variables" not in self.hoolamike_config["extras"]["tale_of_two_wastelands"]:
self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"] = {}
self.hoolamike_config["extras"]["tale_of_two_wastelands"]["variables"]["USERPROFILE"] = userprofile_path
# Make sure game paths are set correctly
# Make sure game paths are set correctly using proper Hoolamike naming format
for game in ['Fallout 3', 'Fallout New Vegas']:
if game in self.game_install_paths:
game_key = game.replace(' ', '').lower()
# Use _format_game_name to ensure correct naming (removes spaces)
formatted_game_name = self._format_game_name(game)
if "games" not in self.hoolamike_config:
self.hoolamike_config["games"] = {}
if game not in self.hoolamike_config["games"]:
self.hoolamike_config["games"][game] = {}
self.hoolamike_config["games"][game]["root_directory"] = str(self.game_install_paths[game])
if formatted_game_name not in self.hoolamike_config["games"]:
self.hoolamike_config["games"][formatted_game_name] = {}
self.hoolamike_config["games"][formatted_game_name]["root_directory"] = str(self.game_install_paths[game])
self.logger.info("Updated Hoolamike configuration with TTW settings.")
def _create_ttw_mod_archive_cli(self, ttw_mpi_path: Path, ttw_output_path: Path) -> bool:
"""Create a zipped mod archive of TTW output for MO2 installation (CLI version).
Args:
ttw_mpi_path: Path to the TTW .mpi file (used for version extraction)
ttw_output_path: Path to the TTW output directory to archive
Returns:
bool: True if successful, False otherwise
"""
try:
import shutil
import re
if not ttw_output_path.exists():
print(f"{COLOR_ERROR}Output directory does not exist: {ttw_output_path}{COLOR_RESET}")
return False
# Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4")
version_suffix = ""
if ttw_mpi_path:
mpi_filename = ttw_mpi_path.stem # Get filename without extension
# Look for version pattern like "3.4", "v3.4", etc.
version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE)
if version_match:
version_suffix = f" {version_match.group(1)}"
# Create archive filename - [NoDelete] prefix is used by MO2 workflows
archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}"
# Place archive in parent directory of output
archive_path = ttw_output_path.parent / archive_name
print(f"\n{COLOR_INFO}Creating mod archive: {archive_name}.zip{COLOR_RESET}")
print(f"{COLOR_INFO}This may take several minutes...{COLOR_RESET}")
# Create the zip archive
# shutil.make_archive returns the path without .zip extension
final_archive = shutil.make_archive(
str(archive_path), # base name (without extension)
'zip', # format
str(ttw_output_path) # directory to archive
)
print(f"\n{COLOR_SUCCESS}Archive created successfully: {Path(final_archive).name}{COLOR_RESET}")
print(f"{COLOR_INFO}Location: {final_archive}{COLOR_RESET}")
print(f"{COLOR_INFO}You can now install this archive as a mod in MO2.{COLOR_RESET}")
self.logger.info(f"Created TTW mod archive: {final_archive}")
return True
except Exception as e:
print(f"\n{COLOR_ERROR}Failed to create mod archive: {e}{COLOR_RESET}")
self.logger.error(f"Failed to create TTW mod archive: {e}", exc_info=True)
return False
def _detect_fallout_nv_userprofile(self) -> str:
"""
Detect the Fallout New Vegas Wine prefix Documents folder for USERPROFILE.
Returns:
str: Path to the Fallout New Vegas Wine prefix Documents folder,
or fallback to Jackify-managed directory if not found.
"""
try:
# Fallout New Vegas AppID
fnv_appid = "22380"
# Find the compatdata directory for Fallout New Vegas
compatdata_path = self.path_handler.find_compat_data(fnv_appid)
if not compatdata_path:
self.logger.warning(f"Could not find compatdata directory for Fallout New Vegas (AppID: {fnv_appid})")
# Fallback to Jackify-managed directory
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
return fallback_path
# Construct the Wine prefix Documents path
wine_documents_path = compatdata_path / "pfx" / "drive_c" / "users" / "steamuser" / "Documents" / "My Games" / "FalloutNV"
if wine_documents_path.exists():
self.logger.info(f"Found Fallout New Vegas Wine prefix Documents folder: {wine_documents_path}")
return str(wine_documents_path)
else:
self.logger.warning(f"Fallout New Vegas Wine prefix Documents folder not found at: {wine_documents_path}")
# Fallback to Jackify-managed directory
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
return fallback_path
except Exception as e:
self.logger.error(f"Error detecting Fallout New Vegas USERPROFILE: {e}", exc_info=True)
# Fallback to Jackify-managed directory
fallback_path = str(self.hoolamike_app_install_path / "USERPROFILE")
self.logger.info(f"Using fallback USERPROFILE path: {fallback_path}")
return fallback_path
def reset_config(self):
"""Resets the hoolamike.yaml to default settings, backing up any existing file."""
if self.hoolamike_config_path.is_file():
@@ -973,6 +1235,165 @@ class HoolamikeHandler:
self.logger.error(f"Error launching or waiting for editor: {e}")
print(f"{COLOR_ERROR}An error occurred while launching the editor: {e}{COLOR_RESET}")
@staticmethod
def integrate_ttw_into_modlist(ttw_output_path: Path, modlist_install_dir: Path, ttw_version: str) -> bool:
"""Integrate TTW output into a modlist's MO2 structure
This method:
1. Copies TTW output to the modlist's mods folder
2. Updates modlist.txt for all profiles
3. Updates plugins.txt with TTW ESMs in correct order
Args:
ttw_output_path: Path to TTW output directory
modlist_install_dir: Path to modlist installation directory
ttw_version: TTW version string (e.g., "3.4")
Returns:
bool: True if integration successful, False otherwise
"""
logging_handler = LoggingHandler()
logging_handler.rotate_log_for_logger('ttw-install', 'TTW_Install_workflow.log')
logger = logging_handler.setup_logger('ttw-install', 'TTW_Install_workflow.log')
try:
import shutil
import re
# Validate paths
if not ttw_output_path.exists():
logger.error(f"TTW output path does not exist: {ttw_output_path}")
return False
mods_dir = modlist_install_dir / "mods"
profiles_dir = modlist_install_dir / "profiles"
if not mods_dir.exists() or not profiles_dir.exists():
logger.error(f"Invalid modlist directory structure: {modlist_install_dir}")
return False
# Create mod folder name with version
mod_folder_name = f"[NoDelete] Tale of Two Wastelands {ttw_version}" if ttw_version else "[NoDelete] Tale of Two Wastelands"
target_mod_dir = mods_dir / mod_folder_name
# Copy TTW output to mods directory
logger.info(f"Copying TTW output to {target_mod_dir}")
if target_mod_dir.exists():
logger.info(f"Removing existing TTW mod at {target_mod_dir}")
shutil.rmtree(target_mod_dir)
shutil.copytree(ttw_output_path, target_mod_dir)
logger.info("TTW output copied successfully")
# TTW ESMs in correct load order
ttw_esms = [
"Fallout3.esm",
"Anchorage.esm",
"ThePitt.esm",
"BrokenSteel.esm",
"PointLookout.esm",
"Zeta.esm",
"TaleOfTwoWastelands.esm",
"YUPTTW.esm"
]
# Process each profile
for profile_dir in profiles_dir.iterdir():
if not profile_dir.is_dir():
continue
profile_name = profile_dir.name
logger.info(f"Processing profile: {profile_name}")
# Update modlist.txt
modlist_file = profile_dir / "modlist.txt"
if modlist_file.exists():
# Read existing modlist
with open(modlist_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Find the TTW placeholder separator and insert BEFORE it
separator_found = False
ttw_mod_line = f"+{mod_folder_name}\n"
new_lines = []
for line in lines:
# Skip existing TTW mod entries (but keep separators and other TTW-related mods)
# Match patterns: "+[NoDelete] Tale of Two Wastelands", "+[NoDelete] TTW", etc.
stripped = line.strip()
if stripped.startswith('+') and '[nodelete]' in stripped.lower():
# Check if it's the main TTW mod (not other TTW-related mods like "TTW Quick Start")
if ('tale of two wastelands' in stripped.lower() and 'quick start' not in stripped.lower() and
'loading wheel' not in stripped.lower()) or stripped.lower().startswith('+[nodelete] ttw '):
logger.info(f"Removing existing TTW mod entry: {stripped}")
continue
# Insert TTW mod BEFORE the placeholder separator (MO2 order is bottom-up)
# Check BEFORE appending so TTW mod appears before separator in file
if "put tale of two wastelands mod here" in line.lower() and "_separator" in line.lower():
new_lines.append(ttw_mod_line)
separator_found = True
logger.info(f"Inserted TTW mod before separator: {line.strip()}")
new_lines.append(line)
# If no separator found, append at the end
if not separator_found:
new_lines.append(ttw_mod_line)
logger.warning(f"No TTW separator found in {profile_name}, appended to end")
# Write back
with open(modlist_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
logger.info(f"Updated modlist.txt for {profile_name}")
else:
logger.warning(f"modlist.txt not found for profile {profile_name}")
# Update plugins.txt
plugins_file = profile_dir / "plugins.txt"
if plugins_file.exists():
# Read existing plugins
with open(plugins_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Remove any existing TTW ESMs
ttw_esm_set = set(esm.lower() for esm in ttw_esms)
lines = [line for line in lines if line.strip().lower() not in ttw_esm_set]
# Find CaravanPack.esm and insert TTW ESMs after it
insert_index = None
for i, line in enumerate(lines):
if line.strip().lower() == "caravanpack.esm":
insert_index = i + 1
break
if insert_index is not None:
# Insert TTW ESMs in correct order
for esm in reversed(ttw_esms):
lines.insert(insert_index, f"{esm}\n")
else:
logger.warning(f"CaravanPack.esm not found in {profile_name}, appending TTW ESMs to end")
for esm in ttw_esms:
lines.append(f"{esm}\n")
# Write back
with open(plugins_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"Updated plugins.txt for {profile_name}")
else:
logger.warning(f"plugins.txt not found for profile {profile_name}")
logger.info("TTW integration completed successfully")
return True
except Exception as e:
logger.error(f"Failed to integrate TTW into modlist: {e}")
import traceback
logger.error(traceback.format_exc())
return False
# Example usage (for testing, remove later)
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')

View File

@@ -152,8 +152,10 @@ class ModlistMenuHandler:
self.path_handler = PathHandler()
self.vdf_handler = VDFHandler()
# Determine Steam Deck status (already done by ConfigHandler, use it)
self.steamdeck = config_handler.settings.get('steamdeck', False)
# Determine Steam Deck status using centralized detection
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
# Create the resolution handler
self.resolution_handler = ResolutionHandler()
@@ -178,7 +180,13 @@ class ModlistMenuHandler:
self.logger.error(f"Error initializing ModlistMenuHandler: {e}")
# Initialize with defaults/empty to prevent errors
self.filesystem_handler = FileSystemHandler()
self.steamdeck = False
# Use centralized detection even in fallback
try:
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.steamdeck = platform_service.is_steamdeck
except:
self.steamdeck = False # Final fallback
self.modlist_handler = None
def show_modlist_menu(self):

View File

@@ -71,15 +71,19 @@ class ModlistHandler:
}
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
# NOTE: dotnet4.x components disabled in v0.1.6.2 - replaced with universal registry fixes
MODLIST_WINE_COMPONENTS = {
"wildlander": ["dotnet472"],
"librum": ["dotnet40", "dotnet8"],
"apostasy": ["dotnet40", "dotnet8"],
"nordicsouls": ["dotnet40"],
"livingskyrim": ["dotnet40"],
"lsiv": ["dotnet40"],
"ls4": ["dotnet40"],
"lostlegacy": ["dotnet48"],
# "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
# "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
# "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
# "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
# "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
}
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
@@ -105,6 +109,12 @@ class ModlistHandler:
self.logger = logging.getLogger(__name__)
self.logger.propagate = False
self.steamdeck = steamdeck
# DEBUG: Log ModlistHandler instantiation details for SD card path debugging
import traceback
caller_info = traceback.extract_stack()[-2] # Get caller info
self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler created: id={id(self)}, steamdeck={steamdeck}")
self.logger.debug(f"[SD_CARD_DEBUG] Created from: {caller_info.filename}:{caller_info.lineno} in {caller_info.name}()")
self.steam_path: Optional[Path] = None
self.verbose = verbose # Store verbose flag
self.mo2_path: Optional[Path] = None
@@ -158,7 +168,10 @@ class ModlistHandler:
self.stock_game_path = None
# Initialize Handlers (should happen regardless of how paths were provided)
self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck, logger=self.logger)
self.protontricks_handler = ProtontricksHandler(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.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler()
self.resolution_handler = ResolutionHandler()
@@ -224,44 +237,41 @@ class ModlistHandler:
discovered_modlists_info = []
try:
# 1. Get ALL non-Steam shortcuts from Protontricks
# 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
# Get shortcuts pointing to the executable from shortcuts.vdf
matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name)
if not matching_vdf_shortcuts:
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.")
return []
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:
app_name = vdf_shortcut.get('AppName')
start_dir = vdf_shortcut.get('StartDir')
signed_appid = vdf_shortcut.get('appid')
if not app_name or not start_dir:
self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}")
continue
if app_name in protontricks_shortcuts:
app_id = protontricks_shortcuts[app_name]
# Append dictionary with all necessary info
modlist_info = {
'name': app_name,
'appid': app_id,
'path': start_dir
}
discovered_modlists_info.append(modlist_info)
self.logger.info(f"Validated shortcut: '{app_name}' (AppID: {app_id}, Path: {start_dir})")
if signed_appid is None:
self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}")
continue
# Convert signed AppID to unsigned AppID (the format used by Steam prefixes)
if signed_appid < 0:
unsigned_appid = signed_appid + (2**32)
else:
# Downgraded from WARNING to INFO
self.logger.info(f"Shortcut '{app_name}' found in VDF but not listed by protontricks. Skipping.")
unsigned_appid = signed_appid
# Append dictionary with all necessary info using unsigned AppID
modlist_info = {
'name': app_name,
'appid': unsigned_appid,
'path': start_dir
}
discovered_modlists_info.append(modlist_info)
self.logger.info(f"Discovered shortcut: '{app_name}' (Signed: {signed_appid} → Unsigned: {unsigned_appid}, Path: {start_dir})")
except Exception as e:
self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True)
@@ -315,13 +325,26 @@ class ModlistHandler:
self.modlist_dir = Path(modlist_dir_path_str)
self.modlist_ini = modlist_ini_path
# Determine if modlist is on SD card
# Use str() for startswith check
if str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media"):
# Determine if modlist is on SD card (Steam Deck only)
# On non-Steam Deck systems, /media mounts should use Z: drive, not D: drive
is_on_sdcard_path = str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media")
# DEBUG: Log SD card detection logic
self.logger.debug(f"[SD_CARD_DEBUG] SD card detection for instance id={id(self)}:")
self.logger.debug(f"[SD_CARD_DEBUG] modlist_dir: {self.modlist_dir}")
self.logger.debug(f"[SD_CARD_DEBUG] is_on_sdcard_path: {is_on_sdcard_path}")
self.logger.debug(f"[SD_CARD_DEBUG] self.steamdeck: {self.steamdeck}")
if is_on_sdcard_path and self.steamdeck:
self.modlist_sdcard = True
self.logger.info("Modlist appears to be on an SD card.")
self.logger.info("Modlist appears to be on an SD card (Steam Deck).")
self.logger.debug(f"[SD_CARD_DEBUG] Set modlist_sdcard=True")
else:
self.modlist_sdcard = False
self.logger.debug(f"[SD_CARD_DEBUG] Set modlist_sdcard=False because: is_on_sdcard_path={is_on_sdcard_path} AND steamdeck={self.steamdeck}")
if is_on_sdcard_path and not self.steamdeck:
self.logger.info("Modlist on /media mount detected on non-Steam Deck system - using Z: drive mapping.")
self.logger.debug("[SD_CARD_DEBUG] This is the ROOT CAUSE - SD card path but steamdeck=False!")
# Find and set compatdata path now that we have appid
# Ensure PathHandler is available (should be initialized in __init__)
@@ -345,7 +368,8 @@ class ModlistHandler:
# Store engine_installed flag for conditional path manipulation
self.engine_installed = modlist_info.get('engine_installed', False)
self.logger.debug(f" Engine Installed: {self.engine_installed}")
# Call internal detection methods to populate more state
if not self._detect_game_variables():
self.logger.warning("Failed to auto-detect game type after setting context.")
@@ -685,12 +709,49 @@ class ModlistHandler:
# All modlists now use their own AppID for wine components
target_appid = self.appid
if not self.protontricks_handler.install_wine_components(target_appid, self.game_var_full, specific_components=components):
self.logger.error("Failed to install Wine components. Configuration aborted.")
# Use user's preferred component installation method (respects settings toggle)
wineprefix = self.protontricks_handler.get_wine_prefix_path(target_appid)
if not wineprefix:
self.logger.error("Failed to get WINEPREFIX path for component installation.")
print("Error: Could not determine wine prefix location.")
return False
# Use the winetricks handler which respects the user's toggle setting
try:
self.logger.info("Installing Wine components using user's preferred method...")
success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components)
if success:
self.logger.info("Wine component installation completed successfully")
else:
self.logger.error("Wine component installation failed")
print("Error: Failed to install necessary Wine components.")
return False
except Exception as e:
self.logger.error(f"Wine component installation failed with exception: {e}")
print("Error: Failed to install necessary Wine components.")
return False # Abort on failure
return False
self.logger.info("Step 4: Installing Wine components... Done")
# Step 4.5: Apply universal dotnet4.x compatibility registry fixes AFTER wine components
# This ensures the fixes are not overwritten by component installation processes
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes")
self.logger.info("Step 4.5: Applying universal dotnet4.x compatibility registry fixes...")
registry_success = False
try:
registry_success = self._apply_universal_dotnet_fixes()
except Exception as e:
self.logger.error(f"CRITICAL: Registry fixes failed - modlist may have .NET compatibility issues: {e}")
registry_success = False
if not registry_success:
self.logger.error("=" * 80)
self.logger.error("WARNING: Universal dotnet4.x registry fixes FAILED!")
self.logger.error("This modlist may experience .NET Framework compatibility issues.")
self.logger.error("Consider manually setting mscoree=native in winecfg if problems occur.")
self.logger.error("=" * 80)
# Continue but user should be aware of potential issues
# Step 5: Ensure permissions of Modlist directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Setting ownership and permissions for modlist directory")
@@ -716,6 +777,14 @@ class ModlistHandler:
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done")
# Step 6.5: Handle symlinked downloads directory
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
self.logger.info("Step 6.5: Checking for symlinked downloads directory...")
if not self._handle_symlinked_downloads():
self.logger.warning("Warning during symlink handling (non-critical)")
self.logger.info("Step 6.5: Checking for symlinked downloads directory... Done")
# Step 7a: Detect Stock Game/Game Root path
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Detecting stock game path")
@@ -758,9 +827,19 @@ class ModlistHandler:
self.logger.info("No stock game path found, skipping gamePath update - edit_binary_working_paths will handle all path updates.")
self.logger.info("Using unified path manipulation to avoid duplicate processing.")
# Conditionally update binary and working directory paths
# Conditionally update binary and working directory paths
# 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
# DEBUG: Add comprehensive logging to identify Steam Deck SD card path manipulation issues
engine_installed = getattr(self, 'engine_installed', False)
self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler instance: id={id(self)}")
self.logger.debug(f"[SD_CARD_DEBUG] engine_installed: {engine_installed}")
self.logger.debug(f"[SD_CARD_DEBUG] modlist_sdcard: {self.modlist_sdcard}")
self.logger.debug(f"[SD_CARD_DEBUG] steamdeck parameter passed to constructor: {getattr(self, 'steamdeck', 'NOT_SET')}")
self.logger.debug(f"[SD_CARD_DEBUG] Path manipulation condition: not {engine_installed} or {self.modlist_sdcard} = {not engine_installed or self.modlist_sdcard}")
if not getattr(self, 'engine_installed', False) or self.modlist_sdcard:
# Convert steamapps/common path to library root path
steam_libraries = None
if self.steam_library:
@@ -779,7 +858,8 @@ class ModlistHandler:
print("Error: Failed to update binary and working directory paths in ModOrganizer.ini.")
return False # Abort on failure
else:
self.logger.debug("Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini")
self.logger.debug("[SD_CARD_DEBUG] Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini")
self.logger.debug(f"[SD_CARD_DEBUG] SKIPPED because: engine_installed={engine_installed} and modlist_sdcard={self.modlist_sdcard}")
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
# Step 9: Update Resolution Settings (if applicable)
@@ -792,10 +872,10 @@ class ModlistHandler:
vanilla_game_dir = None
if self.steam_library and self.game_var_full:
vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / self.game_var_full)
if not self.resolution_handler.update_ini_resolution(
modlist_dir=self.modlist_dir,
game_var=self.game_var_full,
if not ResolutionHandler.update_ini_resolution(
modlist_dir=self.modlist_dir,
game_var=self.game_var_full,
set_res=self.selected_resolution,
vanilla_game_dir=vanilla_game_dir
):
@@ -841,21 +921,38 @@ class ModlistHandler:
print("Warning: Failed to create dxvk.conf file.")
self.logger.info("Step 10: Creating dxvk.conf... Done")
# Step 11a: Small Tasks - Delete Plugin
# Step 11a: Small Tasks - Delete Incompatible Plugins
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugin")
self.logger.info("Step 11a: Deleting incompatible MO2 plugin (FixGameRegKey.py)...")
plugin_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
if plugin_path.exists():
status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugins")
self.logger.info("Step 11a: Deleting incompatible MO2 plugins...")
# Delete FixGameRegKey.py plugin
fixgamereg_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
if fixgamereg_path.exists():
try:
plugin_path.unlink()
fixgamereg_path.unlink()
self.logger.info("FixGameRegKey.py plugin deleted successfully.")
except Exception as e:
self.logger.warning(f"Failed to delete FixGameRegKey.py plugin: {e}")
print("Warning: Failed to delete incompatible plugin file.")
print("Warning: Failed to delete FixGameRegKey.py plugin file.")
else:
self.logger.debug("FixGameRegKey.py plugin not found (this is normal).")
self.logger.info("Step 11a: Plugin deletion check complete.")
# Delete PageFileManager plugin directory (Linux has no PageFile)
pagefilemgr_path = Path(self.modlist_dir) / "plugins" / "PageFileManager"
if pagefilemgr_path.exists():
try:
import shutil
shutil.rmtree(pagefilemgr_path)
self.logger.info("PageFileManager plugin directory deleted successfully.")
except Exception as e:
self.logger.warning(f"Failed to delete PageFileManager plugin directory: {e}")
print("Warning: Failed to delete PageFileManager plugin directory.")
else:
self.logger.debug("PageFileManager plugin not found (this is normal).")
self.logger.info("Step 11a: Incompatible plugin deletion check complete.")
# Step 11b: Download Font
if status_callback:
@@ -863,7 +960,7 @@ class ModlistHandler:
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
if 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_dest_path = fonts_dir / "seguisym.ttf"
@@ -898,6 +995,10 @@ class ModlistHandler:
# status_callback("Configuration completed successfully!")
self.logger.info("Configuration steps completed successfully.")
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
self._re_enforce_windows_10_mode()
return True # Return True on success
def _detect_steam_library_info(self) -> bool:
@@ -1163,7 +1264,7 @@ class ModlistHandler:
# Determine game type
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
# Add game-specific extras
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game:
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
elif "falloutnewvegas" in game or "fnv" in game or "oblivion" in game:
extras += ["d3dx9_43", "d3dx9"]
@@ -1238,6 +1339,12 @@ class ModlistHandler:
# Check ModOrganizer.ini for indicators (nvse/enderal) as an early, robust signal
try:
mo2_ini = modlist_path / "ModOrganizer.ini"
# Also check Somnium's non-standard location
if not mo2_ini.exists():
somnium_mo2_ini = modlist_path / "files" / "ModOrganizer.ini"
if somnium_mo2_ini.exists():
mo2_ini = somnium_mo2_ini
if mo2_ini.exists():
try:
content = mo2_ini.read_text(errors='ignore').lower()
@@ -1294,4 +1401,295 @@ class ModlistHandler:
self.logger.debug("No special game type detected - standard workflow will be used")
return None
# (Ensure EOF is clean and no extra incorrect methods exist below)
def _re_enforce_windows_10_mode(self):
"""
Re-enforce Windows 10 mode after modlist-specific configurations.
This matches the legacy script behavior (line 1333) where Windows 10 mode
is re-applied after modlist-specific steps to ensure consistency.
"""
try:
if not hasattr(self, 'appid') or not self.appid:
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
return
from ..handlers.winetricks_handler import WinetricksHandler
from ..handlers.path_handler import PathHandler
# Get prefix path for the AppID
prefix_path = PathHandler.find_compat_data(str(self.appid))
if not prefix_path:
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
return
# Use winetricks handler to set Windows 10 mode
winetricks_handler = WinetricksHandler()
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
if not wine_binary:
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
return
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations")
except Exception as e:
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
def _handle_symlinked_downloads(self) -> bool:
"""
Check if downloads_directory in ModOrganizer.ini points to a symlink.
If it does, comment out the line to force MO2 to use default behavior.
Returns:
bool: True on success or no action needed, False on error
"""
try:
import configparser
import os
if not self.modlist_ini or not os.path.exists(self.modlist_ini):
self.logger.warning("ModOrganizer.ini not found for symlink check")
return True # Non-critical
# Read the INI file
config = configparser.ConfigParser(allow_no_value=True, delimiters=['='])
config.optionxform = str # Preserve case sensitivity
try:
# Read file manually to handle BOM
with open(self.modlist_ini, 'r', encoding='utf-8-sig') as f:
config.read_file(f)
except UnicodeDecodeError:
with open(self.modlist_ini, 'r', encoding='latin-1') as f:
config.read_file(f)
# Check if downloads_directory or download_directory exists and is a symlink
downloads_key = None
downloads_path = None
if 'General' in config:
# Check for both possible key names
if 'downloads_directory' in config['General']:
downloads_key = 'downloads_directory'
downloads_path = config['General']['downloads_directory']
elif 'download_directory' in config['General']:
downloads_key = 'download_directory'
downloads_path = config['General']['download_directory']
if downloads_path:
if downloads_path and os.path.exists(downloads_path):
# Check if the path or any parent directory contains symlinks
def has_symlink_in_path(path):
"""Check if path or any parent directory is a symlink"""
current_path = Path(path).resolve()
check_path = Path(path)
# Walk up the path checking each component
for parent in [check_path] + list(check_path.parents):
if parent.is_symlink():
return True, str(parent)
return False, None
has_symlink, symlink_path = has_symlink_in_path(downloads_path)
if has_symlink:
self.logger.info(f"Detected symlink in downloads directory path: {symlink_path} -> {downloads_path}")
self.logger.info("Commenting out downloads_directory to avoid Wine symlink issues")
# Read the file manually to preserve comments and formatting
with open(self.modlist_ini, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Find and comment out the downloads directory line
modified = False
for i, line in enumerate(lines):
if line.strip().startswith(f'{downloads_key}='):
lines[i] = '#' + line # Comment out the line
modified = True
break
if modified:
# Write the modified file back
with open(self.modlist_ini, 'w', encoding='utf-8') as f:
f.writelines(lines)
self.logger.info(f"{downloads_key} line commented out successfully")
else:
self.logger.warning("downloads_directory line not found in file")
else:
self.logger.debug(f"downloads_directory is not a symlink: {downloads_path}")
else:
self.logger.debug("downloads_directory path does not exist or is empty")
else:
self.logger.debug("No downloads_directory found in ModOrganizer.ini")
return True
except Exception as e:
self.logger.error(f"Error handling symlinked downloads: {e}", exc_info=True)
return False
def _apply_universal_dotnet_fixes(self):
"""
Apply universal dotnet4.x compatibility registry fixes to ALL modlists.
Now called AFTER wine component installation to prevent overwrites.
Includes wineserver shutdown/flush to ensure persistence.
"""
try:
prefix_path = os.path.join(str(self.compat_data_path), "pfx")
if not os.path.exists(prefix_path):
self.logger.warning(f"Prefix path not found: {prefix_path}")
return False
self.logger.info("Applying universal dotnet4.x compatibility registry fixes (post-component installation)...")
# Find the appropriate Wine binary to use for registry operations
wine_binary = self._find_wine_binary_for_registry()
if not wine_binary:
self.logger.error("Could not find Wine binary for registry operations")
return False
# Find wineserver binary for flushing registry changes
wine_dir = os.path.dirname(wine_binary)
wineserver_binary = os.path.join(wine_dir, 'wineserver')
if not os.path.exists(wineserver_binary):
self.logger.warning(f"wineserver not found at {wineserver_binary}, registry flush may not work")
wineserver_binary = None
# Set environment for Wine registry operations
env = os.environ.copy()
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Shutdown any running wineserver processes to ensure clean slate
if wineserver_binary:
self.logger.debug("Shutting down wineserver before applying registry fixes...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Wineserver shutdown complete")
except Exception as e:
self.logger.warning(f"Wineserver shutdown failed (non-critical): {e}")
# Registry fix 1: Set mscoree=native DLL override
# This tells Wine to use native .NET runtime instead of Wine's implementation
self.logger.debug("Setting mscoree=native DLL override...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if result1.returncode == 0:
self.logger.info("Successfully applied mscoree=native DLL override")
else:
self.logger.warning(f"Failed to set mscoree DLL override: {result1.stderr}")
# Registry fix 2: Set OnlyUseLatestCLR=1
# This prevents .NET version conflicts by using the latest CLR
self.logger.debug("Setting OnlyUseLatestCLR=1 registry entry...")
cmd2 = [
wine_binary, 'reg', 'add',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if result2.returncode == 0:
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
self.logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}")
# Force wineserver to flush registry changes to disk
if wineserver_binary:
self.logger.debug("Flushing registry changes to disk via wineserver shutdown...")
try:
subprocess.run([wineserver_binary, '-w'], env=env, timeout=30, capture_output=True)
self.logger.debug("Registry changes flushed to disk")
except Exception as e:
self.logger.warning(f"Registry flush failed (non-critical): {e}")
# VERIFICATION: Confirm the registry entries persisted
self.logger.info("Verifying registry entries were applied and persisted...")
verification_passed = True
# Verify mscoree=native
verify_cmd1 = [
wine_binary, 'reg', 'query',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', 'mscoree'
]
verify_result1 = subprocess.run(verify_cmd1, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result1.returncode == 0 and 'native' in verify_result1.stdout:
self.logger.info("VERIFIED: mscoree=native is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: mscoree=native not found in registry. Query output: {verify_result1.stdout}")
verification_passed = False
# Verify OnlyUseLatestCLR=1
verify_cmd2 = [
wine_binary, 'reg', 'query',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR'
]
verify_result2 = subprocess.run(verify_cmd2, env=env, capture_output=True, text=True, errors='replace', timeout=30)
if verify_result2.returncode == 0 and ('0x1' in verify_result2.stdout or 'REG_DWORD' in verify_result2.stdout):
self.logger.info("VERIFIED: OnlyUseLatestCLR=1 is set correctly")
else:
self.logger.error(f"VERIFICATION FAILED: OnlyUseLatestCLR=1 not found in registry. Query output: {verify_result2.stdout}")
verification_passed = False
# Both fixes applied and verified
if result1.returncode == 0 and result2.returncode == 0 and verification_passed:
self.logger.info("Universal dotnet4.x compatibility fixes applied, flushed, and verified successfully")
return True
else:
self.logger.error("Registry fixes failed verification - fixes may not persist across prefix restarts")
return False
except Exception as e:
self.logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False
def _find_wine_binary_for_registry(self) -> Optional[str]:
"""Find the appropriate Wine binary for registry operations using user's configured Proton"""
try:
# Use the user's configured Proton version from settings
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
if user_proton_path and user_proton_path != 'auto':
# User has selected a specific Proton version
proton_path = Path(user_proton_path).expanduser()
# Check for wine binary in both GE-Proton and Valve Proton structures
wine_candidates = [
proton_path / "files" / "bin" / "wine", # GE-Proton structure
proton_path / "dist" / "bin" / "wine" # Valve Proton structure
]
for wine_path in wine_candidates:
if wine_path.exists():
self.logger.info(f"Using Wine binary from user's configured Proton: {wine_path}")
return str(wine_path)
self.logger.warning(f"User's configured Proton path has no wine binary: {user_proton_path}")
# Fallback: Try to use same Steam library detection as main Proton detection
from ..handlers.wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if wine_binary:
self.logger.info(f"Using Wine binary from detected Proton: {wine_binary}")
return wine_binary
# NEVER fall back to system wine - it will break Proton prefixes with architecture mismatches
self.logger.error("No suitable Proton Wine binary found for registry operations")
return None
except Exception as e:
self.logger.error(f"Error finding Wine binary: {e}")
return None

View File

@@ -68,7 +68,7 @@ class ModlistInstallCLI:
def __init__(self, menu_handler: MenuHandler, steamdeck: bool = False):
self.menu_handler = menu_handler
self.steamdeck = steamdeck
self.protontricks_handler = ProtontricksHandler(steamdeck=steamdeck)
self.protontricks_handler = ProtontricksHandler(steamdeck)
self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
self.context = {}
# Use standard logging (no file handler)
@@ -945,6 +945,9 @@ class ModlistInstallCLI:
if configuration_success:
self.logger.info("Post-installation configuration completed successfully")
# Check for TTW integration eligibility
self._check_and_prompt_ttw_integration(install_dir_str, detected_game, modlist_name)
else:
self.logger.warning("Post-installation configuration had issues")
else:
@@ -1134,5 +1137,159 @@ class ModlistInstallCLI:
# Add URL on next line for easier debugging
return f"{line}\n Nexus URL: {mod_url}"
return line
return line
def _check_and_prompt_ttw_integration(self, install_dir: str, game_type: str, modlist_name: str):
"""Check if modlist is eligible for TTW integration and prompt user"""
try:
# Check eligibility: FNV game, TTW-compatible modlist, no existing TTW
if not self._is_ttw_eligible(install_dir, game_type, modlist_name):
return
# Prompt user for TTW installation
print(f"\n{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"{COLOR_INFO}TTW Integration Available{COLOR_RESET}")
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"\nThis modlist ({modlist_name}) supports Tale of Two Wastelands (TTW).")
print(f"TTW combines Fallout 3 and New Vegas into a single game.")
print(f"\nWould you like to install TTW now?")
user_input = input(f"{COLOR_PROMPT}Install TTW? (yes/no): {COLOR_RESET}").strip().lower()
if user_input in ['yes', 'y']:
self._launch_ttw_installation(modlist_name, install_dir)
else:
print(f"{COLOR_INFO}Skipping TTW installation. You can install it later from the main menu.{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW eligibility check: {e}", exc_info=True)
def _is_ttw_eligible(self, install_dir: str, game_type: str, modlist_name: str) -> bool:
"""Check if modlist is eligible for TTW integration"""
try:
from pathlib import Path
# Check 1: Must be Fallout New Vegas
if not game_type or game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
return False
# Check 2: Must be on TTW compatibility whitelist
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
if not is_ttw_compatible(modlist_name):
return False
# Check 3: TTW must not already be installed
if self._detect_existing_ttw(install_dir):
self.logger.info(f"TTW already installed in {install_dir}, skipping prompt")
return False
return True
except Exception as e:
self.logger.error(f"Error checking TTW eligibility: {e}")
return False
def _detect_existing_ttw(self, install_dir: str) -> bool:
"""Detect if TTW is already installed in the modlist"""
try:
from pathlib import Path
install_path = Path(install_dir)
# Search for TTW indicators in common locations
search_paths = [
install_path,
install_path / "mods",
install_path / "Stock Game",
install_path / "Game Root"
]
for search_path in search_paths:
if not search_path.exists():
continue
# Look for folders containing "tale" and "two" and "wastelands"
for folder in search_path.iterdir():
if not folder.is_dir():
continue
folder_name_lower = folder.name.lower()
if all(keyword in folder_name_lower for keyword in ['tale', 'two', 'wastelands']):
# Verify it has the TTW ESM file
for file in folder.rglob('*.esm'):
if 'taleoftwowastelands' in file.name.lower():
self.logger.info(f"Found existing TTW installation: {file}")
return True
return False
except Exception as e:
self.logger.error(f"Error detecting existing TTW: {e}")
return False
def _launch_ttw_installation(self, modlist_name: str, install_dir: str):
"""Launch TTW installation workflow"""
try:
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
# Import TTW installation handler
from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler
from jackify.backend.models.configuration import SystemInfo
system_info = SystemInfo()
hoolamike_handler = HoolamikeHandler(system_info)
# Check if Hoolamike is installed
is_installed, installed_version = hoolamike_handler.check_installation_status()
if not is_installed:
print(f"{COLOR_INFO}Hoolamike (TTW installer) is not installed.{COLOR_RESET}")
user_input = input(f"{COLOR_PROMPT}Install Hoolamike? (yes/no): {COLOR_RESET}").strip().lower()
if user_input not in ['yes', 'y']:
print(f"{COLOR_INFO}TTW installation cancelled.{COLOR_RESET}")
return
# Install Hoolamike
print(f"{COLOR_INFO}Installing Hoolamike...{COLOR_RESET}")
success, message = hoolamike_handler.install_hoolamike()
if not success:
print(f"{COLOR_ERROR}Failed to install Hoolamike: {message}{COLOR_RESET}")
return
print(f"{COLOR_INFO}Hoolamike installed successfully.{COLOR_RESET}")
# Get Hoolamike MPI path
mpi_path = hoolamike_handler.get_mpi_path()
if not mpi_path or not os.path.exists(mpi_path):
print(f"{COLOR_ERROR}Hoolamike MPI file not found at: {mpi_path}{COLOR_RESET}")
return
# Prompt for TTW installation directory
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
print(f"Default: {os.path.join(install_dir, 'TTW')}")
ttw_install_dir = input(f"{COLOR_PROMPT}TTW install directory (Enter for default): {COLOR_RESET}").strip()
if not ttw_install_dir:
ttw_install_dir = os.path.join(install_dir, "TTW")
# Run Hoolamike installation
print(f"\n{COLOR_INFO}Installing TTW using Hoolamike...{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a while (15-30 minutes depending on your system).{COLOR_RESET}")
success = hoolamike_handler.run_hoolamike_install(mpi_path, ttw_install_dir)
if success:
print(f"\n{COLOR_INFO}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"{COLOR_INFO}TTW Installation Complete!{COLOR_RESET}")
print(f"{COLOR_PROMPT}═══════════════════════════════════════════════════════════════{COLOR_RESET}")
print(f"\nTTW has been installed to: {ttw_install_dir}")
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
else:
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")

View File

@@ -32,14 +32,21 @@ class PathHandler:
@staticmethod
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 /).
"""
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
relative_part = path_str[len(SDCARD_PREFIX):]
return relative_part if relative_part else "." # Return '.' if it was exactly the prefix
from .wine_utils import WineUtils
path_str = path_obj.as_posix() # Work with consistent forward slashes
# Use dynamic SD card detection from WineUtils
stripped_path = WineUtils._strip_sdcard_path(path_str)
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
@staticmethod
@@ -623,47 +630,30 @@ class PathHandler:
# Moved _find_shortcuts_vdf here from ShortcutHandler
def _find_shortcuts_vdf(self) -> Optional[str]:
"""Helper to find the active shortcuts.vdf file for a user.
"""Helper to find the active shortcuts.vdf file for the current Steam user.
Iterates through userdata directories and returns the path to the
first found shortcuts.vdf file.
Uses proper multi-user detection to find the correct Steam user instead
of just taking the first found user directory.
Returns:
Optional[str]: The full path to the shortcuts.vdf file, or None if not found.
"""
# This implementation was moved from ShortcutHandler
userdata_base_paths = [
os.path.expanduser("~/.steam/steam/userdata"),
os.path.expanduser("~/.local/share/Steam/userdata"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata")
]
found_vdf_path = None
for base_path in userdata_base_paths:
if not os.path.isdir(base_path):
logger.debug(f"Userdata base path not found or not a directory: {base_path}")
continue
logger.debug(f"Searching for user IDs in: {base_path}")
try:
for item in os.listdir(base_path):
user_path = os.path.join(base_path, item)
if os.path.isdir(user_path) and item.isdigit():
logger.debug(f"Checking user directory: {user_path}")
config_path = os.path.join(user_path, "config")
shortcuts_file = os.path.join(config_path, "shortcuts.vdf")
if os.path.isfile(shortcuts_file):
logger.info(f"Found shortcuts.vdf at: {shortcuts_file}")
found_vdf_path = shortcuts_file
break # Found it for this base path
else:
logger.debug(f"shortcuts.vdf not found in {config_path}")
except OSError as e:
logger.warning(f"Could not access directory {base_path}: {e}")
continue # Try next base path
if found_vdf_path:
break # Found it in this base path
if not found_vdf_path:
logger.error("Could not find any shortcuts.vdf file in common Steam locations.")
return found_vdf_path
try:
# Use native Steam service for proper multi-user detection
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
shortcuts_path = steam_service.get_shortcuts_vdf_path()
if shortcuts_path:
logger.info(f"Found shortcuts.vdf using multi-user detection: {shortcuts_path}")
return str(shortcuts_path)
else:
logger.error("Could not determine shortcuts.vdf path using multi-user detection")
return None
except Exception as e:
logger.error(f"Error using multi-user detection for shortcuts.vdf: {e}")
return None
@staticmethod
def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]:
@@ -686,10 +676,10 @@ class PathHandler:
# For each library path, look for each target game
for library_path in library_paths:
# Check if the common directory exists
common_dir = library_path / "common"
# Check if the common directory exists (games are in steamapps/common)
common_dir = library_path / "steamapps" / "common"
if not common_dir.is_dir():
logger.debug(f"No 'common' directory in library: {library_path}")
logger.debug(f"No 'steamapps/common' directory in library: {library_path}")
continue
# Get subdirectories in common dir
@@ -704,8 +694,8 @@ class PathHandler:
if game_name in results:
continue # Already found this game
# Try to find by appmanifest
appmanifest_path = library_path / f"appmanifest_{app_id}.acf"
# Try to find by appmanifest (manifests are in steamapps subdirectory)
appmanifest_path = library_path / "steamapps" / f"appmanifest_{app_id}.acf"
if appmanifest_path.is_file():
# Find the installdir value
try:
@@ -737,7 +727,7 @@ class PathHandler:
try:
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
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)
windows_style = processed_path.replace('/', '\\')
windows_style_double = windows_style.replace('\\', '\\\\')
@@ -787,17 +777,36 @@ class PathHandler:
# Extract existing gamePath to use as source of truth for vanilla game location
existing_game_path = None
for line in lines:
gamepath_line_index = -1
for i, line in enumerate(lines):
if re.match(r'^\s*gamepath\s*=.*@ByteArray\(([^)]+)\)', line, re.IGNORECASE):
match = re.search(r'@ByteArray\(([^)]+)\)', line)
if match:
raw_path = match.group(1)
gamepath_line_index = i
# Convert Windows path back to Linux path
if raw_path.startswith(('Z:', 'D:')):
linux_path = raw_path[2:].replace('\\\\', '/').replace('\\', '/')
existing_game_path = linux_path
logger.debug(f"Extracted existing gamePath: {existing_game_path}")
break
# Special handling for gamePath in three-true scenario (engine_installed + steamdeck + sdcard)
if modlist_sdcard and existing_game_path and existing_game_path.startswith('/run/media') and gamepath_line_index != -1:
# Simple manual stripping of /run/media/deck/UUID pattern for SD card paths
# Match /run/media/deck/[UUID]/Games/... and extract just /Games/...
sdcard_pattern = r'^/run/media/deck/[^/]+(/Games/.*)$'
match = re.match(sdcard_pattern, existing_game_path)
if match:
stripped_path = match.group(1) # Just the /Games/... part
windows_path = stripped_path.replace('/', '\\\\')
new_gamepath_value = f"D:\\\\{windows_path}"
new_gamepath_line = f"gamePath = @ByteArray({new_gamepath_value})\n"
logger.info(f"Updating gamePath for SD card: {lines[gamepath_line_index].strip()} -> {new_gamepath_line.strip()}")
lines[gamepath_line_index] = new_gamepath_line
else:
logger.warning(f"SD card path doesn't match expected pattern: {existing_game_path}")
game_path_updated = False
binary_paths_updated = 0
@@ -876,9 +885,10 @@ class PathHandler:
rel_path = value_part[idx:].lstrip('/')
else:
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)
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}")
lines[i] = new_binary_line + "\n"
binary_paths_updated += 1
@@ -893,7 +903,7 @@ class PathHandler:
wd_path = drive_prefix + wd_path
formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path)
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}")
lines[j] = new_wd_line + "\n"
working_dirs_updated += 1

View File

@@ -21,14 +21,19 @@ logger = logging.getLogger(__name__)
class ProtontricksHandler:
"""
Handles operations related to Protontricks detection and usage
This handler now supports native Steam operations as a fallback/replacement
for protontricks functionality.
"""
def __init__(self, steamdeck: bool, logger=None):
self.logger = logger or logging.getLogger(__name__)
self.which_protontricks = None # 'flatpak' or 'native'
self.protontricks_version = None
self.protontricks_path = None
self.steamdeck = steamdeck # Store steamdeck status
self._native_steam_service = None
self.use_native_operations = True # Enable native Steam operations by default
def _get_clean_subprocess_env(self):
"""
@@ -69,7 +74,14 @@ class ProtontricksHandler:
env.pop('DYLD_LIBRARY_PATH', None)
return env
def _get_native_steam_service(self):
"""Get native Steam operations service instance"""
if self._native_steam_service is None:
from ..services.native_steam_operations_service import NativeSteamOperationsService
self._native_steam_service = NativeSteamOperationsService(steamdeck=self.steamdeck)
return self._native_steam_service
def detect_protontricks(self):
"""
Detect if protontricks is installed and whether it's flatpak or native.
@@ -137,9 +149,29 @@ class ProtontricksHandler:
should_install = True
else:
try:
response = input("Protontricks not found. Install the Flatpak version? (Y/n): ").lower()
if response == 'y' or response == '':
print("\nProtontricks not found. Choose installation method:")
print("1. Install via Flatpak (automatic)")
print("2. Install via native package manager (manual)")
print("3. Skip (Use bundled winetricks instead)")
choice = input("Enter choice (1/2/3): ").strip()
if choice == '1' or choice == '':
should_install = True
elif choice == '2':
print("\nTo install protontricks via your system package manager:")
print("• Ubuntu/Debian: sudo apt install protontricks")
print("• Fedora: sudo dnf install protontricks")
print("• Arch Linux: sudo pacman -S protontricks")
print("• openSUSE: sudo zypper install protontricks")
print("\nAfter installation, please rerun Jackify.")
return False
elif choice == '3':
print("Skipping protontricks installation. Will use bundled winetricks for component installation.")
logger.info("User chose to skip protontricks and use winetricks fallback")
return False
else:
print("Invalid choice. Installation cancelled.")
return False
except KeyboardInterrupt:
print("\nInstallation cancelled.")
return False
@@ -255,9 +287,19 @@ class ProtontricksHandler:
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
"""
# Use native operations if enabled
if self.use_native_operations:
logger.debug("Using native Steam operations, permissions handled natively")
try:
return self._get_native_steam_service().set_steam_permissions(modlist_dir, steamdeck)
except Exception as e:
logger.warning(f"Native permissions failed, falling back to protontricks: {e}")
if self.which_protontricks != 'flatpak':
logger.debug("Using Native protontricks, skip setting permissions")
return True
@@ -338,15 +380,22 @@ class ProtontricksHandler:
# Renamed from list_non_steam_games for clarity and purpose
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
"Non-Steam shortcut: [Name] ([AppID])".
Uses native VDF parsing when enabled, falls back to protontricks -l parsing.
Returns:
A dictionary mapping the shortcut name (AppName) to its AppID.
Returns an empty dictionary if none are found or an error occurs.
"""
# Use native operations if enabled
if self.use_native_operations:
logger.info("Listing non-Steam shortcuts via native VDF parsing...")
try:
return self._get_native_steam_service().list_non_steam_shortcuts()
except Exception as e:
logger.warning(f"Native shortcut listing failed, falling back to protontricks: {e}")
logger.info("Listing ALL non-Steam shortcuts via protontricks...")
non_steam_shortcuts = {}
# --- Ensure protontricks is detected before proceeding ---
@@ -459,7 +508,7 @@ class ProtontricksHandler:
if "ShowDotFiles" not in content:
logger.debug(f"Adding ShowDotFiles entry to {user_reg_path}")
with open(user_reg_path, 'a', encoding='utf-8') as f:
f.write('\n[Software\\Wine] 1603891765\n')
f.write('\n[Software\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n')
dotfiles_set_success = True # Count file write as success too
else:
@@ -468,7 +517,7 @@ class ProtontricksHandler:
else:
logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
with open(user_reg_path, 'w', encoding='utf-8') as f:
f.write('[Software\\Wine] 1603891765\n')
f.write('[Software\\Wine] 1603891765\n')
f.write('"ShowDotFiles"="Y"\n')
dotfiles_set_success = True # Creating file counts as success
except Exception as e:
@@ -577,12 +626,22 @@ class ProtontricksHandler:
def get_wine_prefix_path(self, appid) -> Optional[str]:
"""Gets the WINEPREFIX path for a given AppID.
Uses native path discovery when enabled, falls back to protontricks detection.
Args:
appid (str): The Steam AppID.
Returns:
The WINEPREFIX path as a string, or None if detection fails.
"""
# Use native operations if enabled
if self.use_native_operations:
logger.debug(f"Getting WINEPREFIX for AppID {appid} via native path discovery")
try:
return self._get_native_steam_service().get_wine_prefix_path(appid)
except Exception as e:
logger.warning(f"Native WINEPREFIX detection failed, falling back to protontricks: {e}")
logger.debug(f"Getting WINEPREFIX for AppID {appid}")
result = self.run_protontricks("-c", "echo $WINEPREFIX", appid)
if result and result.returncode == 0 and result.stdout.strip():

View File

@@ -41,7 +41,7 @@ class ShortcutHandler:
self._last_shortcuts_backup = None # Track the last backup path
self._safe_shortcuts_backup = None # Track backup made just before restart
# Initialize ProtontricksHandler here, passing steamdeck status
self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck)
self.protontricks_handler = ProtontricksHandler(self.steamdeck)
def _enable_tab_completion(self):
"""Enable tab completion for file paths using the shared completer"""
@@ -964,7 +964,7 @@ class ShortcutHandler:
self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')")
try:
from .protontricks_handler import ProtontricksHandler # Local import
pt_handler = ProtontricksHandler(steamdeck=self.steamdeck)
pt_handler = ProtontricksHandler(self.steamdeck)
if not pt_handler.detect_protontricks():
self.logger.error("Protontricks not detected")
return None
@@ -988,8 +988,8 @@ class ShortcutHandler:
shortcuts_data = VDFHandler.load(shortcuts_vdf_path, binary=True)
if shortcuts_data and 'shortcuts' in shortcuts_data:
for idx, shortcut in shortcuts_data['shortcuts'].items():
app_name = shortcut.get('AppName', '').strip()
exe = shortcut.get('Exe', '').strip('"').strip()
app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip()
vdf_shortcuts.append((app_name, exe, idx))
except Exception as e:
self.logger.error(f"Error parsing shortcuts.vdf for exe path matching: {e}")
@@ -1036,7 +1036,7 @@ class ShortcutHandler:
matched_shortcuts = []
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
self.logger.error(f"shortcuts.vdf path not found or invalid: {self.shortcuts_path}")
self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations")
return []
# Directly process the single shortcuts.vdf file found during init
@@ -1054,9 +1054,9 @@ class ShortcutHandler:
self.logger.warning(f"Skipping invalid shortcut entry (not a dict) at index {shortcut_id} in {shortcuts_file}")
continue
app_name = shortcut.get('AppName')
exe_path = shortcut.get('Exe', '').strip('"')
start_dir = shortcut.get('StartDir', '').strip('"')
app_name = shortcut.get('AppName', shortcut.get('appname'))
exe_path = shortcut.get('Exe', shortcut.get('exe', '')).strip('"')
start_dir = shortcut.get('StartDir', shortcut.get('startdir', '')).strip('"')
# Check if the base name of the exe_path matches the target
if app_name and start_dir and os.path.basename(exe_path) == executable_name:
@@ -1159,7 +1159,7 @@ class ShortcutHandler:
# --- Use the single shortcuts.vdf path found during init ---
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
self.logger.error(f"shortcuts.vdf path not found or invalid: {self.shortcuts_path}")
self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations")
return []
vdf_path = self.shortcuts_path
@@ -1180,18 +1180,21 @@ class ShortcutHandler:
self.logger.warning(f"Skipping invalid shortcut entry at index {index} in {vdf_path}")
continue
exe_path = shortcut_details.get('Exe', '').strip('"') # Get Exe path, remove quotes
app_name = shortcut_details.get('AppName', 'Unknown Shortcut')
exe_path = shortcut_details.get('Exe', shortcut_details.get('exe', '')).strip('"') # Get Exe path, remove quotes
app_name = shortcut_details.get('AppName', shortcut_details.get('appname', 'Unknown Shortcut'))
# Check if the executable_name is present in the Exe path
if executable_name in os.path.basename(exe_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 = {
'AppName': app_name,
'Exe': exe_path, # Store unquoted path
'StartDir': shortcut_details.get('StartDir', '').strip('"') # Unquoted
# Add other useful fields if needed, e.g., 'ShortcutPath'
'StartDir': start_dir,
'appid': app_id # Include the AppID for conversion to unsigned
}
matching_shortcuts.append(match)
else:

View File

@@ -222,15 +222,21 @@ class ValidationHandler:
def validate_steam_shortcut(self, app_id: str) -> Tuple[bool, str]:
"""Validate a Steam shortcut."""
try:
# Check if shortcuts.vdf exists
shortcuts_path = Path.home() / '.steam' / 'steam' / 'userdata' / '75424832' / 'config' / 'shortcuts.vdf'
# Use native Steam service to get proper shortcuts.vdf path with multi-user support
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
shortcuts_path = steam_service.get_shortcuts_vdf_path()
if not shortcuts_path:
return False, "Could not determine shortcuts.vdf path (no active Steam user found)"
if not shortcuts_path.exists():
return False, "shortcuts.vdf not found"
# Check if shortcuts.vdf is accessible
if not os.access(shortcuts_path, os.R_OK | os.W_OK):
return False, "shortcuts.vdf is not accessible"
# Parse shortcuts.vdf using VDFHandler
shortcuts_data = VDFHandler.load(str(shortcuts_path), binary=True)

View File

@@ -132,7 +132,8 @@ class WabbajackParser:
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered'
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
return [display_names.get(game, game) for game in self.supported_games]

View File

@@ -13,7 +13,7 @@ import shutil
import time
from pathlib import Path
import glob
from typing import Optional, Tuple
from typing import Optional, Tuple, List, Dict
from .subprocess_utils import get_clean_subprocess_env
# Initialize logger
@@ -197,16 +197,43 @@ class WineUtils:
logger.error(f"Error editing binary working paths: {e}")
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
def _strip_sdcard_path(path):
"""
Strip /run/media/deck/UUID from SD card paths
Internal helper method
Strip any detected SD card mount prefix from paths
Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns
"""
if path.startswith("/run/media/deck/"):
parts = path.split("/", 5)
if len(parts) >= 6:
return "/" + parts[5]
sd_mounts = WineUtils._get_sd_card_mounts()
for mount in sd_mounts:
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
@staticmethod
@@ -510,10 +537,7 @@ class WineUtils:
if "mods" in binary_path:
# mods path type found
if modlist_sdcard:
path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir
# Strip /run/media/deck/UUID if present
if '/run/media/' in path_middle:
path_middle = '/' + path_middle.split('/run/media/', 1)[1].split('/', 2)[2]
path_middle = WineUtils._strip_sdcard_path(modlist_dir)
else:
path_middle = modlist_dir
@@ -523,10 +547,7 @@ class WineUtils:
elif any(x in binary_path for x in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]):
# Stock/Game Root found
if modlist_sdcard:
path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir
# Strip /run/media/deck/UUID if present
if '/run/media/' in path_middle:
path_middle = '/' + path_middle.split('/run/media/', 1)[1].split('/', 2)[2]
path_middle = WineUtils._strip_sdcard_path(modlist_dir)
else:
path_middle = modlist_dir
@@ -562,7 +583,7 @@ class WineUtils:
elif "steamapps" in binary_path:
# Steamapps found
if basegame_sdcard:
path_middle = steam_library.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in steam_library else steam_library
path_middle = WineUtils._strip_sdcard_path(steam_library)
drive_letter = "D:"
else:
path_middle = steam_library.split('steamapps', 1)[0] if 'steamapps' in steam_library else steam_library
@@ -609,12 +630,49 @@ class WineUtils:
"""
# Clean up the version string for directory matching
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
# Standard Steam library locations
steam_common_paths = [
Path.home() / ".steam/steam/steamapps/common",
Path.home() / ".local/share/Steam/steamapps/common",
Path.home() / ".steam/root/steamapps/common"
]
# 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 = [
Path.home() / ".steam/steam/steamapps/common",
Path.home() / ".local/share/Steam/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",
# Flatpak GE-Proton extension paths
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
])
# Special handling for Proton 9: try all possible directory names
if proton_version.strip().startswith("Proton 9"):
proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"]
@@ -628,8 +686,9 @@ class WineUtils:
wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
# General case: try version patterns
for base_path in steam_common_paths:
# General case: try version patterns in both steamapps and compatibilitytools.d
all_paths = steam_common_paths + compatibility_paths
for base_path in all_paths:
if not base_path.is_dir():
continue
for pattern in version_patterns:
@@ -643,7 +702,20 @@ class WineUtils:
wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file():
return str(wine_bin)
# Fallback: Try 'Proton - Experimental' if present
# Fallback: Try user's configured Proton version
try:
from .config_handler import ConfigHandler
config = ConfigHandler()
fallback_path = config.get_proton_path()
if fallback_path != 'auto':
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
if fallback_wine_bin.is_file():
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
return str(fallback_wine_bin)
except Exception:
pass
# Final fallback: Try 'Proton - Experimental' if present
for base_path in steam_common_paths:
wine_bin = base_path / "Proton - Experimental" / "files/bin/wine"
if wine_bin.is_file():
@@ -698,4 +770,307 @@ class WineUtils:
proton_path = str(Path(wine_bin).parent.parent)
logger.debug(f"Found Proton path: {proton_path}")
return compatdata_path, proton_path, wine_bin
return compatdata_path, proton_path, wine_bin
@staticmethod
def get_steam_library_paths() -> List[Path]:
"""
Get all Steam library paths from libraryfolders.vdf (handles Flatpak, custom locations, etc.).
Returns:
List of Path objects for Steam library directories
"""
steam_common_paths = []
try:
from .path_handler import PathHandler
# Use existing PathHandler that reads libraryfolders.vdf
library_paths = PathHandler.get_all_steam_library_paths()
logger.info(f"PathHandler found Steam libraries: {library_paths}")
# Convert to steamapps/common paths for Proton scanning
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"Added Steam library: {common_path}")
else:
logger.debug(f"Steam library path doesn't exist: {common_path}")
except Exception as e:
logger.error(f"PathHandler failed to read libraryfolders.vdf: {e}")
# Always add fallback paths in case PathHandler missed something
fallback_paths = [
Path.home() / ".steam/steam/steamapps/common",
Path.home() / ".local/share/Steam/steamapps/common",
Path.home() / ".steam/root/steamapps/common"
]
for fallback_path in fallback_paths:
if fallback_path.exists() and fallback_path not in steam_common_paths:
steam_common_paths.append(fallback_path)
logger.debug(f"Added fallback Steam library: {fallback_path}")
logger.info(f"Final Steam library paths for Proton scanning: {steam_common_paths}")
return steam_common_paths
@staticmethod
def get_compatibility_tool_paths() -> List[Path]:
"""
Get all compatibility tool paths for GE-Proton and other custom Proton versions.
Returns:
List of Path objects for compatibility tool directories
"""
compat_paths = [
Path.home() / ".steam/steam/compatibilitytools.d",
Path.home() / ".local/share/Steam/compatibilitytools.d",
Path.home() / ".steam/root/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
# Flatpak GE-Proton extension paths
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
]
# Return only existing paths
return [path for path in compat_paths if path.exists()]
@staticmethod
def scan_ge_proton_versions() -> List[Dict[str, any]]:
"""
Scan for available GE-Proton versions in compatibilitytools.d directories.
Returns:
List of dicts with version info, sorted by priority (newest first)
"""
logger.info("Scanning for available GE-Proton versions...")
found_versions = []
compat_paths = WineUtils.get_compatibility_tool_paths()
if not compat_paths:
logger.warning("No compatibility tool paths found")
return []
for compat_path in compat_paths:
logger.debug(f"Scanning compatibility tools: {compat_path}")
try:
# Look for GE-Proton directories
for proton_dir in compat_path.iterdir():
if not proton_dir.is_dir():
continue
dir_name = proton_dir.name
if not dir_name.startswith("GE-Proton"):
continue
# Check for wine binary
wine_bin = proton_dir / "files" / "bin" / "wine"
if not wine_bin.exists() or not wine_bin.is_file():
logger.debug(f"Skipping {dir_name} - no wine binary found")
continue
# Parse version from directory name (e.g., "GE-Proton10-16")
version_match = re.match(r'GE-Proton(\d+)-(\d+)', dir_name)
if version_match:
major_ver = int(version_match.group(1))
minor_ver = int(version_match.group(2))
# Calculate priority: GE-Proton gets highest priority
# Priority format: 200 (base) + major*10 + minor (e.g., 200 + 100 + 16 = 316)
priority = 200 + (major_ver * 10) + minor_ver
found_versions.append({
'name': dir_name,
'path': proton_dir,
'wine_bin': wine_bin,
'priority': priority,
'major_version': major_ver,
'minor_version': minor_ver,
'type': 'GE-Proton'
})
logger.debug(f"Found {dir_name} at {proton_dir} (priority: {priority})")
else:
logger.debug(f"Skipping {dir_name} - unknown GE-Proton version format")
except Exception as e:
logger.warning(f"Error scanning {compat_path}: {e}")
# Sort by priority (highest first, so newest GE-Proton versions come first)
found_versions.sort(key=lambda x: x['priority'], reverse=True)
logger.info(f"Found {len(found_versions)} GE-Proton version(s)")
return found_versions
@staticmethod
def scan_valve_proton_versions() -> List[Dict[str, any]]:
"""
Scan for available Valve Proton versions with fallback priority.
Returns:
List of dicts with version info, sorted by priority (best first)
"""
logger.info("Scanning for available Valve Proton versions...")
found_versions = []
steam_libs = WineUtils.get_steam_library_paths()
if not steam_libs:
logger.warning("No Steam library paths found")
return []
# Priority order for Valve Proton versions
# Note: GE-Proton uses 200+ range, so Valve Proton gets 100+ range
preferred_versions = [
("Proton - Experimental", 150), # Higher priority than regular Valve Proton
("Proton 10.0", 140),
("Proton 9.0", 130),
("Proton 9.0 (Beta)", 125)
]
for steam_path in steam_libs:
logger.debug(f"Scanning Steam library: {steam_path}")
for version_name, priority in preferred_versions:
proton_path = steam_path / version_name
wine_bin = proton_path / "files" / "bin" / "wine"
if wine_bin.exists() and wine_bin.is_file():
found_versions.append({
'name': version_name,
'path': proton_path,
'wine_bin': wine_bin,
'priority': priority,
'type': 'Valve-Proton'
})
logger.debug(f"Found {version_name} at {proton_path}")
# Sort by priority (highest first)
found_versions.sort(key=lambda x: x['priority'], reverse=True)
# Remove duplicates while preserving order
unique_versions = []
seen_names = set()
for version in found_versions:
if version['name'] not in seen_names:
unique_versions.append(version)
seen_names.add(version['name'])
logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)")
return unique_versions
@staticmethod
def scan_all_proton_versions() -> List[Dict[str, any]]:
"""
Scan for all available Proton versions (GE-Proton + Valve Proton) with unified priority.
Priority Chain (highest to lowest):
1. GE-Proton10-16+ (priority 316+)
2. GE-Proton10-* (priority 200+)
3. Proton - Experimental (priority 150)
4. Proton 10.0 (priority 140)
5. Proton 9.0 (priority 130)
6. Proton 9.0 (Beta) (priority 125)
Returns:
List of dicts with version info, sorted by priority (best first)
"""
logger.info("Scanning for all available Proton versions...")
all_versions = []
# Scan GE-Proton versions (highest priority)
ge_versions = WineUtils.scan_ge_proton_versions()
all_versions.extend(ge_versions)
# Scan Valve Proton versions
valve_versions = WineUtils.scan_valve_proton_versions()
all_versions.extend(valve_versions)
# Sort by priority (highest first)
all_versions.sort(key=lambda x: x['priority'], reverse=True)
# Remove duplicates while preserving order
unique_versions = []
seen_names = set()
for version in all_versions:
if version['name'] not in seen_names:
unique_versions.append(version)
seen_names.add(version['name'])
if unique_versions:
logger.info(f"Found {len(unique_versions)} total Proton version(s)")
logger.info(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})")
else:
logger.warning("No Proton versions found")
return unique_versions
@staticmethod
def select_best_proton() -> Optional[Dict[str, any]]:
"""
Select the best available Proton version (GE-Proton or Valve Proton) using unified precedence.
Returns:
Dict with version info for the best Proton, or None if none found
"""
available_versions = WineUtils.scan_all_proton_versions()
if not available_versions:
logger.warning("No compatible Proton versions found")
return None
# Return the highest priority version (first in sorted list)
best_version = available_versions[0]
logger.info(f"Selected best Proton version: {best_version['name']} ({best_version['type']})")
return best_version
@staticmethod
def select_best_valve_proton() -> Optional[Dict[str, any]]:
"""
Select the best available Valve Proton version using fallback precedence.
Note: This method is kept for backward compatibility. Consider using select_best_proton() instead.
Returns:
Dict with version info for the best Proton, or None if none found
"""
available_versions = WineUtils.scan_valve_proton_versions()
if not available_versions:
logger.warning("No compatible Valve Proton versions found")
return None
# Return the highest priority version (first in sorted list)
best_version = available_versions[0]
logger.info(f"Selected Valve Proton version: {best_version['name']}")
return best_version
@staticmethod
def check_proton_requirements() -> Tuple[bool, str, Optional[Dict[str, any]]]:
"""
Check if compatible Proton version is available for workflows.
Returns:
tuple: (requirements_met, status_message, proton_info)
- requirements_met: True if compatible Proton found
- status_message: Human-readable status for display to user
- proton_info: Dict with Proton details if found, None otherwise
"""
logger.info("Checking Proton requirements for workflow...")
# Scan for available Proton versions (includes GE-Proton + Valve Proton)
best_proton = WineUtils.select_best_proton()
if best_proton:
# Compatible Proton found
proton_type = best_proton.get('type', 'Unknown')
status_msg = f"✓ Using {best_proton['name']} ({proton_type}) for this workflow"
logger.info(f"Proton requirements satisfied: {best_proton['name']} ({proton_type})")
return True, status_msg, best_proton
else:
# No compatible Proton found
status_msg = "✗ No compatible Proton version found (GE-Proton 10+, Proton 9+, 10, or Experimental required)"
logger.warning("Proton requirements not met - no compatible version found")
return False, status_msg, None

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,65 @@ class AutomatedPrefixService:
"""Get consistent progress timestamp"""
from jackify.shared.timing import get_timestamp
return get_timestamp()
def _get_user_proton_version(self, modlist_name: str = None):
"""Get user's preferred Proton version from config, with fallback to auto-detection
Args:
modlist_name: Optional modlist name for special handling (e.g., Lorerim)
"""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
# Check for Lorerim-specific Proton override first
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
if modlist_normalized == 'lorerim':
lorerim_proton = self._get_lorerim_preferred_proton()
if lorerim_proton:
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
self._store_proton_override_notification("Lorerim", lorerim_proton)
return lorerim_proton
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
if modlist_normalized == 'lostlegacy':
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
if lostlegacy_proton:
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
return lostlegacy_proton
config_handler = ConfigHandler()
user_proton_path = config_handler.get_game_proton_path()
if user_proton_path == 'auto':
# Use enhanced fallback logic with GE-Proton preference
logger.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence")
return WineUtils.select_best_proton()
else:
# User has selected a specific Proton version
# Use the exact directory name for Steam config.vdf
try:
proton_version = os.path.basename(user_proton_path)
# GE-Proton uses exact directory name, Valve Proton needs lowercase conversion
if proton_version.startswith('GE-Proton'):
# Keep GE-Proton name exactly as-is
steam_proton_name = proton_version
else:
# Convert Valve Proton names to Steam's format
steam_proton_name = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
if not steam_proton_name.startswith('proton'):
steam_proton_name = f"proton_{steam_proton_name}"
logger.info(f"Using user-selected Proton: {steam_proton_name}")
return steam_proton_name
except Exception as e:
logger.warning(f"Invalid user Proton path '{user_proton_path}', falling back to auto: {e}")
return WineUtils.select_best_proton()
except Exception as e:
logger.error(f"Failed to get user Proton preference, using default: {e}")
return "proton_experimental"
def create_shortcut_with_native_service(self, shortcut_name: str, exe_path: str,
@@ -87,6 +146,9 @@ class AutomatedPrefixService:
logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}")
launch_options = "%command%"
# Get user's preferred Proton version (with Lorerim-specific override)
proton_version = self._get_user_proton_version(shortcut_name)
# Create shortcut with Proton using native service
success, app_id = steam_service.create_shortcut_with_proton(
app_name=shortcut_name,
@@ -94,7 +156,7 @@ class AutomatedPrefixService:
start_dir=modlist_install_dir,
launch_options=launch_options,
tags=["Jackify"],
proton_version="proton_experimental"
proton_version=proton_version
)
if success and app_id:
@@ -292,15 +354,18 @@ class AutomatedPrefixService:
logger.error(f"Steam userdata directory not found: {userdata_dir}")
return None
# Find the first user directory (most systems have only one user)
user_dirs = [d for d in userdata_dir.iterdir() if d.is_dir() and d.name.isdigit()]
if not user_dirs:
logger.error("No Steam user directories found in userdata")
# Use NativeSteamService for proper user detection
from ..services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
if not steam_service.find_steam_user():
logger.error("Could not detect Steam user for shortcuts")
return None
shortcuts_path = steam_service.get_shortcuts_vdf_path()
if not shortcuts_path:
logger.error("Could not get shortcuts.vdf path from Steam service")
return None
# Use the first user directory found
user_dir = user_dirs[0]
shortcuts_path = user_dir / "config" / "shortcuts.vdf"
logger.debug(f"Looking for shortcuts.vdf at: {shortcuts_path}")
if not shortcuts_path.exists():
@@ -407,7 +472,7 @@ exit"""
if shortcut_name in name:
appid = shortcut.get('appid')
exe_path = shortcut.get('Exe', '')
exe_path = shortcut.get('Exe', '').strip('"')
logger.info(f"Found shortcut: {name}")
logger.info(f" AppID: {appid}")
@@ -443,7 +508,7 @@ exit"""
try:
# Use the existing protontricks handler
from jackify.backend.handlers.protontricks_handler import ProtontricksHandler
protontricks_handler = ProtontricksHandler(steamdeck=steamdeck or False)
protontricks_handler = ProtontricksHandler(steamdeck or False)
result = protontricks_handler.run_protontricks('-l')
if result.returncode == 0:
@@ -1512,6 +1577,9 @@ echo Prefix creation complete.
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam Configuration complete!")
# Show Proton override notification if applicable
self._show_proton_override_notification(progress_callback)
logger.info(" Simple automated prefix creation workflow completed successfully")
return True, prefix_path, actual_appid
@@ -1718,21 +1786,15 @@ echo Prefix creation complete.
progress_callback("=== Steam Integration ===")
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
modlist_handler = ModlistHandler()
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
if special_game_type == "enderal":
custom_launch_options = self._generate_special_game_launch_options(special_game_type, modlist_install_dir)
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")
if special_game_type in ["fnv", "enderal"]:
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
else:
logger.debug("Standard modlist - no special game handling needed")
@@ -1808,23 +1870,19 @@ echo Prefix creation complete.
if progress_callback:
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)
prefix_path = self.get_prefix_path(appid)
if special_game_type == "fnv":
logger.info("Step 5: Injecting FNV game registry entries")
if special_game_type in ["fnv", "enderal"]:
logger.info(f"Step 5: Injecting {special_game_type.upper()} game registry entries")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Injecting FNV game registry entries...")
progress_callback(f"{self._get_progress_timestamp()} Injecting {special_game_type.upper()} game registry entries...")
if prefix_path:
self._inject_game_registry_entries(str(prefix_path))
else:
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:
logger.info("Step 5: Skipping registry injection for standard modlist")
if progress_callback:
@@ -1835,6 +1893,11 @@ echo Prefix creation complete.
if progress_callback:
progress_callback(f"{last_timestamp} Steam integration complete")
progress_callback("") # Blank line after Steam integration complete
# Show Proton override notification if applicable
self._show_proton_override_notification(progress_callback)
if progress_callback:
progress_callback("") # Extra blank line to span across Configuration Summary
progress_callback("") # And one more to create space before Prefix Configuration
@@ -2496,15 +2559,31 @@ echo Prefix creation complete.
Returns:
Path to localconfig.vdf or None if not found
"""
# Try the standard Steam userdata path
steam_userdata_path = Path.home() / ".steam" / "steam" / "userdata"
if steam_userdata_path.exists():
# Find the first user directory (usually only one on Steam Deck)
user_dirs = [d for d in steam_userdata_path.iterdir() if d.is_dir() and d.name.isdigit()]
if user_dirs:
localconfig_path = user_dirs[0] / "config" / "localconfig.vdf"
# Use NativeSteamService for proper user detection
try:
from ..services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
if steam_service.find_steam_user():
localconfig_path = steam_service.user_config_path / "localconfig.vdf"
if localconfig_path.exists():
return str(localconfig_path)
except Exception as e:
logger.error(f"Error using Steam service for localconfig.vdf detection: {e}")
# Fallback to manual detection
steam_userdata_path = Path.home() / ".steam" / "steam" / "userdata"
if steam_userdata_path.exists():
user_dirs = [d for d in steam_userdata_path.iterdir() if d.is_dir() and d.name.isdigit() and d.name != "0"]
if user_dirs:
# Use most recently modified directory as fallback
try:
most_recent = max(user_dirs, key=lambda d: d.stat().st_mtime)
localconfig_path = most_recent / "config" / "localconfig.vdf"
if localconfig_path.exists():
return str(localconfig_path)
except Exception:
pass
logger.error("Could not find localconfig.vdf")
return None
@@ -2601,8 +2680,11 @@ echo Prefix creation complete.
env = os.environ.copy()
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root)
env['STEAM_COMPAT_DATA_PATH'] = str(compatdata_dir / str(abs(appid)))
# Suppress GUI windows by unsetting DISPLAY
# Suppress GUI windows using jackify-engine's proven approach
env['DISPLAY'] = ''
env['WAYLAND_DISPLAY'] = ''
env['WINEDEBUG'] = '-all'
env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d'
# Create the compatdata directory
compat_dir = compatdata_dir / str(abs(appid))
@@ -2615,8 +2697,19 @@ echo Prefix creation complete.
# Run proton run wineboot -u to initialize the prefix
cmd = [str(proton_path), 'run', 'wineboot', '-u']
logger.info(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=60)
# Adjust timeout for SD card installations on Steam Deck (slower I/O)
from ..services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
is_steamdeck_sdcard = (platform_service.is_steamdeck and
str(proton_path).startswith('/run/media/'))
timeout = 180 if is_steamdeck_sdcard else 60
if is_steamdeck_sdcard:
logger.info(f"Using extended timeout ({timeout}s) for Steam Deck SD card Proton installation")
# Use jackify-engine's approach: UseShellExecute=false, CreateNoWindow=true equivalent
result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout,
shell=False, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
logger.info(f"Proton exit code: {result.returncode}")
if result.stdout:
@@ -2644,31 +2737,64 @@ echo Prefix creation complete.
return False
def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]:
"""Locate a Proton wrapper script to use (prefer Experimental)."""
candidates = []
preferred = [
"Proton - Experimental",
"Proton 9.0",
"Proton 8.0",
"Proton Hotfix",
]
for name in preferred:
p = proton_common_dir / name / "proton"
if p.exists():
candidates.append(p)
# As a fallback, scan all Proton* dirs
if not candidates and proton_common_dir.exists():
for p in proton_common_dir.glob("Proton*/proton"):
candidates.append(p)
if not candidates:
logger.error("No Proton wrapper found under steamapps/common")
"""Locate a Proton wrapper script to use, respecting user's configuration."""
try:
from jackify.backend.handlers.config_handler import ConfigHandler
from jackify.backend.handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_game_proton_path()
# If user selected a specific Proton, try that first
if user_proton_path != 'auto':
# Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam
resolved_proton_path = os.path.realpath(user_proton_path)
# Check for wine binary in different Proton structures
valve_proton_wine = Path(resolved_proton_path) / "dist" / "bin" / "wine"
ge_proton_wine = Path(resolved_proton_path) / "files" / "bin" / "wine"
if valve_proton_wine.exists() or ge_proton_wine.exists():
# Found user's Proton, now find the proton wrapper script
proton_wrapper = Path(resolved_proton_path) / "proton"
if proton_wrapper.exists():
logger.info(f"Using user-selected Proton wrapper: {proton_wrapper}")
return proton_wrapper
else:
logger.warning(f"User-selected Proton missing wrapper script: {proton_wrapper}")
else:
logger.warning(f"User-selected Proton path invalid: {user_proton_path}")
# Fall back to auto-detection
logger.info("Falling back to automatic Proton detection")
candidates = []
preferred = [
"Proton - Experimental",
"Proton 9.0",
"Proton 8.0",
"Proton Hotfix",
]
for name in preferred:
p = proton_common_dir / name / "proton"
if p.exists():
candidates.append(p)
# As a fallback, scan all Proton* dirs
if not candidates and proton_common_dir.exists():
for p in proton_common_dir.glob("Proton*/proton"):
candidates.append(p)
if not candidates:
logger.error("No Proton wrapper found under steamapps/common")
return None
logger.info(f"Using auto-detected Proton wrapper: {candidates[0]}")
return candidates[0]
except Exception as e:
logger.error(f"Error finding Proton binary: {e}")
return None
logger.info(f"Using Proton wrapper: {candidates[0]}")
return candidates[0]
def replace_existing_shortcut(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Tuple[bool, Optional[int]]:
"""
@@ -2718,26 +2844,39 @@ echo Prefix creation complete.
def verify_compatibility_tool_persists(self, appid: int) -> bool:
"""
Verify that the compatibility tool setting persists.
Verify that the compatibility tool setting persists with correct Proton version.
Args:
appid: The AppID to check
Returns:
True if compatibility tool is set, False otherwise
True if compatibility tool is correctly set, False otherwise
"""
try:
config_path = Path.home() / ".steam/steam/config/config.vdf"
with open(config_path, 'r') as f:
if not config_path.exists():
logger.warning("Steam config.vdf not found")
return False
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
# Check if AppID exists and has a Proton version set
if f'"{appid}"' in content:
logger.info(" Compatibility tool persists")
return True
# Get the expected Proton version
expected_proton = self._get_user_proton_version()
# Look for the Proton version in the compatibility tool mapping
if expected_proton in content:
logger.info(f" Compatibility tool persists: {expected_proton}")
return True
else:
logger.warning(f"AppID {appid} found but Proton version '{expected_proton}' not set")
return False
else:
logger.warning("Compatibility tool not found")
return False
except Exception as e:
logger.error(f"Error verifying compatibility tool: {e}")
return False
@@ -2851,14 +2990,115 @@ echo Prefix creation complete.
logger.error(f"Failed to update registry path: {e}")
return False
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists"""
try:
prefix_path = os.path.join(modlist_compatdata_path, "pfx")
if not os.path.exists(prefix_path):
logger.warning(f"Prefix path not found: {prefix_path}")
return False
logger.info("Applying universal dotnet4.x compatibility registry fixes...")
# Find the appropriate Wine binary to use for registry operations
wine_binary = self._find_wine_binary_for_registry(modlist_compatdata_path)
if not wine_binary:
logger.error("Could not find Wine binary for registry operations")
return False
# Set environment for Wine registry operations
env = os.environ.copy()
env['WINEPREFIX'] = prefix_path
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
# Registry fix 1: Set mscoree=native DLL override
# This tells Wine to use native .NET runtime instead of Wine's implementation
logger.debug("Setting mscoree=native DLL override...")
cmd1 = [
wine_binary, 'reg', 'add',
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
]
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True, errors='replace')
if result1.returncode == 0:
logger.info("Successfully applied mscoree=native DLL override")
else:
logger.warning(f"Failed to set mscoree DLL override: {result1.stderr}")
# Registry fix 2: Set OnlyUseLatestCLR=1
# This prevents .NET version conflicts by using the latest CLR
logger.debug("Setting OnlyUseLatestCLR=1 registry entry...")
cmd2 = [
wine_binary, 'reg', 'add',
'HKEY_LOCAL_MACHINE\\Software\\Microsoft\\.NETFramework',
'/v', 'OnlyUseLatestCLR', '/t', 'REG_DWORD', '/d', '1', '/f'
]
result2 = subprocess.run(cmd2, env=env, capture_output=True, text=True, errors='replace')
if result2.returncode == 0:
logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
logger.warning(f"Failed to set OnlyUseLatestCLR: {result2.stderr}")
# Both fixes applied - this should eliminate dotnet4.x installation requirements
if result1.returncode == 0 and result2.returncode == 0:
logger.info("Universal dotnet4.x compatibility fixes applied successfully")
return True
else:
logger.warning("Some dotnet4.x registry fixes failed, but continuing...")
return False
except Exception as e:
logger.error(f"Failed to apply universal dotnet4.x fixes: {e}")
return False
def _find_wine_binary_for_registry(self, modlist_compatdata_path: str) -> Optional[str]:
"""Find the appropriate Wine binary for registry operations"""
try:
# Method 1: Try to detect from Steam's config or use Proton from compat data
# Look for wine binary in common Proton locations
proton_paths = [
os.path.expanduser("~/.local/share/Steam/compatibilitytools.d"),
os.path.expanduser("~/.steam/steam/steamapps/common")
]
for base_path in proton_paths:
if os.path.exists(base_path):
for item in os.listdir(base_path):
if 'proton' in item.lower():
wine_path = os.path.join(base_path, item, 'files', 'bin', 'wine')
if os.path.exists(wine_path):
logger.debug(f"Found Wine binary: {wine_path}")
return wine_path
# Method 2: Fallback to system wine if available
try:
result = subprocess.run(['which', 'wine'], capture_output=True, text=True)
if result.returncode == 0:
wine_path = result.stdout.strip()
logger.debug(f"Using system Wine binary: {wine_path}")
return wine_path
except Exception:
pass
logger.error("No suitable Wine binary found for registry operations")
return None
except Exception as e:
logger.error(f"Error finding Wine binary: {e}")
return None
def _inject_game_registry_entries(self, modlist_compatdata_path: str):
"""Detect and inject FNV/Enderal game paths into modlist's system.reg"""
"""Detect and inject FNV/Enderal game paths and apply universal dotnet4.x compatibility fixes"""
system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg")
if not os.path.exists(system_reg_path):
logger.warning("system.reg not found, skipping game path injection")
return
logger.info("Detecting and injecting game registry entries...")
logger.info("Detecting game registry entries...")
# NOTE: Universal dotnet4.x registry fixes now applied in modlist_handler.py after .reg downloads
# Game configurations
games_config = {
@@ -2889,10 +3129,107 @@ echo Prefix creation complete.
)
if success:
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:
logger.warning(f"Failed to update registry entry for {config['name']}")
else:
logger.debug(f"{config['name']} not found in Steam libraries")
logger.info("Game registry injection completed")
def _get_lorerim_preferred_proton(self):
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
try:
from jackify.backend.handlers.wine_utils import WineUtils
# Get all available Proton versions
available_versions = WineUtils.scan_all_proton_versions()
if not available_versions:
logger.warning("No Proton versions found for Lorerim override")
return None
# Priority order for Lorerim:
# 1. GEProton9-27 (specific version)
# 2. Other GEProton-9 versions (latest first)
# 3. Valve Proton 9 (any version)
preferred_candidates = []
for version in available_versions:
version_name = version['name']
# Priority 1: GEProton9-27 specifically
if version_name == 'GE-Proton9-27':
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
return version_name
# Priority 2: Other GE-Proton 9 versions
elif version_name.startswith('GE-Proton9-'):
preferred_candidates.append(('ge_proton_9', version_name, version))
# Priority 3: Valve Proton 9
elif 'Proton 9' in version_name:
preferred_candidates.append(('valve_proton_9', version_name, version))
# Return best candidate if any found
if preferred_candidates:
# Sort by priority (GE-Proton first, then by name for latest)
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
best_candidate = preferred_candidates[0]
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
return best_candidate[1]
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
return None
except Exception as e:
logger.error(f"Error detecting Lorerim Proton preference: {e}")
return None
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
"""Store Proton override information for end-of-install notification"""
try:
# Store override info for later display
if not hasattr(self, '_proton_overrides'):
self._proton_overrides = []
self._proton_overrides.append({
'modlist': modlist_name,
'proton_version': proton_version,
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
})
logger.debug(f"Stored Proton override notification: {modlist_name}{proton_version}")
except Exception as e:
logger.error(f"Failed to store Proton override notification: {e}")
def _show_proton_override_notification(self, progress_callback=None):
"""Display any Proton override notifications to the user"""
try:
if hasattr(self, '_proton_overrides') and self._proton_overrides:
for override in self._proton_overrides:
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
if progress_callback:
progress_callback("")
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
logger.info(notification_msg)
# Clear notifications after display
self._proton_overrides = []
except Exception as e:
logger.error(f"Failed to show Proton override notification: {e}")

View File

@@ -34,8 +34,10 @@ class ModlistService:
"""Lazy initialization of modlist handler."""
if self._modlist_handler is None:
from ..handlers.modlist_handler import ModlistHandler
# Initialize with proper dependencies
self._modlist_handler = ModlistHandler()
from ..services.platform_detection_service import PlatformDetectionService
# Initialize with proper dependencies and centralized Steam Deck detection
platform_service = PlatformDetectionService.get_instance()
self._modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck)
return self._modlist_handler
def _get_wabbajack_handler(self):
@@ -293,15 +295,7 @@ class ModlistService:
elif context.get('machineid'):
cmd += ['-m', context['machineid']]
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
# Store original environment values (copied from working code)
@@ -637,8 +631,13 @@ class ModlistService:
'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'),
'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True, # Service layer should be non-interactive
'manual_steps_completed': False
'manual_steps_completed': False,
'appid': getattr(context, 'app_id', None) # Fix: Include appid like other configuration paths
}
# DEBUG: Log what resolution we're passing
logger.info(f"DEBUG: config_context resolution = {config_context['resolution']}")
logger.info(f"DEBUG: context.resolution = {getattr(context, 'resolution', 'NOT_SET')}")
# Run the complete configuration phase
success = modlist_menu.run_modlist_configuration_phase(config_context)

View 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

View File

@@ -15,6 +15,8 @@ import vdf
from pathlib import Path
from typing import Optional, Tuple, Dict, Any, List
from ..handlers.vdf_handler import VDFHandler
logger = logging.getLogger(__name__)
class NativeSteamService:
@@ -28,37 +30,153 @@ class NativeSteamService:
"""
def __init__(self):
self.steam_path = Path.home() / ".steam" / "steam"
self.userdata_path = self.steam_path / "userdata"
self.steam_paths = [
Path.home() / ".steam" / "steam",
Path.home() / ".local" / "share" / "Steam",
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam",
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam",
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam"
]
self.steam_path = None
self.userdata_path = None
self.user_id = None
self.user_config_path = None
def find_steam_user(self) -> bool:
"""Find the active Steam user directory"""
"""
Find the active Steam user directory using Steam's own configuration files.
No more guessing - uses loginusers.vdf to get the most recent user and converts SteamID64 to SteamID3.
"""
try:
if not self.userdata_path.exists():
logger.error("Steam userdata directory not found")
# Step 1: Find Steam installation using Steam's own file structure
if not self._find_steam_installation():
logger.error("No Steam installation found")
return False
# Find the first user directory (usually there's only one)
user_dirs = [d for d in self.userdata_path.iterdir() if d.is_dir() and d.name.isdigit()]
if not user_dirs:
logger.error("No Steam user directories found")
# Step 2: Parse loginusers.vdf to get the most recent user (SteamID64)
steamid64 = self._get_most_recent_user_from_loginusers()
if not steamid64:
logger.warning("Could not determine most recent Steam user from loginusers.vdf, trying fallback method")
# Fallback: Look for existing user directories in userdata
steamid3 = self._find_user_from_userdata_directory()
if steamid3:
logger.info(f"Found Steam user using userdata directory fallback: SteamID3={steamid3}")
# Skip the conversion step since we already have SteamID3
self.user_id = str(steamid3)
self.user_config_path = self.userdata_path / str(steamid3) / "config"
logger.info(f"Steam user set up via fallback: {self.user_id}")
logger.info(f"User config path: {self.user_config_path}")
return True
else:
logger.error("Could not determine Steam user using any method")
return False
# Step 3: Convert SteamID64 to SteamID3 (userdata directory format)
steamid3 = self._convert_steamid64_to_steamid3(steamid64)
logger.info(f"Most recent Steam user: SteamID64={steamid64}, SteamID3={steamid3}")
# Step 4: Verify the userdata directory exists
user_dir = self.userdata_path / str(steamid3)
if not user_dir.exists():
logger.error(f"Userdata directory does not exist: {user_dir}")
return False
# Use the first user directory
user_dir = user_dirs[0]
self.user_id = user_dir.name
self.user_config_path = user_dir / "config"
logger.info(f"Found Steam user: {self.user_id}")
config_dir = user_dir / "config"
if not config_dir.exists():
logger.error(f"User config directory does not exist: {config_dir}")
return False
# Step 5: Set up the service state
self.user_id = str(steamid3)
self.user_config_path = config_dir
logger.info(f"VERIFIED Steam user: {self.user_id}")
logger.info(f"User config path: {self.user_config_path}")
logger.info(f"Shortcuts.vdf will be at: {self.user_config_path / 'shortcuts.vdf'}")
return True
except Exception as e:
logger.error(f"Error finding Steam user: {e}")
logger.error(f"Error finding Steam user: {e}", exc_info=True)
return False
def _find_steam_installation(self) -> bool:
"""Find Steam installation by checking for config/loginusers.vdf"""
for steam_path in self.steam_paths:
loginusers_path = steam_path / "config" / "loginusers.vdf"
userdata_path = steam_path / "userdata"
if loginusers_path.exists() and userdata_path.exists():
self.steam_path = steam_path
self.userdata_path = userdata_path
logger.info(f"Found Steam installation at: {steam_path}")
return True
return False
def _get_most_recent_user_from_loginusers(self) -> Optional[str]:
"""
Parse loginusers.vdf to get the SteamID64 of the most recent user.
Uses Steam's own MostRecent flag and Timestamp.
"""
try:
loginusers_path = self.steam_path / "config" / "loginusers.vdf"
# Load VDF data
vdf_data = VDFHandler.load(str(loginusers_path), binary=False)
if not vdf_data:
logger.error("Failed to parse loginusers.vdf")
return None
users_section = vdf_data.get("users", {})
if not users_section:
logger.error("No users section found in loginusers.vdf")
return None
most_recent_user = None
most_recent_timestamp = 0
# Find user with MostRecent=1 or highest timestamp
for steamid64, user_data in users_section.items():
if isinstance(user_data, dict):
# Check for MostRecent flag first
if user_data.get("MostRecent") == "1":
logger.info(f"Found user marked as MostRecent: {steamid64}")
return steamid64
# Also track highest timestamp as fallback
timestamp = int(user_data.get("Timestamp", "0"))
if timestamp > most_recent_timestamp:
most_recent_timestamp = timestamp
most_recent_user = steamid64
# Return user with highest timestamp if no MostRecent flag found
if most_recent_user:
logger.info(f"Found most recent user by timestamp: {most_recent_user}")
return most_recent_user
logger.error("No valid users found in loginusers.vdf")
return None
except Exception as e:
logger.error(f"Error parsing loginusers.vdf: {e}")
return None
def _convert_steamid64_to_steamid3(self, steamid64: str) -> int:
"""
Convert SteamID64 to SteamID3 (used in userdata directory names).
Formula: SteamID3 = SteamID64 - 76561197960265728
"""
try:
steamid64_int = int(steamid64)
steamid3 = steamid64_int - 76561197960265728
logger.debug(f"Converted SteamID64 {steamid64} to SteamID3 {steamid3}")
return steamid3
except ValueError as e:
logger.error(f"Invalid SteamID64 format: {steamid64}")
raise
def get_shortcuts_vdf_path(self) -> Optional[Path]:
"""Get the path to shortcuts.vdf"""
if not self.user_config_path:
@@ -241,16 +359,22 @@ class NativeSteamService:
def set_proton_version(self, app_id: int, proton_version: str = "proton_experimental") -> bool:
"""
Set the Proton version for a specific app using ONLY config.vdf like steam-conductor does.
Args:
app_id: The unsigned AppID
app_id: The unsigned AppID
proton_version: The Proton version to set
Returns:
True if successful
"""
# Ensure Steam user detection is completed first
if not self.steam_path:
if not self.find_steam_user():
logger.error("Cannot set Proton version: Steam user detection failed")
return False
logger.info(f"Setting Proton version '{proton_version}' for AppID {app_id} using STL-compatible format")
try:
# Step 1: Write to the main config.vdf for CompatToolMapping
config_path = self.steam_path / "config" / "config.vdf"
@@ -272,8 +396,27 @@ class NativeSteamService:
# Find the CompatToolMapping section
compat_start = config_text.find('"CompatToolMapping"')
if compat_start == -1:
logger.error("CompatToolMapping section not found in config.vdf")
return False
logger.warning("CompatToolMapping section not found in config.vdf, creating it")
# Find the Steam section to add CompatToolMapping to
steam_section = config_text.find('"Steam"')
if steam_section == -1:
logger.error("Steam section not found in config.vdf")
return False
# Find the opening brace for Steam section
steam_brace = config_text.find('{', steam_section)
if steam_brace == -1:
logger.error("Steam section opening brace not found")
return False
# Insert CompatToolMapping section right after Steam opening brace
insert_pos = steam_brace + 1
compat_section = '\n\t\t"CompatToolMapping"\n\t\t{\n\t\t}\n'
config_text = config_text[:insert_pos] + compat_section + config_text[insert_pos:]
# Update compat_start position after insertion
compat_start = config_text.find('"CompatToolMapping"')
logger.info("Created CompatToolMapping section in config.vdf")
# Find the closing brace for CompatToolMapping
# Look for the opening brace after CompatToolMapping
@@ -327,17 +470,27 @@ class NativeSteamService:
logger.error(f"Error setting Proton version: {e}")
return False
def create_shortcut_with_proton(self, app_name: str, exe_path: str, start_dir: str = None,
def create_shortcut_with_proton(self, app_name: str, exe_path: str, start_dir: str = None,
launch_options: str = "%command%", tags: List[str] = None,
proton_version: str = "proton_experimental") -> Tuple[bool, Optional[int]]:
proton_version: str = None) -> Tuple[bool, Optional[int]]:
"""
Complete workflow: Create shortcut and set Proton version.
This is the main method that replaces STL entirely.
Returns:
(success, app_id) - Success status and the AppID
"""
# Auto-detect best Proton version if none provided
if proton_version is None:
try:
from jackify.backend.core.modlist_operations import _get_user_proton_version
proton_version = _get_user_proton_version()
logger.info(f"Auto-detected Proton version: {proton_version}")
except Exception as e:
logger.warning(f"Failed to auto-detect Proton, falling back to experimental: {e}")
proton_version = "proton_experimental"
logger.info(f"Creating shortcut with Proton: '{app_name}' -> '{proton_version}'")
# Step 1: Create the shortcut

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
Platform Detection Service
Centralizes platform detection logic (Steam Deck, etc.) to be performed once at application startup
and shared across all components.
"""
import os
import logging
logger = logging.getLogger(__name__)
class PlatformDetectionService:
"""
Service for detecting platform-specific information once at startup
"""
_instance = None
_is_steamdeck = None
def __new__(cls):
"""Singleton pattern to ensure only one instance"""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize platform detection if not already done"""
if self._is_steamdeck is None:
self._detect_platform()
def _detect_platform(self):
"""Perform platform detection once"""
logger.debug("Performing platform detection...")
# Steam Deck detection
self._is_steamdeck = False
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release', 'r') as f:
content = f.read().lower()
if 'steamdeck' in content:
self._is_steamdeck = True
logger.info("Steam Deck platform detected")
else:
logger.debug("Non-Steam Deck Linux platform detected")
else:
logger.debug("No /etc/os-release found - assuming non-Steam Deck platform")
except Exception as e:
logger.warning(f"Error detecting Steam Deck platform: {e}")
self._is_steamdeck = False
logger.debug(f"Platform detection complete: is_steamdeck={self._is_steamdeck}")
@property
def is_steamdeck(self) -> bool:
"""Get Steam Deck detection result"""
if self._is_steamdeck is None:
self._detect_platform()
return self._is_steamdeck
@classmethod
def get_instance(cls):
"""Get the singleton instance"""
return cls()

View File

@@ -39,7 +39,7 @@ class ProtontricksDetectionService:
def _get_protontricks_handler(self) -> ProtontricksHandler:
"""Get or create ProtontricksHandler instance"""
if self._protontricks_handler is None:
self._protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck)
self._protontricks_handler = ProtontricksHandler(self.steamdeck)
return self._protontricks_handler
def detect_protontricks(self, use_cache: bool = True) -> Tuple[bool, str, str]:

View File

@@ -103,15 +103,33 @@ class UpdateService:
# Determine if this is a delta update
is_delta = '.delta' in download_url or 'delta' in download_url.lower()
return UpdateInfo(
version=latest_version,
tag_name=release_data['tag_name'],
release_date=release_data['published_at'],
changelog=release_data.get('body', ''),
download_url=download_url,
file_size=file_size,
is_delta_update=is_delta
)
# Safety checks to prevent segfault
try:
# Sanitize string fields
safe_version = str(latest_version) if latest_version else ""
safe_tag = str(release_data.get('tag_name', ''))
safe_date = str(release_data.get('published_at', ''))
safe_changelog = str(release_data.get('body', ''))[:1000] # Limit size
safe_url = str(download_url)
logger.debug(f"Creating UpdateInfo for version {safe_version}")
update_info = UpdateInfo(
version=safe_version,
tag_name=safe_tag,
release_date=safe_date,
changelog=safe_changelog,
download_url=safe_url,
file_size=file_size,
is_delta_update=is_delta
)
logger.debug(f"UpdateInfo created successfully")
return update_info
except Exception as e:
logger.error(f"Failed to create UpdateInfo: {e}")
return None
else:
logger.warning(f"No AppImage found in release {latest_version}")
@@ -173,9 +191,14 @@ class UpdateService:
def check_worker():
try:
update_info = self.check_for_updates()
logger.debug(f"check_worker: Received update_info: {update_info}")
logger.debug(f"check_worker: About to call callback...")
callback(update_info)
logger.debug(f"check_worker: Callback completed")
except Exception as e:
logger.error(f"Error in background update check: {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
callback(None)
thread = threading.Thread(target=check_worker, daemon=True)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@
"targets": {
".NETCoreApp,Version=v8.0": {},
".NETCoreApp,Version=v8.0/linux-x64": {
"jackify-engine/0.3.14": {
"jackify-engine/0.3.17": {
"dependencies": {
"Markdig": "0.40.0",
"Microsoft.Extensions.Configuration.Json": "9.0.1",
@@ -22,16 +22,16 @@
"SixLabors.ImageSharp": "3.1.6",
"System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.CLI.Builder": "0.3.14",
"Wabbajack.Downloaders.Bethesda": "0.3.14",
"Wabbajack.Downloaders.Dispatcher": "0.3.14",
"Wabbajack.Hashing.xxHash64": "0.3.14",
"Wabbajack.Networking.Discord": "0.3.14",
"Wabbajack.Networking.GitHub": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14",
"Wabbajack.Server.Lib": "0.3.14",
"Wabbajack.Services.OSIntegrated": "0.3.14",
"Wabbajack.VFS": "0.3.14",
"Wabbajack.CLI.Builder": "0.3.17",
"Wabbajack.Downloaders.Bethesda": "0.3.17",
"Wabbajack.Downloaders.Dispatcher": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Networking.Discord": "0.3.17",
"Wabbajack.Networking.GitHub": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17",
"Wabbajack.Server.Lib": "0.3.17",
"Wabbajack.Services.OSIntegrated": "0.3.17",
"Wabbajack.VFS": "0.3.17",
"MegaApiClient": "1.0.0.0",
"runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.19"
},
@@ -1781,7 +1781,7 @@
}
}
},
"Wabbajack.CLI.Builder/0.3.14": {
"Wabbajack.CLI.Builder/0.3.17": {
"dependencies": {
"Microsoft.Extensions.Configuration.Json": "9.0.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -1791,109 +1791,109 @@
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.Paths": "0.3.14"
"Wabbajack.Paths": "0.3.17"
},
"runtime": {
"Wabbajack.CLI.Builder.dll": {}
}
},
"Wabbajack.Common/0.3.14": {
"Wabbajack.Common/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.Reactive": "6.0.1",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Networking.Http": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14"
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17"
},
"runtime": {
"Wabbajack.Common.dll": {}
}
},
"Wabbajack.Compiler/0.3.14": {
"Wabbajack.Compiler/0.3.17": {
"dependencies": {
"F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Dispatcher": "0.3.14",
"Wabbajack.Installer": "0.3.14",
"Wabbajack.VFS": "0.3.14",
"Wabbajack.Downloaders.Dispatcher": "0.3.17",
"Wabbajack.Installer": "0.3.17",
"Wabbajack.VFS": "0.3.17",
"ini-parser-netstandard": "2.5.2"
},
"runtime": {
"Wabbajack.Compiler.dll": {}
}
},
"Wabbajack.Compression.BSA/0.3.14": {
"Wabbajack.Compression.BSA/0.3.17": {
"dependencies": {
"K4os.Compression.LZ4.Streams": "1.3.8",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.3.14",
"Wabbajack.DTOs": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.17"
},
"runtime": {
"Wabbajack.Compression.BSA.dll": {}
}
},
"Wabbajack.Compression.Zip/0.3.14": {
"Wabbajack.Compression.Zip/0.3.17": {
"dependencies": {
"Wabbajack.IO.Async": "0.3.14"
"Wabbajack.IO.Async": "0.3.17"
},
"runtime": {
"Wabbajack.Compression.Zip.dll": {}
}
},
"Wabbajack.Configuration/0.3.14": {
"Wabbajack.Configuration/0.3.17": {
"runtime": {
"Wabbajack.Configuration.dll": {}
}
},
"Wabbajack.Downloaders.Bethesda/0.3.14": {
"Wabbajack.Downloaders.Bethesda/0.3.17": {
"dependencies": {
"LibAES-CTR": "1.1.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Networking.BethesdaNet": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.BethesdaNet": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.Bethesda.dll": {}
}
},
"Wabbajack.Downloaders.Dispatcher/0.3.14": {
"Wabbajack.Downloaders.Dispatcher/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Bethesda": "0.3.14",
"Wabbajack.Downloaders.GameFile": "0.3.14",
"Wabbajack.Downloaders.GoogleDrive": "0.3.14",
"Wabbajack.Downloaders.Http": "0.3.14",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Downloaders.Manual": "0.3.14",
"Wabbajack.Downloaders.MediaFire": "0.3.14",
"Wabbajack.Downloaders.Mega": "0.3.14",
"Wabbajack.Downloaders.ModDB": "0.3.14",
"Wabbajack.Downloaders.Nexus": "0.3.14",
"Wabbajack.Downloaders.VerificationCache": "0.3.14",
"Wabbajack.Downloaders.WabbajackCDN": "0.3.14",
"Wabbajack.Networking.WabbajackClientApi": "0.3.14"
"Wabbajack.Downloaders.Bethesda": "0.3.17",
"Wabbajack.Downloaders.GameFile": "0.3.17",
"Wabbajack.Downloaders.GoogleDrive": "0.3.17",
"Wabbajack.Downloaders.Http": "0.3.17",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Downloaders.Manual": "0.3.17",
"Wabbajack.Downloaders.MediaFire": "0.3.17",
"Wabbajack.Downloaders.Mega": "0.3.17",
"Wabbajack.Downloaders.ModDB": "0.3.17",
"Wabbajack.Downloaders.Nexus": "0.3.17",
"Wabbajack.Downloaders.VerificationCache": "0.3.17",
"Wabbajack.Downloaders.WabbajackCDN": "0.3.17",
"Wabbajack.Networking.WabbajackClientApi": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.Dispatcher.dll": {}
}
},
"Wabbajack.Downloaders.GameFile/0.3.14": {
"Wabbajack.Downloaders.GameFile/0.3.17": {
"dependencies": {
"GameFinder.StoreHandlers.EADesktop": "4.5.0",
"GameFinder.StoreHandlers.EGS": "4.5.0",
@@ -1903,360 +1903,360 @@
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.VFS": "0.3.14"
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.VFS": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.GameFile.dll": {}
}
},
"Wabbajack.Downloaders.GoogleDrive/0.3.14": {
"Wabbajack.Downloaders.GoogleDrive/0.3.17": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.AspNetCore.Http.Extensions": "2.3.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.14",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Networking.Http": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.GoogleDrive.dll": {}
}
},
"Wabbajack.Downloaders.Http/0.3.14": {
"Wabbajack.Downloaders.Http/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.14",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Networking.BethesdaNet": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.BethesdaNet": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.Http.dll": {}
}
},
"Wabbajack.Downloaders.Interfaces/0.3.14": {
"Wabbajack.Downloaders.Interfaces/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Compression.Zip": "0.3.14",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14"
"Wabbajack.Compression.Zip": "0.3.17",
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.Interfaces.dll": {}
}
},
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.14": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.17": {
"dependencies": {
"F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Networking.Http": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {}
}
},
"Wabbajack.Downloaders.Manual/0.3.14": {
"Wabbajack.Downloaders.Manual/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.Manual.dll": {}
}
},
"Wabbajack.Downloaders.MediaFire/0.3.14": {
"Wabbajack.Downloaders.MediaFire/0.3.17": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.MediaFire.dll": {}
}
},
"Wabbajack.Downloaders.Mega/0.3.14": {
"Wabbajack.Downloaders.Mega/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.Mega.dll": {}
}
},
"Wabbajack.Downloaders.ModDB/0.3.14": {
"Wabbajack.Downloaders.ModDB/0.3.17": {
"dependencies": {
"HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Networking.Http": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.ModDB.dll": {}
}
},
"Wabbajack.Downloaders.Nexus/0.3.14": {
"Wabbajack.Downloaders.Nexus/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Hashing.xxHash64": "0.3.14",
"Wabbajack.Networking.Http": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14",
"Wabbajack.Networking.NexusApi": "0.3.14",
"Wabbajack.Paths": "0.3.14"
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Networking.NexusApi": "0.3.17",
"Wabbajack.Paths": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.Nexus.dll": {}
}
},
"Wabbajack.Downloaders.VerificationCache/0.3.14": {
"Wabbajack.Downloaders.VerificationCache/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Stub.System.Data.SQLite.Core.NetStandard": "1.0.119",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14"
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.VerificationCache.dll": {}
}
},
"Wabbajack.Downloaders.WabbajackCDN/0.3.14": {
"Wabbajack.Downloaders.WabbajackCDN/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Toolkit.HighPerformance": "7.1.2",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Networking.Http": "0.3.14",
"Wabbajack.RateLimiter": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.RateLimiter": "0.3.17"
},
"runtime": {
"Wabbajack.Downloaders.WabbajackCDN.dll": {}
}
},
"Wabbajack.DTOs/0.3.14": {
"Wabbajack.DTOs/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Hashing.xxHash64": "0.3.14",
"Wabbajack.Paths": "0.3.14"
"Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Paths": "0.3.17"
},
"runtime": {
"Wabbajack.DTOs.dll": {}
}
},
"Wabbajack.FileExtractor/0.3.14": {
"Wabbajack.FileExtractor/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"OMODFramework": "3.0.1",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Compression.BSA": "0.3.14",
"Wabbajack.Hashing.PHash": "0.3.14",
"Wabbajack.Paths": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Compression.BSA": "0.3.17",
"Wabbajack.Hashing.PHash": "0.3.17",
"Wabbajack.Paths": "0.3.17"
},
"runtime": {
"Wabbajack.FileExtractor.dll": {}
}
},
"Wabbajack.Hashing.PHash/0.3.14": {
"Wabbajack.Hashing.PHash/0.3.17": {
"dependencies": {
"BCnEncoder.Net.ImageSharp": "1.1.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Shipwreck.Phash": "0.5.0",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.3.14",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Paths": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Paths": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17"
},
"runtime": {
"Wabbajack.Hashing.PHash.dll": {}
}
},
"Wabbajack.Hashing.xxHash64/0.3.14": {
"Wabbajack.Hashing.xxHash64/0.3.17": {
"dependencies": {
"Wabbajack.Paths": "0.3.14",
"Wabbajack.RateLimiter": "0.3.14"
"Wabbajack.Paths": "0.3.17",
"Wabbajack.RateLimiter": "0.3.17"
},
"runtime": {
"Wabbajack.Hashing.xxHash64.dll": {}
}
},
"Wabbajack.Installer/0.3.14": {
"Wabbajack.Installer/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"Octopus.Octodiff": "2.0.548",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Downloaders.Dispatcher": "0.3.14",
"Wabbajack.Downloaders.GameFile": "0.3.14",
"Wabbajack.FileExtractor": "0.3.14",
"Wabbajack.Networking.WabbajackClientApi": "0.3.14",
"Wabbajack.Paths": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14",
"Wabbajack.VFS": "0.3.14",
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Downloaders.Dispatcher": "0.3.17",
"Wabbajack.Downloaders.GameFile": "0.3.17",
"Wabbajack.FileExtractor": "0.3.17",
"Wabbajack.Networking.WabbajackClientApi": "0.3.17",
"Wabbajack.Paths": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17",
"Wabbajack.VFS": "0.3.17",
"ini-parser-netstandard": "2.5.2"
},
"runtime": {
"Wabbajack.Installer.dll": {}
}
},
"Wabbajack.IO.Async/0.3.14": {
"Wabbajack.IO.Async/0.3.17": {
"runtime": {
"Wabbajack.IO.Async.dll": {}
}
},
"Wabbajack.Networking.BethesdaNet/0.3.14": {
"Wabbajack.Networking.BethesdaNet/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Networking.Http": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14"
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.Networking.BethesdaNet.dll": {}
}
},
"Wabbajack.Networking.Discord/0.3.14": {
"Wabbajack.Networking.Discord/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Networking.Http.Interfaces": "0.3.14"
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.Networking.Discord.dll": {}
}
},
"Wabbajack.Networking.GitHub/0.3.14": {
"Wabbajack.Networking.GitHub/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14"
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.Networking.GitHub.dll": {}
}
},
"Wabbajack.Networking.Http/0.3.14": {
"Wabbajack.Networking.Http/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Http": "9.0.1",
"Microsoft.Extensions.Logging": "9.0.1",
"Wabbajack.Configuration": "0.3.14",
"Wabbajack.Downloaders.Interfaces": "0.3.14",
"Wabbajack.Hashing.xxHash64": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14",
"Wabbajack.Paths": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14"
"Wabbajack.Configuration": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Paths": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17"
},
"runtime": {
"Wabbajack.Networking.Http.dll": {}
}
},
"Wabbajack.Networking.Http.Interfaces/0.3.14": {
"Wabbajack.Networking.Http.Interfaces/0.3.17": {
"dependencies": {
"Wabbajack.Hashing.xxHash64": "0.3.14"
"Wabbajack.Hashing.xxHash64": "0.3.17"
},
"runtime": {
"Wabbajack.Networking.Http.Interfaces.dll": {}
}
},
"Wabbajack.Networking.NexusApi/0.3.14": {
"Wabbajack.Networking.NexusApi/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Networking.Http": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14",
"Wabbajack.Networking.WabbajackClientApi": "0.3.14"
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Networking.WabbajackClientApi": "0.3.17"
},
"runtime": {
"Wabbajack.Networking.NexusApi.dll": {}
}
},
"Wabbajack.Networking.WabbajackClientApi/0.3.14": {
"Wabbajack.Networking.WabbajackClientApi/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0",
"Wabbajack.Common": "0.3.14",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14",
"Wabbajack.VFS.Interfaces": "0.3.14",
"Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17",
"Wabbajack.VFS.Interfaces": "0.3.17",
"YamlDotNet": "16.3.0"
},
"runtime": {
"Wabbajack.Networking.WabbajackClientApi.dll": {}
}
},
"Wabbajack.Paths/0.3.14": {
"Wabbajack.Paths/0.3.17": {
"runtime": {
"Wabbajack.Paths.dll": {}
}
},
"Wabbajack.Paths.IO/0.3.14": {
"Wabbajack.Paths.IO/0.3.17": {
"dependencies": {
"Wabbajack.Paths": "0.3.14",
"Wabbajack.Paths": "0.3.17",
"shortid": "4.0.0"
},
"runtime": {
"Wabbajack.Paths.IO.dll": {}
}
},
"Wabbajack.RateLimiter/0.3.14": {
"Wabbajack.RateLimiter/0.3.17": {
"runtime": {
"Wabbajack.RateLimiter.dll": {}
}
},
"Wabbajack.Server.Lib/0.3.14": {
"Wabbajack.Server.Lib/0.3.17": {
"dependencies": {
"FluentFTP": "52.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -2264,58 +2264,58 @@
"Nettle": "3.0.0",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.3.14",
"Wabbajack.Networking.Http.Interfaces": "0.3.14",
"Wabbajack.Services.OSIntegrated": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Services.OSIntegrated": "0.3.17"
},
"runtime": {
"Wabbajack.Server.Lib.dll": {}
}
},
"Wabbajack.Services.OSIntegrated/0.3.14": {
"Wabbajack.Services.OSIntegrated/0.3.17": {
"dependencies": {
"DeviceId": "6.8.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Compiler": "0.3.14",
"Wabbajack.Downloaders.Dispatcher": "0.3.14",
"Wabbajack.Installer": "0.3.14",
"Wabbajack.Networking.BethesdaNet": "0.3.14",
"Wabbajack.Networking.Discord": "0.3.14",
"Wabbajack.VFS": "0.3.14"
"Wabbajack.Compiler": "0.3.17",
"Wabbajack.Downloaders.Dispatcher": "0.3.17",
"Wabbajack.Installer": "0.3.17",
"Wabbajack.Networking.BethesdaNet": "0.3.17",
"Wabbajack.Networking.Discord": "0.3.17",
"Wabbajack.VFS": "0.3.17"
},
"runtime": {
"Wabbajack.Services.OSIntegrated.dll": {}
}
},
"Wabbajack.VFS/0.3.14": {
"Wabbajack.VFS/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6",
"System.Data.SQLite.Core": "1.0.119",
"Wabbajack.Common": "0.3.14",
"Wabbajack.FileExtractor": "0.3.14",
"Wabbajack.Hashing.PHash": "0.3.14",
"Wabbajack.Hashing.xxHash64": "0.3.14",
"Wabbajack.Paths": "0.3.14",
"Wabbajack.Paths.IO": "0.3.14",
"Wabbajack.VFS.Interfaces": "0.3.14"
"Wabbajack.Common": "0.3.17",
"Wabbajack.FileExtractor": "0.3.17",
"Wabbajack.Hashing.PHash": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Paths": "0.3.17",
"Wabbajack.Paths.IO": "0.3.17",
"Wabbajack.VFS.Interfaces": "0.3.17"
},
"runtime": {
"Wabbajack.VFS.dll": {}
}
},
"Wabbajack.VFS.Interfaces/0.3.14": {
"Wabbajack.VFS.Interfaces/0.3.17": {
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.3.14",
"Wabbajack.Hashing.xxHash64": "0.3.14",
"Wabbajack.Paths": "0.3.14"
"Wabbajack.DTOs": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Paths": "0.3.17"
},
"runtime": {
"Wabbajack.VFS.Interfaces.dll": {}
@@ -2332,7 +2332,7 @@
}
},
"libraries": {
"jackify-engine/0.3.14": {
"jackify-engine/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
@@ -3021,202 +3021,202 @@
"path": "yamldotnet/16.3.0",
"hashPath": "yamldotnet.16.3.0.nupkg.sha512"
},
"Wabbajack.CLI.Builder/0.3.14": {
"Wabbajack.CLI.Builder/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Common/0.3.14": {
"Wabbajack.Common/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compiler/0.3.14": {
"Wabbajack.Compiler/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compression.BSA/0.3.14": {
"Wabbajack.Compression.BSA/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Compression.Zip/0.3.14": {
"Wabbajack.Compression.Zip/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Configuration/0.3.14": {
"Wabbajack.Configuration/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Bethesda/0.3.14": {
"Wabbajack.Downloaders.Bethesda/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Dispatcher/0.3.14": {
"Wabbajack.Downloaders.Dispatcher/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.GameFile/0.3.14": {
"Wabbajack.Downloaders.GameFile/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.GoogleDrive/0.3.14": {
"Wabbajack.Downloaders.GoogleDrive/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Http/0.3.14": {
"Wabbajack.Downloaders.Http/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Interfaces/0.3.14": {
"Wabbajack.Downloaders.Interfaces/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.14": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Manual/0.3.14": {
"Wabbajack.Downloaders.Manual/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.MediaFire/0.3.14": {
"Wabbajack.Downloaders.MediaFire/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Mega/0.3.14": {
"Wabbajack.Downloaders.Mega/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.ModDB/0.3.14": {
"Wabbajack.Downloaders.ModDB/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.Nexus/0.3.14": {
"Wabbajack.Downloaders.Nexus/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.VerificationCache/0.3.14": {
"Wabbajack.Downloaders.VerificationCache/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Downloaders.WabbajackCDN/0.3.14": {
"Wabbajack.Downloaders.WabbajackCDN/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.DTOs/0.3.14": {
"Wabbajack.DTOs/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.FileExtractor/0.3.14": {
"Wabbajack.FileExtractor/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Hashing.PHash/0.3.14": {
"Wabbajack.Hashing.PHash/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Hashing.xxHash64/0.3.14": {
"Wabbajack.Hashing.xxHash64/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Installer/0.3.14": {
"Wabbajack.Installer/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.IO.Async/0.3.14": {
"Wabbajack.IO.Async/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.BethesdaNet/0.3.14": {
"Wabbajack.Networking.BethesdaNet/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Discord/0.3.14": {
"Wabbajack.Networking.Discord/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.GitHub/0.3.14": {
"Wabbajack.Networking.GitHub/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Http/0.3.14": {
"Wabbajack.Networking.Http/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.Http.Interfaces/0.3.14": {
"Wabbajack.Networking.Http.Interfaces/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.NexusApi/0.3.14": {
"Wabbajack.Networking.NexusApi/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Networking.WabbajackClientApi/0.3.14": {
"Wabbajack.Networking.WabbajackClientApi/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Paths/0.3.14": {
"Wabbajack.Paths/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Paths.IO/0.3.14": {
"Wabbajack.Paths.IO/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.RateLimiter/0.3.14": {
"Wabbajack.RateLimiter/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Server.Lib/0.3.14": {
"Wabbajack.Server.Lib/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.Services.OSIntegrated/0.3.14": {
"Wabbajack.Services.OSIntegrated/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.VFS/0.3.14": {
"Wabbajack.VFS/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""
},
"Wabbajack.VFS.Interfaces/0.3.14": {
"Wabbajack.VFS.Interfaces/0.3.17": {
"type": "project",
"serviceable": false,
"sha512": ""

Binary file not shown.

View File

@@ -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

View File

@@ -21,11 +21,9 @@ from jackify import __version__ as jackify_version
# Import our command handlers
from .commands.configure_modlist import ConfigureModlistCommand
from .commands.install_modlist import InstallModlistCommand
from .commands.tuxborn import TuxbornCommand
# Import our menu handlers
from .menus.main_menu import MainMenuHandler
from .menus.tuxborn_menu import TuxbornMenuHandler
from .menus.wabbajack_menu import WabbajackMenuHandler
from .menus.hoolamike_menu import HoolamikeMenuHandler
from .menus.additional_menu import AdditionalMenuHandler
@@ -280,7 +278,6 @@ class JackifyCLI:
commands = {
'configure_modlist': ConfigureModlistCommand(self.backend_services),
'install_modlist': InstallModlistCommand(self.backend_services, self.system_info),
'tuxborn': TuxbornCommand(self.backend_services, self.system_info)
}
return commands
@@ -292,7 +289,6 @@ class JackifyCLI:
"""
menus = {
'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)),
'tuxborn': TuxbornMenuHandler(),
'wabbajack': WabbajackMenuHandler(),
'hoolamike': HoolamikeMenuHandler(),
'additional': AdditionalMenuHandler()
@@ -371,10 +367,6 @@ class JackifyCLI:
self._debug_print('Entering restart_steam workflow')
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
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')
# Add command-specific arguments
self.commands['tuxborn'].add_args(parser)
self.commands['install_modlist'].add_top_level_args(parser)
# Add subcommands
@@ -459,13 +450,9 @@ class JackifyCLI:
return 0
elif choice == "wabbajack":
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
# elif choice == "hoolamike":
# self.menus['hoolamike'].show_hoolamike_menu(self)
# elif choice == "additional":
# self.menus['additional'].show_additional_tasks_menu(self)
elif choice == "additional":
self.menus['additional'].show_additional_tasks_menu(self)
else:
logger.warning(f"Invalid choice '{choice}' received from show_main_menu.")

View File

@@ -4,7 +4,6 @@ Extracted from the legacy monolithic CLI system
"""
from .main_menu import MainMenuHandler
from .tuxborn_menu import TuxbornMenuHandler
from .wabbajack_menu import WabbajackMenuHandler
from .hoolamike_menu import HoolamikeMenuHandler
from .additional_menu import AdditionalMenuHandler
@@ -12,7 +11,6 @@ from .recovery_menu import RecoveryMenuHandler
__all__ = [
'MainMenuHandler',
'TuxbornMenuHandler',
'WabbajackMenuHandler',
'HoolamikeMenuHandler',
'AdditionalMenuHandler',

View File

@@ -6,7 +6,7 @@ Extracted from src.modules.menu_handler.MenuHandler.show_additional_tasks_menu()
import time
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT, COLOR_INFO, COLOR_DISABLED, COLOR_WARNING
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header, clear_screen
@@ -24,29 +24,26 @@ class AdditionalMenuHandler:
clear_screen()
def show_additional_tasks_menu(self, cli_instance):
"""Show the MO2, NXM Handling & Recovery submenu"""
"""Show the Additional Tasks & Tools submenu"""
while True:
self._clear_screen()
print_jackify_banner()
print_section_header("Additional Utilities") # Broader title
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install Mod Organizer 2 (Base Setup)")
print(f" {COLOR_ACTION}→ Proton setup for a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure NXM Handling {COLOR_DISABLED}(Not Implemented){COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Jackify Recovery Tools")
print(f" {COLOR_ACTION}→ Restore files modified or backed up by Jackify{COLOR_RESET}")
print_section_header("Additional Tasks & Tools")
print(f"{COLOR_INFO}Additional Tasks & Tools, such as TTW Installation{COLOR_RESET}\n")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation")
print(f" {COLOR_ACTION}→ Install TTW using Hoolamike native automation{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Coming Soon...")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_legacy_install_mo2(cli_instance)
self._execute_hoolamike_ttw_install(cli_instance)
elif selection == "2":
print(f"{COLOR_INFO}Configure NXM Handling is not yet implemented.{COLOR_RESET}")
input("\nPress Enter to return to the Utilities menu...")
elif selection == "3":
self._execute_legacy_recovery_menu(cli_instance)
print(f"\n{COLOR_INFO}More features coming soon!{COLOR_RESET}")
input("\nPress Enter to return to menu...")
elif selection == "0":
break
else:
@@ -69,4 +66,59 @@ class AdditionalMenuHandler:
recovery_handler = RecoveryMenuHandler()
recovery_handler.logger = self.logger
recovery_handler.show_recovery_menu(cli_instance)
recovery_handler.show_recovery_menu(cli_instance)
def _execute_hoolamike_ttw_install(self, cli_instance):
"""Execute TTW installation using Hoolamike handler"""
from ....backend.handlers.hoolamike_handler import HoolamikeHandler
from ....backend.models.configuration import SystemInfo
from ....shared.colors import COLOR_ERROR
system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck)
hoolamike_handler = HoolamikeHandler(
steamdeck=system_info.is_steamdeck,
verbose=cli_instance.verbose,
filesystem_handler=cli_instance.filesystem_handler,
config_handler=cli_instance.config_handler,
menu_handler=cli_instance.menu_handler
)
# First check if Hoolamike is installed
if not hoolamike_handler.hoolamike_installed:
print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}")
if not hoolamike_handler.install_update_hoolamike():
print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with TTW installation.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
# Run TTW installation workflow
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
result = hoolamike_handler.install_ttw()
if result is None:
print(f"\n{COLOR_WARNING}TTW installation returned without result.{COLOR_RESET}")
input("Press Enter to return to menu...")
def _execute_hoolamike_modlist_install(self, cli_instance):
"""Execute modlist installation using Hoolamike handler"""
from ....backend.handlers.hoolamike_handler import HoolamikeHandler
from ....backend.models.configuration import SystemInfo
system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck)
hoolamike_handler = HoolamikeHandler(
steamdeck=system_info.is_steamdeck,
verbose=cli_instance.verbose,
filesystem_handler=cli_instance.filesystem_handler,
config_handler=cli_instance.config_handler,
menu_handler=cli_instance.menu_handler
)
# First check if Hoolamike is installed
if not hoolamike_handler.hoolamike_installed:
print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}")
if not hoolamike_handler.install_update_hoolamike():
print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with modlist installation.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
# Run modlist installation
hoolamike_handler.install_modlist()

View File

@@ -42,36 +42,17 @@ class MainMenuHandler:
print(f"{COLOR_SELECTION}{'-'*22}{COLOR_RESET}") # Standard separator
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks")
print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Coming Soon...")
print(f" {COLOR_ACTION}More features coming in future releases{COLOR_RESET}")
if self.dev_mode:
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Hoolamike Tasks")
print(f" {COLOR_ACTION}→ Wabbajack alternative: Install Modlists, TTW, etc{COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Additional Tasks")
print(f" {COLOR_ACTION}→ Install Wabbajack (via WINE), MO2, NXM Handling, Jackify Recovery{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Additional Tasks & Tools")
print(f" {COLOR_ACTION}TTW automation, Wabbajack via Wine, MO2, NXM Handling, Recovery{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify")
if self.dev_mode:
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-4): {COLOR_RESET}").strip()
else:
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
if choice.lower() == 'q': # Allow 'q' to re-display menu
continue
if choice == "1":
return "wabbajack"
elif choice == "2":
# Additional features are coming in future releases
print(f"\n{COLOR_PROMPT}Coming Soon!{COLOR_RESET}")
print(f"More features will be added in future releases.")
print(f"Please use 'Modlist Tasks' for all current functionality.")
print(f"Press Enter to continue...")
input()
continue # Return to main menu
if self.dev_mode:
if choice == "3":
return "hoolamike"
elif choice == "4":
return "additional"
return "additional"
elif choice == "0":
return "exit"
else:

View File

@@ -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)

View File

@@ -256,18 +256,24 @@ class UpdateDialog(QDialog):
self.downloaded_path = downloaded_path
self.progress_label.setText("Download completed successfully!")
self.progress_bar.setValue(100)
# Show install button
self.download_button.setVisible(False)
self.install_button.setVisible(True)
# Re-enable other buttons
self.later_button.setEnabled(True)
self.skip_button.setEnabled(True)
# Check if auto-restart is enabled
if self.auto_restart_checkbox.isChecked():
# Auto-install immediately
self.progress_label.setText("Auto-installing update...")
self.install_update()
else:
# Show install button for manual installation
self.download_button.setVisible(False)
self.install_button.setVisible(True)
# Re-enable other buttons
self.later_button.setEnabled(True)
self.skip_button.setEnabled(True)
else:
self.show_error("Download Failed", "Failed to download the update. Please try again later.")
# Reset UI
self.progress_group.setVisible(False)
self.download_button.setEnabled(True)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GUI Mixins Package
Reusable mixins for GUI functionality
"""
from .operation_lock_mixin import OperationLockMixin
__all__ = ['OperationLockMixin']

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Operation Lock Mixin
Provides reliable button state management for GUI operations
"""
from contextlib import contextmanager
class OperationLockMixin:
"""
Mixin that provides reliable button state management.
Ensures controls are always re-enabled after operations, even if exceptions occur.
"""
def operation_lock(self):
"""
Context manager that ensures controls are always re-enabled after operations.
Usage:
with self.operation_lock():
# Perform operation that might fail
risky_operation()
# Controls are guaranteed to be re-enabled here
"""
@contextmanager
def lock_manager():
try:
if hasattr(self, '_disable_controls_during_operation'):
self._disable_controls_during_operation()
yield
finally:
# Ensure controls are re-enabled even if exceptions occur
if hasattr(self, '_enable_controls_after_operation'):
self._enable_controls_after_operation()
return lock_manager()
def safe_operation(self, operation_func, *args, **kwargs):
"""
Execute an operation with automatic button state management.
Args:
operation_func: Function to execute
*args, **kwargs: Arguments to pass to operation_func
Returns:
Result of operation_func or None if exception occurred
"""
try:
with self.operation_lock():
return operation_func(*args, **kwargs)
except Exception as e:
# Log the error but don't re-raise - controls are already re-enabled
if hasattr(self, 'logger'):
self.logger.error(f"Operation failed: {e}", exc_info=True)
# Could also show user error dialog here if needed
return None
def reset_screen_to_defaults(self):
"""
Reset the screen to default state when navigating back from main menu.
Override this method in subclasses to implement screen-specific reset logic.
"""
pass # Default implementation does nothing - subclasses should override

View File

@@ -5,16 +5,16 @@ Contains all the GUI screen components for Jackify.
"""
from .main_menu import MainMenu
from .tuxborn_installer import TuxbornInstallerScreen
from .modlist_tasks import ModlistTasksScreen
from .additional_tasks import AdditionalTasksScreen
from .install_modlist import InstallModlistScreen
from .configure_new_modlist import ConfigureNewModlistScreen
from .configure_existing_modlist import ConfigureExistingModlistScreen
__all__ = [
'MainMenu',
'TuxbornInstallerScreen',
'ModlistTasksScreen',
'AdditionalTasksScreen',
'InstallModlistScreen',
'ConfigureNewModlistScreen',
'ConfigureExistingModlistScreen'

View File

@@ -0,0 +1,169 @@
"""
Additional Tasks & Tools Screen
Simple screen for TTW automation only.
Follows the same pattern as ModlistTasksScreen.
"""
import logging
from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QGridLayout
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from jackify.backend.models.configuration import SystemInfo
from ..shared_theme import JACKIFY_COLOR_BLUE
logger = logging.getLogger(__name__)
class AdditionalTasksScreen(QWidget):
"""Simple Additional Tasks screen for TTW only"""
def __init__(self, stacked_widget=None, main_menu_index=0, system_info: Optional[SystemInfo] = None):
super().__init__()
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.system_info = system_info or SystemInfo(is_steamdeck=False)
self._setup_ui()
def _setup_ui(self):
"""Set up the user interface following ModlistTasksScreen pattern"""
layout = QVBoxLayout()
layout.setContentsMargins(40, 40, 40, 40)
layout.setSpacing(0)
# Header section
self._setup_header(layout)
# Menu buttons section
self._setup_menu_buttons(layout)
# Bottom spacer
layout.addStretch()
self.setLayout(layout)
def _setup_header(self, layout):
"""Set up the header section"""
header_layout = QVBoxLayout()
header_layout.setSpacing(0)
# Title
title = QLabel("<b>Additional Tasks & Tools</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
# Add a spacer to match main menu vertical spacing
header_layout.addSpacing(16)
# Description
desc = QLabel(
"TTW automation and additional tools.<br>&nbsp;"
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
desc.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(desc)
header_layout.addSpacing(24)
# Separator (shorter like main menu)
sep = QLabel()
sep.setFixedHeight(2)
sep.setFixedWidth(400) # Match button width
sep.setStyleSheet("background: #fff;")
header_layout.addWidget(sep, alignment=Qt.AlignHCenter)
header_layout.addSpacing(16)
layout.addLayout(header_layout)
def _setup_menu_buttons(self, layout):
"""Set up the menu buttons section"""
# Menu options - ONLY TTW and placeholder
MENU_ITEMS = [
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using Hoolamike automation"),
("Coming Soon...", "coming_soon", "Additional tools will be added in future updates"),
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
]
# Create grid layout for buttons (mirror ModlistTasksScreen pattern)
button_grid = QGridLayout()
button_grid.setSpacing(16)
button_grid.setAlignment(Qt.AlignHCenter)
button_width = 400
button_height = 50
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
# Create button
btn = QPushButton(label)
btn.setFixedSize(button_width, button_height)
btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
text-align: center;
}}
QPushButton:hover {{
background-color: #5a6578;
}}
QPushButton:pressed {{
background-color: {JACKIFY_COLOR_BLUE};
}}
""")
btn.clicked.connect(lambda checked, a=action_id: self._handle_button_click(a))
# Description label
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 12px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)
# Add to grid (button row, then description row)
button_grid.addWidget(btn, i * 2, 0, Qt.AlignHCenter)
button_grid.addWidget(desc_label, i * 2 + 1, 0, Qt.AlignHCenter)
layout.addLayout(button_grid)
# Removed _create_menu_button; using same pattern as ModlistTasksScreen
def _handle_button_click(self, action_id):
"""Handle button clicks"""
if action_id == "ttw_install":
self._show_ttw_info()
elif action_id == "coming_soon":
self._show_coming_soon_info()
elif action_id == "return_main_menu":
self._return_to_main_menu()
def _show_ttw_info(self):
"""Navigate to TTW installation screen"""
if self.stacked_widget:
# Navigate to TTW installation screen (index 5)
self.stacked_widget.setCurrentIndex(5)
def _show_coming_soon_info(self):
"""Show coming soon info"""
from ..services.message_service import MessageService
MessageService.information(
self,
"Coming Soon",
"Additional tools and features will be added in future updates.\n\n"
"Check back later for more functionality!"
)
def _return_to_main_menu(self):
"""Return to main menu"""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)

View File

@@ -37,7 +37,9 @@ class ConfigureExistingModlistScreen(QWidget):
self.refresh_paths()
# --- Detect Steam Deck ---
steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower()
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
steamdeck = platform_service.is_steamdeck
self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
# Initialize services early
@@ -119,7 +121,7 @@ class ConfigureExistingModlistScreen(QWidget):
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map = []
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_map.append(shortcut)
@@ -410,6 +412,9 @@ class ConfigureExistingModlistScreen(QWidget):
pass
def validate_and_start_configure(self):
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
from pathlib import Path
@@ -427,8 +432,8 @@ class ConfigureExistingModlistScreen(QWidget):
self._enable_controls_after_operation()
return
shortcut = self.shortcut_map[idx]
modlist_name = shortcut.get('AppName', '')
install_dir = shortcut.get('StartDir', '')
modlist_name = shortcut.get('AppName', shortcut.get('appname', ''))
install_dir = shortcut.get('StartDir', shortcut.get('startdir', ''))
if not modlist_name or not install_dir:
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
self._enable_controls_after_operation()
@@ -451,10 +456,14 @@ class ConfigureExistingModlistScreen(QWidget):
def start_workflow(self, modlist_name, install_dir, resolution):
"""Start the configuration workflow using backend service directly"""
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# This ensures Proton version and winetricks settings are current
self.config_handler._load_config()
try:
# Start time tracking
self._workflow_start_time = time.time()
self._safe_append_text("[Jackify] Starting post-install configuration...")
# Create configuration thread using backend service
@@ -499,6 +508,7 @@ class ConfigureExistingModlistScreen(QWidget):
# For existing modlists, add resolution if specified
if self.resolution != "Leave unchanged":
modlist_context.resolution = self.resolution.split()[0]
# Note: If "Leave unchanged" is selected, resolution stays None (no fallback needed)
# Define callbacks
def progress_callback(message):
@@ -710,7 +720,7 @@ class ConfigureExistingModlistScreen(QWidget):
self.shortcut_map.clear()
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_map.append(shortcut)
@@ -738,6 +748,30 @@ class ConfigureExistingModlistScreen(QWidget):
else:
return f"{elapsed_seconds_remainder} seconds"
def reset_screen_to_defaults(self):
"""Reset the screen to default state when navigating back from main menu"""
# Clear the shortcut selection
self.shortcut_combo.clear()
self.shortcut_map.clear()
# Auto-refresh modlist list when screen is entered
self.refresh_modlist_list()
# Clear console and process monitor
self.console.clear()
self.process_monitor.clear()
# Reset resolution combo to saved config preference
saved_resolution = self.resolution_service.get_saved_resolution()
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
elif self.resolution_combo.count() > 0:
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")

View File

@@ -22,6 +22,7 @@ from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog
from PySide6.QtWidgets import QApplication
from jackify.frontends.gui.services.message_service import MessageService
from jackify.shared.resolution_utils import get_resolution_fallback
def debug_print(message):
"""Print debug message only if debug mode is enabled"""
@@ -480,7 +481,7 @@ class ConfigureNewModlistScreen(QWidget):
def go_back(self):
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):
try:
@@ -553,6 +554,9 @@ class ConfigureNewModlistScreen(QWidget):
return True # Continue anyway
def validate_and_start_configure(self):
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Check protontricks before proceeding
if not self._check_protontricks():
return
@@ -590,7 +594,9 @@ class ConfigureNewModlistScreen(QWidget):
return
# --- Shortcut creation will be handled by automated workflow ---
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower()
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
steamdeck = platform_service.is_steamdeck
shortcut_handler = ShortcutHandler(steamdeck=steamdeck) # Still needed for Steam restart
# Check if auto-restart is enabled
@@ -662,6 +668,10 @@ class ConfigureNewModlistScreen(QWidget):
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.", safety_level="medium")
def configure_modlist(self):
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# This ensures Proton version and winetricks settings are current
self.config_handler._load_config()
install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()
modlist_name = self.modlist_name_edit.text().strip()
mo2_exe_path = self.install_dir_edit.text().strip()
@@ -669,12 +679,12 @@ class ConfigureNewModlistScreen(QWidget):
if not install_dir or not modlist_name:
MessageService.warning(self, "Missing Info", "Install directory or modlist name is missing.", safety_level="low")
return
# Use automated prefix service instead of manual steps
self._safe_append_text("")
self._safe_append_text("=== Steam Integration Phase ===")
self._safe_append_text("Starting automated Steam setup workflow...")
# Start automated prefix workflow
self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path, resolution)
@@ -722,16 +732,10 @@ class ConfigureNewModlistScreen(QWidget):
except Exception as e:
self.error_occurred.emit(str(e))
# Detect Steam Deck once
try:
import os
_is_steamdeck = False
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
_is_steamdeck = True
except Exception:
_is_steamdeck = False
# Detect Steam Deck once using centralized service
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
_is_steamdeck = platform_service.is_steamdeck
# Create and start the thread
self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path, _is_steamdeck)
@@ -927,7 +931,10 @@ class ConfigureNewModlistScreen(QWidget):
# Steam assigns a NEW AppID during restart, different from the one we initially created
self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
shortcut_handler = ShortcutHandler(steamdeck=False)
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck)
current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path)
if not current_appid or not current_appid.isdigit():
@@ -951,7 +958,12 @@ class ConfigureNewModlistScreen(QWidget):
# Initialize ModlistHandler with correct parameters
path_handler = PathHandler()
modlist_handler = ModlistHandler(steamdeck=False, verbose=False)
# Use centralized Steam Deck detection
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
# Set required properties manually after initialization
modlist_handler.modlist_dir = install_dir
@@ -1033,7 +1045,7 @@ class ConfigureNewModlistScreen(QWidget):
try:
# Get resolution from UI
resolution = self.resolution_combo.currentText()
resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else '2560x1600'
resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else None
# Update the context with the new AppID (same format as manual steps)
mo2_exe_path = self.install_dir_edit.text().strip()
@@ -1082,7 +1094,7 @@ class ConfigureNewModlistScreen(QWidget):
nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value'),
modlist_source=self.context.get('modlist_source', 'identifier'),
resolution=self.context.get('resolution', '2560x1600'),
resolution=self.context.get('resolution') or get_resolution_fallback(None),
skip_confirmation=True
)
@@ -1183,7 +1195,7 @@ class ConfigureNewModlistScreen(QWidget):
nexus_api_key='', # Not needed for configuration
modlist_value='', # Not needed for existing modlist
modlist_source='existing',
resolution=self.context.get('resolution'),
resolution=self.context.get('resolution') or get_resolution_fallback(None),
skip_confirmation=True
)
@@ -1325,6 +1337,27 @@ class ConfigureNewModlistScreen(QWidget):
btn_exit.clicked.connect(on_exit)
dlg.exec()
def reset_screen_to_defaults(self):
"""Reset the screen to default state when navigating back from main menu"""
# Reset form fields
self.install_dir_edit.setText("/path/to/Modlist/ModOrganizer.exe")
# Clear console and process monitor
self.console.clear()
self.process_monitor.clear()
# Reset resolution combo to saved config preference
saved_resolution = self.resolution_service.get_saved_resolution()
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
elif self.resolution_combo.count() > 0:
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()
def cleanup(self):
"""Clean up any running threads when the screen is closed"""
debug_print("DEBUG: cleanup called - cleaning up threads")

View File

@@ -367,6 +367,10 @@ class InstallModlistScreen(QWidget):
self.resolution_service = ResolutionService()
self.config_handler = ConfigHandler()
self.protontricks_service = ProtontricksDetectionService()
# Somnium guidance tracking
self._show_somnium_guidance = False
self._somnium_install_dir = None
# Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False
@@ -392,7 +396,7 @@ class InstallModlistScreen(QWidget):
header_layout.addWidget(title)
# Description
desc = QLabel(
"This screen allows you to install a Wabbajack modlist using Jackify's native Linux tools. "
"This screen allows you to install a Wabbajack modlist using Jackify. "
"Configure your options and start the installation."
)
desc.setWordWrap(True)
@@ -1053,7 +1057,7 @@ class InstallModlistScreen(QWidget):
def go_back(self):
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):
try:
@@ -1068,7 +1072,8 @@ class InstallModlistScreen(QWidget):
line_lower = line.lower()
if (
("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower)
"wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or
"hoolamike" in line_lower)
and "jackify-gui.py" not in line_lower
):
cols = line.strip().split(None, 3)
@@ -1087,29 +1092,198 @@ class InstallModlistScreen(QWidget):
"""Check if protontricks is available before critical operations"""
try:
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
if not is_installed:
# Show protontricks error dialog
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
result = dialog.exec()
if result == QDialog.Rejected:
return False
# Re-check after dialog
is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False)
return is_installed
return True
except Exception as e:
print(f"Error checking protontricks: {e}")
MessageService.warning(self, "Protontricks Check Failed",
MessageService.warning(self, "Protontricks Check Failed",
f"Unable to verify protontricks installation: {e}\n\n"
"Continuing anyway, but some features may not work correctly.")
return True # Continue anyway
def _check_ttw_eligibility(self, modlist_name: str, game_type: str, install_dir: str) -> bool:
"""Check if modlist is FNV, TTW-compatible, and doesn't already have TTW
Args:
modlist_name: Name of the installed modlist
game_type: Game type (e.g., 'falloutnv')
install_dir: Modlist installation directory
Returns:
bool: True if should offer TTW integration
"""
try:
# Check 1: Must be Fallout New Vegas
if game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
return False
# Check 2: Must be on whitelist
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
if not is_ttw_compatible(modlist_name):
return False
# Check 3: TTW must not already be installed
if self._detect_existing_ttw(install_dir):
debug_print("DEBUG: TTW already installed, skipping prompt")
return False
return True
except Exception as e:
debug_print(f"DEBUG: Error checking TTW eligibility: {e}")
return False
def _detect_existing_ttw(self, install_dir: str) -> bool:
"""Check if TTW is already installed in the modlist
Args:
install_dir: Modlist installation directory
Returns:
bool: True if TTW is already present
"""
try:
from pathlib import Path
mods_dir = Path(install_dir) / "mods"
if not mods_dir.exists():
return False
# Check for folders containing "Tale of Two Wastelands" that have actual TTW content
# Exclude separators and placeholder folders
for folder in mods_dir.iterdir():
if not folder.is_dir():
continue
folder_name_lower = folder.name.lower()
# Skip separator folders and placeholders
if "_separator" in folder_name_lower or "put" in folder_name_lower or "here" in folder_name_lower:
continue
# Check if folder name contains TTW indicator
if "tale of two wastelands" in folder_name_lower:
# Verify it has actual TTW content by checking for the main ESM
ttw_esm = folder / "TaleOfTwoWastelands.esm"
if ttw_esm.exists():
debug_print(f"DEBUG: Found existing TTW installation: {folder.name}")
return True
else:
debug_print(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
return False
except Exception as e:
debug_print(f"DEBUG: Error detecting existing TTW: {e}")
return False # Assume not installed on error
def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str):
"""Navigate to TTW screen and set it up for modlist integration
Args:
modlist_name: Name of the modlist that needs TTW integration
install_dir: Path to the modlist installation directory
"""
try:
# Store modlist context for later use when TTW completes
self._ttw_modlist_name = modlist_name
self._ttw_install_dir = install_dir
# Get reference to TTW screen BEFORE navigation
if self.stacked_widget:
ttw_screen = self.stacked_widget.widget(5)
# Set integration mode BEFORE navigating to avoid showEvent race condition
if hasattr(ttw_screen, 'set_modlist_integration_mode'):
ttw_screen.set_modlist_integration_mode(modlist_name, install_dir)
# Connect to completion signal to show success dialog after TTW
if hasattr(ttw_screen, 'integration_complete'):
ttw_screen.integration_complete.connect(self._on_ttw_integration_complete)
else:
debug_print("WARNING: TTW screen does not support modlist integration mode yet")
# Navigate to TTW screen AFTER setting integration mode
self.stacked_widget.setCurrentIndex(5)
# Force collapsed state shortly after navigation to avoid any
# showEvent/layout timing races that may leave it expanded
try:
from PySide6.QtCore import QTimer
QTimer.singleShot(50, lambda: getattr(ttw_screen, 'force_collapsed_state', lambda: None)())
except Exception:
pass
except Exception as e:
debug_print(f"ERROR: Failed to initiate TTW workflow: {e}")
MessageService.critical(
self,
"TTW Navigation Failed",
f"Failed to navigate to TTW installation screen: {str(e)}"
)
def _on_ttw_integration_complete(self, success: bool, ttw_version: str = ""):
"""Handle completion of TTW integration and show final success dialog
Args:
success: Whether TTW integration completed successfully
ttw_version: Version of TTW that was installed
"""
try:
if not success:
MessageService.critical(
self,
"TTW Integration Failed",
"Tale of Two Wastelands integration did not complete successfully."
)
return
# Navigate back to this screen to show success dialog
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(4)
# Build success message including TTW installation
modlist_name = getattr(self, '_ttw_modlist_name', 'Unknown')
time_str = getattr(self, '_elapsed_time_str', '0m 0s')
game_name = "Fallout New Vegas"
# Show enhanced success dialog
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
time_taken=time_str,
game_name=game_name,
parent=self
)
# Add TTW installation info to dialog if possible
if hasattr(success_dialog, 'add_info_line'):
success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully")
success_dialog.show()
except Exception as e:
debug_print(f"ERROR: Failed to show final success dialog: {e}")
MessageService.critical(
self,
"Display Error",
f"TTW integration completed but failed to show success dialog: {str(e)}"
)
def _on_api_key_save_toggled(self, checked):
"""Handle immediate API key saving with silent validation when checkbox is toggled"""
try:
@@ -1184,11 +1358,14 @@ class InstallModlistScreen(QWidget):
import time
self._install_workflow_start_time = time.time()
debug_print('DEBUG: validate_and_start_install called')
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Check protontricks before proceeding
if not self._check_protontricks():
return
# Disable all controls during installation (except Cancel)
self._disable_controls_during_operation()
@@ -1356,7 +1533,8 @@ class InstallModlistScreen(QWidget):
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion_remastered': 'oblivion_remastered',
'enderal': 'enderal'
'enderal': 'enderal',
'enderal special edition': 'enderal'
}
game_type = game_mapping.get(game_name.lower())
debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
@@ -1373,6 +1551,7 @@ class InstallModlistScreen(QWidget):
# Check if game is supported
debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported")
debug_print(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
debug_print(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
@@ -1740,7 +1919,14 @@ class InstallModlistScreen(QWidget):
# Save resolution for later use in configuration
resolution = self.resolution_combo.currentText()
self._current_resolution = resolution.split()[0] if resolution != "Leave unchanged" else "2560x1600"
# Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)")
if resolution != "Leave unchanged":
if " (" in resolution:
self._current_resolution = resolution.split(" (")[0]
else:
self._current_resolution = resolution
else:
self._current_resolution = None
# Use automated prefix creation instead of manual steps
debug_print("DEBUG: Starting automated prefix creation workflow")
@@ -1752,6 +1938,22 @@ class InstallModlistScreen(QWidget):
def start_automated_prefix_workflow(self):
"""Start the automated prefix creation workflow"""
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# This ensures Proton version and winetricks settings are current
self.config_handler._load_config()
# Ensure _current_resolution is always set before starting workflow
if not hasattr(self, '_current_resolution') or self._current_resolution is None:
resolution = self.resolution_combo.currentText() if hasattr(self, 'resolution_combo') else None
# Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)")
if resolution and resolution != "Leave unchanged":
if " (" in resolution:
self._current_resolution = resolution.split(" (")[0]
else:
self._current_resolution = resolution
else:
self._current_resolution = None
try:
# Disable controls during installation
self._disable_controls_during_operation()
@@ -1760,10 +1962,26 @@ class InstallModlistScreen(QWidget):
final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
if not os.path.exists(final_exe_path):
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
MessageService.critical(self, "ModOrganizer.exe Not Found",
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
return
# Check if this is Somnium specifically (uses files/ subdirectory)
modlist_name_lower = modlist_name.lower()
if "somnium" in modlist_name_lower:
somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
if os.path.exists(somnium_exe_path):
final_exe_path = somnium_exe_path
self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup")
# Show Somnium guidance popup after automated workflow completes
self._show_somnium_guidance = True
self._somnium_install_dir = install_dir
else:
self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}")
MessageService.critical(self, "Somnium ModOrganizer.exe Not Found",
f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.")
return
else:
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
MessageService.critical(self, "ModOrganizer.exe Not Found",
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
return
# Run automated prefix creation in separate thread
from PySide6.QtCore import QThread, Signal
@@ -1940,6 +2158,10 @@ class InstallModlistScreen(QWidget):
self._enable_controls_after_operation()
if success:
# Check if we need to show Somnium guidance
if self._show_somnium_guidance:
self._show_somnium_post_install_guidance()
# Show celebration SuccessDialog after the entire workflow
from ..dialogs import SuccessDialog
import time
@@ -1958,6 +2180,31 @@ class InstallModlistScreen(QWidget):
'enderal': 'Enderal'
}
game_name = display_names.get(self._current_game_type, self._current_game_name)
# Check for TTW eligibility before showing final success dialog
install_dir = self.install_dir_edit.text().strip()
if self._check_ttw_eligibility(modlist_name, self._current_game_type, install_dir):
# Offer TTW installation
reply = MessageService.question(
self,
"Install TTW?",
f"{modlist_name} requires Tale of Two Wastelands!\n\n"
"Would you like to install and configure TTW automatically now?\n\n"
"This will:\n"
"• Guide you through TTW installation\n"
"• Automatically integrate TTW into your modlist\n"
"• Configure load order correctly\n\n"
"Note: TTW installation can take a while. You can also install TTW later from Additional Tasks & Tools.",
critical=False,
safety_level="medium"
)
if reply == QMessageBox.Yes:
# Navigate to TTW screen
self._initiate_ttw_workflow(modlist_name, install_dir)
return # Don't show success dialog yet, will show after TTW completes
# Show normal success dialog
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
@@ -1997,7 +2244,10 @@ class InstallModlistScreen(QWidget):
"""Handle configuration error on main thread"""
self._safe_append_text(f"Configuration failed with error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}")
# Re-enable all controls on error
self._enable_controls_after_operation()
# Clean up thread
if hasattr(self, 'config_thread') and self.config_thread is not None:
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
@@ -2041,11 +2291,20 @@ class InstallModlistScreen(QWidget):
self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False)
def _get_mo2_path(self, install_dir, modlist_name):
"""Get ModOrganizer.exe path, handling Somnium's non-standard structure"""
mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower():
somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
if os.path.exists(somnium_path):
mo2_exe_path = somnium_path
return mo2_exe_path
def validate_manual_steps_completion(self):
"""Validate that manual steps were actually completed and handle retry logic"""
modlist_name = self.modlist_name_edit.text().strip()
install_dir = self.install_dir_edit.text().strip()
mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
mo2_exe_path = self._get_mo2_path(install_dir, modlist_name)
# Add delay to allow Steam filesystem updates to complete
self._safe_append_text("Waiting for Steam filesystem updates to complete...")
@@ -2056,7 +2315,10 @@ class InstallModlistScreen(QWidget):
# Steam assigns a NEW AppID during restart, different from the one we initially created
self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
shortcut_handler = ShortcutHandler(steamdeck=False)
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck)
current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path)
if not current_appid or not current_appid.isdigit():
@@ -2077,7 +2339,12 @@ class InstallModlistScreen(QWidget):
# Initialize ModlistHandler with correct parameters
path_handler = PathHandler()
modlist_handler = ModlistHandler(steamdeck=False, verbose=False)
# Use centralized Steam Deck detection
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
# Set required properties manually after initialization
modlist_handler.modlist_dir = install_dir
@@ -2283,10 +2550,10 @@ class InstallModlistScreen(QWidget):
updated_context = {
'name': modlist_name,
'path': install_dir,
'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"),
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
'modlist_value': None,
'modlist_source': None,
'resolution': getattr(self, '_current_resolution', '2560x1600'),
'resolution': getattr(self, '_current_resolution', None),
'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed since automated prefix is done
'appid': new_appid, # Use the NEW AppID from automated prefix creation
@@ -2295,15 +2562,21 @@ class InstallModlistScreen(QWidget):
self.context = updated_context # Ensure context is always set
debug_print(f"Updated context with new AppID: {new_appid}")
# Get Steam Deck detection once and pass to ConfigThread
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
is_steamdeck = platform_service.is_steamdeck
# Create new config thread with updated context
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
error_occurred = Signal(str)
def __init__(self, context):
def __init__(self, context, is_steamdeck):
super().__init__()
self.context = context
self.is_steamdeck = is_steamdeck
def run(self):
try:
@@ -2312,8 +2585,8 @@ class InstallModlistScreen(QWidget):
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
# Initialize backend service
system_info = SystemInfo(is_steamdeck=False)
# Initialize backend service with passed Steam Deck detection
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
modlist_service = ModlistService(system_info)
# Convert context to ModlistContext for service
@@ -2325,7 +2598,7 @@ class InstallModlistScreen(QWidget):
nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value'),
modlist_source=self.context.get('modlist_source', 'identifier'),
resolution=self.context.get('resolution', '2560x1600'),
resolution=self.context.get('resolution'),
skip_confirmation=True,
engine_installed=True # Skip path manipulation for engine workflows
)
@@ -2344,7 +2617,7 @@ class InstallModlistScreen(QWidget):
# This shouldn't happen since automated prefix creation is complete
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
# Call the service method for post-Steam configuration
# Call the service method for post-Steam configuration
result = modlist_service.configure_modlist_post_steam(
context=modlist_context,
progress_callback=progress_callback,
@@ -2360,7 +2633,7 @@ class InstallModlistScreen(QWidget):
self.error_occurred.emit(str(e))
# Start configuration thread
self.config_thread = ConfigThread(updated_context)
self.config_thread = ConfigThread(updated_context, is_steamdeck)
self.config_thread.progress_update.connect(self.on_configuration_progress)
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
self.config_thread.error_occurred.connect(self.on_configuration_error)
@@ -2381,10 +2654,10 @@ class InstallModlistScreen(QWidget):
updated_context = {
'name': modlist_name,
'path': install_dir,
'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"),
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
'modlist_value': None,
'modlist_source': None,
'resolution': getattr(self, '_current_resolution', '2560x1600'),
'resolution': getattr(self, '_current_resolution', None),
'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed
'appid': new_appid # Use the NEW AppID from Steam
@@ -2421,15 +2694,21 @@ class InstallModlistScreen(QWidget):
def _create_config_thread(self, context):
"""Create a new ConfigThread with proper lifecycle management"""
from PySide6.QtCore import QThread, Signal
# Get Steam Deck detection once
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
is_steamdeck = platform_service.is_steamdeck
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
error_occurred = Signal(str)
def __init__(self, context, parent=None):
def __init__(self, context, is_steamdeck, parent=None):
super().__init__(parent)
self.context = context
self.is_steamdeck = is_steamdeck
def run(self):
try:
@@ -2438,8 +2717,8 @@ class InstallModlistScreen(QWidget):
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
# Initialize backend service
system_info = SystemInfo(is_steamdeck=False)
# Initialize backend service with passed Steam Deck detection
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
modlist_service = ModlistService(system_info)
# Convert context to ModlistContext for service
@@ -2488,7 +2767,7 @@ class InstallModlistScreen(QWidget):
self.progress_update.emit(f"DEBUG: {error_details}")
self.error_occurred.emit(str(e))
return ConfigThread(context, parent=self)
return ConfigThread(context, is_steamdeck, parent=self)
def handle_validation_failure(self, missing_text):
"""Handle failed validation with retry logic"""
@@ -2616,12 +2895,59 @@ class InstallModlistScreen(QWidget):
self._safe_append_text("Installation cancelled by user.")
def _show_somnium_post_install_guidance(self):
"""Show guidance popup for Somnium post-installation steps"""
from ..widgets.message_service import MessageService
guidance_text = f"""<b>Somnium Post-Installation Required</b><br><br>
Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:<br><br>
<b>1.</b> Launch the Steam shortcut created for Somnium<br>
<b>2.</b> In ModOrganizer, go to Settings → Executables<br>
<b>3.</b> For each executable entry (SKSE64, etc.), update the binary path to point to:<br>
<code>{self._somnium_install_dir}/files/root/Enderal Special Edition/skse64_loader.exe</code><br><br>
<b>Note:</b> Full Somnium support will be added in a future Jackify update.<br><br>
<i>You can also refer to the Somnium installation guide at:<br>
https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
MessageService.information(self, "Somnium Setup Required", guidance_text)
# Reset the guidance flag
self._show_somnium_guidance = False
self._somnium_install_dir = None
def cancel_and_cleanup(self):
"""Handle Cancel button - clean up processes and go back"""
self.cleanup_processes()
self.go_back()
def closeEvent(self, event):
"""Handle window close event - clean up processes"""
self.cleanup_processes()
event.accept()
def reset_screen_to_defaults(self):
"""Reset the screen to default state when navigating back from main menu"""
# Reset form fields
self.modlist_btn.setText("Select Modlist")
self.modlist_btn.setEnabled(False)
self.file_edit.setText("")
self.modlist_name_edit.setText("")
self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir())
# Reset game type button
self.game_type_btn.setText("Please Select...")
# Clear console and process monitor
self.console.clear()
self.process_monitor.clear()
# Reset tabs to first tab (Online)
self.source_tabs.setCurrentIndex(0)
# Reset resolution combo to saved config preference
saved_resolution = self.resolution_service.get_saved_resolution()
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
elif self.resolution_combo.count() > 0:
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
# Re-enable controls (in case they were disabled from previous errors)
self._enable_controls_after_operation()

File diff suppressed because it is too large Load Diff

View File

@@ -47,12 +47,9 @@ class MainMenu(QWidget):
button_height = 60
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Coming Soon...", "coming_soon", "More features coming soon!"),
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
("Exit Jackify", "exit_jackify", "Close the application"),
]
if self.dev_mode:
MENU_ITEMS.append(("Hoolamike Tasks", "hoolamike_tasks", "Manage Hoolamike modding tools"))
MENU_ITEMS.append(("Additional Tasks", "additional_tasks", "Additional utilities and tools"))
MENU_ITEMS.append(("Exit Jackify", "exit_jackify", "Close the application"))
for label, action_id, description in MENU_ITEMS:
# Main button
@@ -120,9 +117,11 @@ class MainMenu(QWidget):
msg.setIcon(QMessageBox.Information)
msg.exec()
elif action_id == "modlist_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(2)
elif action_id == "additional_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(3)
elif action_id == "return_main_menu":
# This is the main menu, so do nothing
pass
elif self.stacked_widget:
self.stacked_widget.setCurrentIndex(2) # Placeholder for now
self.stacked_widget.setCurrentIndex(1) # Default to placeholder

View File

@@ -198,11 +198,11 @@ class ModlistTasksScreen(QWidget):
if action_id == "return_main_menu":
self.stacked_widget.setCurrentIndex(0)
elif action_id == "install_modlist":
self.stacked_widget.setCurrentIndex(4)
self.stacked_widget.setCurrentIndex(4) # Install Modlist Screen
elif action_id == "configure_new_modlist":
self.stacked_widget.setCurrentIndex(5)
self.stacked_widget.setCurrentIndex(6) # Configure New Modlist Screen
elif action_id == "configure_existing_modlist":
self.stacked_widget.setCurrentIndex(6)
self.stacked_widget.setCurrentIndex(7) # Configure Existing Modlist Screen
def go_back(self):
"""Return to main menu"""

File diff suppressed because it is too large Load Diff

View File

@@ -220,6 +220,8 @@ class MessageService:
msg_box = MessageService._create_base_message_box(parent, critical, safety_level)
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(title)
msg_box.setTextFormat(Qt.RichText)
msg_box.setTextInteractionFlags(Qt.TextBrowserInteraction)
msg_box.setText(message)
msg_box.setStandardButtons(buttons)
msg_box.setDefaultButton(default_button)

View File

@@ -9,6 +9,21 @@ ANSI_COLOR_MAP = {
}
ANSI_RE = re.compile(r'\x1b\[(\d+)(;\d+)?m')
# Pattern to match terminal control codes (cursor movement, line clearing, etc.)
ANSI_CONTROL_RE = re.compile(
r'\x1b\[' # CSI sequence start
r'[0-9;]*' # Parameters
r'[A-Za-z]' # Command letter
)
def strip_ansi_control_codes(text):
"""Remove ALL ANSI escape sequences including control codes.
This is useful for Hoolamike output which uses terminal control codes
for progress bars that don't render well in QTextEdit.
"""
return ANSI_CONTROL_RE.sub('', text)
def ansi_to_html(text):
"""Convert ANSI color codes to HTML"""
result = ''

View File

@@ -94,6 +94,7 @@ class UnsupportedGameDialog(QDialog):
<li><strong>Oblivion</strong></li>
<li><strong>Starfield</strong></li>
<li><strong>Oblivion Remastered</strong></li>
<li><strong>Enderal</strong></li>
</ul>
<p>For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.</p>
@@ -113,6 +114,7 @@ class UnsupportedGameDialog(QDialog):
<li><strong>Oblivion</strong></li>
<li><strong>Starfield</strong></li>
<li><strong>Oblivion Remastered</strong></li>
<li><strong>Enderal</strong></li>
</ul>
<p>For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.</p>

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Resolution Utilities Module
Provides utility functions for handling resolution across GUI and CLI frontends
"""
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
def get_default_resolution() -> str:
"""
Get the appropriate default resolution based on system detection and user preferences.
Returns:
str: Resolution string (e.g., '1920x1080', '1280x800')
"""
try:
# First try to get saved resolution from config
from ..backend.services.resolution_service import ResolutionService
resolution_service = ResolutionService()
saved_resolution = resolution_service.get_saved_resolution()
if saved_resolution and saved_resolution != 'Leave unchanged':
logger.debug(f"Using saved resolution: {saved_resolution}")
return saved_resolution
except Exception as e:
logger.warning(f"Could not load ResolutionService: {e}")
try:
# Check for Steam Deck
if _is_steam_deck():
logger.debug("Steam Deck detected, using 1280x800")
return "1280x800"
except Exception as e:
logger.warning(f"Error detecting Steam Deck: {e}")
# Fallback to common 1080p instead of arbitrary resolution
logger.debug("Using fallback resolution: 1920x1080")
return "1920x1080"
def _is_steam_deck() -> bool:
"""
Detect if running on Steam Deck
Returns:
bool: True if Steam Deck detected
"""
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
content = f.read().lower()
return "steamdeck" in content or "steamos" in content
except Exception as e:
logger.debug(f"Error reading /etc/os-release: {e}")
return False
def get_resolution_fallback(current_resolution: Optional[str]) -> str:
"""
Get appropriate resolution fallback when current resolution is invalid or None
Args:
current_resolution: Current resolution value that might be None/invalid
Returns:
str: Valid resolution string
"""
if current_resolution and current_resolution != 'Leave unchanged':
# Validate format
if _validate_resolution_format(current_resolution):
return current_resolution
# Use proper default resolution logic
return get_default_resolution()
def _validate_resolution_format(resolution: str) -> bool:
"""
Validate resolution format
Args:
resolution: Resolution string to validate
Returns:
bool: True if valid WxH format
"""
import re
if not resolution:
return False
# Handle Steam Deck format
clean_resolution = resolution.replace(' (Steam Deck)', '')
# Check WxH format
if re.match(r'^[0-9]+x[0-9]+$', clean_resolution):
try:
width, height = clean_resolution.split('x')
width_int, height_int = int(width), int(height)
return 0 < width_int <= 10000 and 0 < height_int <= 10000
except ValueError:
return False
return False

View File

@@ -222,15 +222,21 @@ class ValidationHandler:
def validate_steam_shortcut(self, app_id: str) -> Tuple[bool, str]:
"""Validate a Steam shortcut."""
try:
# Check if shortcuts.vdf exists
shortcuts_path = Path.home() / '.steam' / 'steam' / 'userdata' / '75424832' / 'config' / 'shortcuts.vdf'
# Use native Steam service to get proper shortcuts.vdf path with multi-user support
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
shortcuts_path = steam_service.get_shortcuts_vdf_path()
if not shortcuts_path:
return False, "Could not determine shortcuts.vdf path (no active Steam user found)"
if not shortcuts_path.exists():
return False, "shortcuts.vdf not found"
# Check if shortcuts.vdf is accessible
if not os.access(shortcuts_path, os.R_OK | os.W_OK):
return False, "shortcuts.vdf is not accessible"
# Parse shortcuts.vdf using VDFHandler
shortcuts_data = VDFHandler.load(str(shortcuts_path), binary=True)

BIN
jackify/tools/cabextract Executable file

Binary file not shown.

19627
jackify/tools/winetricks Executable file

File diff suppressed because it is too large Load Diff