24 Commits

Author SHA1 Message Date
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
Omni
64c76046ce Sync from development - prepare for v0.1.2 2025-09-18 08:44:19 +01:00
Omni
4eb1d63de7 Sync from development - prepare for v0.1.2 2025-09-18 08:21:33 +01:00
Omni
8131e23057 Merge branch 'main' of https://github.com/Omni-guides/Jackify 2025-09-18 08:20:54 +01:00
Omni
1cd4caf04b Sync from development - prepare for v0.1.2 2025-09-18 08:18:59 +01:00
Omni-guides
e005f56bdb Update README.md 2025-09-17 08:22:50 +01:00
Omni-guides
1f84fc7c68 Update README.md 2025-09-17 08:14:56 +01:00
Omni
70b18004e1 Sync from development - prepare for v0.1.1 2025-09-15 20:18:13 +01:00
Omni
0b6e32beac Sync from development - prepare for v0.1.2 2025-09-14 21:54:18 +01:00
Omni
c20a27dd90 Merge branch 'main' of https://github.com/Omni-guides/Jackify 2025-09-14 20:46:07 +01:00
Omni
cac4411137 Sync binaries from development - complete legacy scripts collection 2025-09-14 20:45:52 +01:00
Omni
2e4cdc2854 Sync from development - prepare for v0.1.0.1 2025-09-14 20:18:33 +01:00
98 changed files with 26193 additions and 4575 deletions

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,277 @@
# Jackify Changelog # Jackify Changelog
## 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
### New Features
- **About Dialog**: System information display with OS, kernel, desktop environment, and display server detection
- **Engine Version Detection**: Real-time jackify-engine version reporting
- **Update Integration**: Check for Updates functionality within About dialog
- **Support Tools**: Copy system info for troubleshooting
- **Configurable Jackify Directory**: Users can now customize the Jackify data directory location via Settings
### UX Improvements
- **Control Management**: Form controls are now disabled during install/configure workflows to prevent user conflicts (only Cancel remains active)
- **Auto-Accept Steam Restart**: Optional checkbox to automatically accept Steam restart dialogs for unattended workflows
- **Layout Optimization**: Resolution dropdown and Steam restart option share the same line for better space utilization
### Bug Fixes
- **Resolution Handler**: Fixed regression in resolution setting for Fallout 4 and other games when modlists use vanilla game directories instead of traditional "Stock Game" folders
- **DXVK Configuration**: Fixed dxvk.conf creation failure when modlists point directly to vanilla game installations
- **CLI Resolution Setting**: Fixed missing resolution prompting in CLI Install workflow
### Engine Updates
- **jackify-engine v0.3.14**: Updated to support configurable Jackify data directory, improved Nexus API error handling with better 404/403 responses, and enhanced error logging for troubleshooting
---
## v0.1.1 - Self-Updater Implementation
**Release Date:** September 17, 2025
### New Features
- **Self-Updater System**: Complete automatic update mechanism for Jackify AppImages
- **GitHub Integration**: Automatic detection of new releases from GitHub
- **GUI Update Dialog**: Professional update notification with Jackify theme styling
- **CLI Update Command**: `--update` flag for manual update checks and installation
- **Startup Checks**: Automatic update detection on application launch
- **User Control**: Skip version, remind later, and download & install options
### Technical Implementation
- **UpdateService**: Core service handling version detection, download, and installation
- **Full AppImage Replacement**: Reliable update mechanism using helper scripts
- **User-Writable Directories**: All update files stored in `~/Jackify/updates/` for consistency with existing directory structure
- **Progress Indication**: Download progress bars for both GUI and CLI
- **Error Handling**: Graceful fallbacks and comprehensive error messages
### Security Enhancements
- **AppImage Validation**: Prevents accidental updating of other AppImages when running from development environments
- **Path Verification**: Validates target AppImage contains "jackify" in filename before applying updates
### User Experience
- **Seamless Updates**: Users receive notifications when updates are available
- **Professional Interface**: Update dialog matches Jackify's visual theme
- **Flexible Options**: Users can choose when and how to update
- **No External Dependencies**: Works on all systems including SteamOS and immutable OSes
### Bug Fixes
- **Path Regression Fix**: Resolved regression where Configure New/Existing Modlist workflows were creating malformed paths
- Fixed duplicate steamapps/common path generation
- Corrected Steam library root path detection
- Removed broken duplicate PathHandler causing path duplication
- **Enhanced Download Error Messages**: Added Nexus mod URLs to failed download errors for easier troubleshooting
- Automatically appends direct Nexus mod page links
- Supports all major games (Skyrim, Fallout 4, FNV, Oblivion, Starfield)
---
## v0.1.0.1 - Engine Update and Stability Improvements
**Release Date:** September 14, 2025
### Engine Updates
- **jackify-engine v0.3.13**: Major stability and resource management improvements
- **Wine Prefix Cleanup**: Automatic cleanup of ~281MB Wine prefix directories after each modlist installation
- **Manual Download Handling**: Fixed installation crashes when manual downloads are required
- **Enhanced Error Messaging**: Detailed mod information for failed downloads (Nexus ModID/FileID, Google Drive, HTTP sources)
- **Resource Settings Compliance**: Fixed resource settings not being respected during VFS and Installer operations
- **VFS Crash Prevention**: Fixed KeyNotFoundException crashes during "Priming VFS" phase with missing archives
- **Creation Club File Handling**: Fixed incorrect re-download attempts for Creation Club files
- **BSA Extraction Fix**: Fixed DirectoryNotFoundException during BSA building operations
### Improvements
- **Disk Space Management**: No more accumulation of Wine prefix directories consuming hundreds of MB per installation
- **Clean Error Handling**: Manual download requirements now show clear summary instead of stack traces
- **Better Resource Control**: Users can now properly control CPU usage during installation via resource_settings.json
### Bug Fixes
- **Download System**: Fixed GoogleDrive and MEGA download regressions
- **Configuration Integration**: MEGA tokens properly stored in Jackify's config directory structure
- **Installation Reliability**: Enhanced error handling prevents crashes with missing or corrupted archives
---
## v0.1.0 - First Public Release ## v0.1.0 - First Public Release
**Release Date:** September 11, 2025 **Release Date:** September 11, 2025

View File

@@ -817,7 +817,7 @@ configuration_phase() {
display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW" display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW"
display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW" display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
fi fi
echo -e "\n${YELLOW}⚠️ IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}" echo -e "\n${YELLOW}IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}"
echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n" echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n"
echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton." echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton."
exit 0 exit 0

View File

@@ -858,7 +858,7 @@ configuration_phase() {
display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW" display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW"
display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW" display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
fi fi
echo -e "\n${YELLOW}⚠️ IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}" echo -e "\n${YELLOW}IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}"
echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n" echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n"
echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton." echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton."
exit 0 exit 0

View File

@@ -2,7 +2,7 @@
<div align="center"> <div align="center">
[Wiki](https://github.com/Omni-guides/Jackify/wiki) | [Nexus](https://www.nexusmods.com/site/mods/1427) | [Download](https://www.nexusmods.com/Core/Libs/Common/Widgets/DownloadPopUp?id=5807&game_id=2295) | [Wabbajack Discord](https://discord.gg/wabbajack) | [Jackify Issues](https://github.com/Omni-guides/Jackify/issues) | [Legacy Guides](https://github.com/Omni-guides/Jackify/tree/master/Legacy) | [Ko-fi](https://ko-fi.com/omni1) [Wiki](https://github.com/Omni-guides/Jackify/wiki) | [Nexus](https://www.nexusmods.com/site/mods/1427) | [Download](https://www.nexusmods.com/site/mods/1427?tab=files) | [Wabbajack Discord](https://discord.gg/wabbajack) | [Jackify Issues](https://github.com/Omni-guides/Jackify/issues) | [Legacy Guides](https://github.com/Omni-guides/Jackify/tree/master/Legacy) | [Ko-fi](https://ko-fi.com/omni1)
</div> </div>
@@ -93,7 +93,7 @@ For a complete step-by-step guide with screenshots, see the [User Guide](https:/
### Quick Start ### Quick Start
1. **Download**: Get the latest release from [NexusMods](https://www.nexusmods.com/Core/Libs/Common/Widgets/DownloadPopUp?id=5807&game_id=2295) 1. **Download**: Get the latest release from [NexusMods](https://www.nexusmods.com/site/mods/1427?tab=files)
2. **Extract**: Unzip the .7z archive to get `Jackify.AppImage` 2. **Extract**: Unzip the .7z archive to get `Jackify.AppImage`
3. **Run**: `chmod +x Jackify.AppImage && ./Jackify.AppImage` 3. **Run**: `chmod +x Jackify.AppImage && ./Jackify.AppImage`
4. **Install**: Choose "Install a Modlist", select your game and modlist, configure directories and API key 4. **Install**: Choose "Install a Modlist", select your game and modlist, configure directories and API key

View File

@@ -858,7 +858,7 @@ configuration_phase() {
display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW" display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW"
display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW" display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
fi fi
echo -e "\n${YELLOW}⚠️ IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}" echo -e "\n${YELLOW}IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}"
echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n" echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n"
echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton." echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton."
exit 0 exit 0

View File

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

View File

@@ -23,6 +23,44 @@ from jackify.backend.handlers.config_handler import ConfigHandler
# UI Colors already imported above # 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 # Attempt to import readline for tab completion
READLINE_AVAILABLE = False READLINE_AVAILABLE = False
try: try:
@@ -104,8 +142,8 @@ class ModlistInstallCLI:
if isinstance(menu_handler_or_system_info, SystemInfo): if isinstance(menu_handler_or_system_info, SystemInfo):
# GUI frontend initialization pattern # GUI frontend initialization pattern
system_info = menu_handler_or_system_info self.system_info = menu_handler_or_system_info
self.steamdeck = system_info.is_steamdeck self.steamdeck = self.system_info.is_steamdeck
# Initialize menu_handler for GUI mode # Initialize menu_handler for GUI mode
from ..handlers.menu_handler import MenuHandler from ..handlers.menu_handler import MenuHandler
@@ -114,8 +152,11 @@ class ModlistInstallCLI:
# CLI frontend initialization pattern # CLI frontend initialization pattern
self.menu_handler = menu_handler_or_system_info self.menu_handler = menu_handler_or_system_info
self.steamdeck = steamdeck self.steamdeck = steamdeck
# Create system_info for CLI mode
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.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck)
self.context = {} self.context = {}
# Use standard logging (no file handler) # Use standard logging (no file handler)
@@ -689,6 +730,14 @@ class ModlistInstallCLI:
cmd += ['-m', self.context['machineid']] cmd += ['-m', self.context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str] cmd += ['-o', install_dir_str, '-d', download_dir_str]
# Add debug flag if debug mode is enabled
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
cmd.append('--debug')
self.logger.info("Adding --debug flag to jackify-engine")
# Store original environment values to restore later # Store original environment values to restore later
original_env_values = { original_env_values = {
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'), 'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
@@ -914,6 +963,20 @@ class ModlistInstallCLI:
self.logger.debug("configuration_phase: Proceeding with Steam configuration...") self.logger.debug("configuration_phase: Proceeding with Steam configuration...")
# Add resolution prompting for CLI mode (before Steam operations)
if not is_gui_mode:
from jackify.backend.handlers.resolution_handler import ResolutionHandler
resolution_handler = ResolutionHandler()
# Check if Steam Deck
is_steamdeck = self.steamdeck if hasattr(self, 'steamdeck') else False
# Prompt for resolution in CLI mode
selected_resolution = resolution_handler.select_resolution(steamdeck=is_steamdeck)
if selected_resolution:
self.context['resolution'] = selected_resolution
self.logger.info(f"Resolution set to: {selected_resolution}")
# Proceed with Steam configuration # Proceed with Steam configuration
self.logger.info(f"Starting Steam configuration for '{shortcut_name}'") self.logger.info(f"Starting Steam configuration for '{shortcut_name}'")
@@ -957,8 +1020,8 @@ class ModlistInstallCLI:
shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck shortcut_name, install_dir_str, mo2_exe_path, progress_callback, steamdeck=_is_steamdeck
) )
# Handle the result # Handle the result (same logic as GUI)
if isinstance(result, tuple) and len(result) == 3: if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT": if result[0] == "CONFLICT":
# Handle conflict # Handle conflict
conflicts = result[1] conflicts = result[1]
@@ -984,8 +1047,8 @@ class ModlistInstallCLI:
result = prefix_service.continue_workflow_after_conflict_resolution( result = prefix_service.continue_workflow_after_conflict_resolution(
shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback
) )
if isinstance(result, tuple) and len(result) == 3: if isinstance(result, tuple) and len(result) >= 3:
success, prefix_path, app_id = result success, prefix_path, app_id = result[0], result[1], result[2]
else: else:
success, prefix_path, app_id = False, None, None success, prefix_path, app_id = False, None, None
else: else:
@@ -1000,10 +1063,58 @@ class ModlistInstallCLI:
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}") print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
return return
else: else:
# Normal result # Normal result with timestamp (4-tuple)
success, prefix_path, app_id, last_timestamp = result
elif isinstance(result, tuple) and len(result) == 3:
if result[0] == "CONFLICT":
# Handle conflict (3-tuple format)
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" • Replace - Remove the existing shortcut and create a new one")
print(" • Cancel - Keep the existing shortcut and stop the installation")
print(" • Skip - Continue without creating a Steam shortcut")
choice = input(f"\n{COLOR_PROMPT}Choose an option (replace/cancel/skip): {COLOR_RESET}").strip().lower()
if choice == 'replace':
print(f"{COLOR_INFO}Replacing existing shortcut...{COLOR_RESET}")
success, app_id = prefix_service.replace_existing_shortcut(shortcut_name, mo2_exe_path, install_dir_str)
if success and app_id:
# Continue the workflow after replacement
result = prefix_service.continue_workflow_after_conflict_resolution(
shortcut_name, install_dir_str, mo2_exe_path, app_id, progress_callback
)
if isinstance(result, tuple) and len(result) >= 3:
success, prefix_path, app_id = result[0], result[1], result[2]
else:
success, prefix_path, app_id = False, None, None
else:
success, prefix_path, app_id = False, None, None
elif choice == 'cancel':
print(f"{COLOR_INFO}Cancelling installation.{COLOR_RESET}")
return
elif choice == 'skip':
print(f"{COLOR_INFO}Skipping Steam shortcut creation.{COLOR_RESET}")
success, prefix_path, app_id = True, None, None
else:
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
return
else:
# Normal result (3-tuple format)
success, prefix_path, app_id = result success, prefix_path, app_id = result
else: else:
success, prefix_path, app_id = False, None, None # Result is not a tuple, check if it's just a boolean success
if result is True:
success, prefix_path, app_id = True, None, None
else:
success, prefix_path, app_id = False, None, None
if success: if success:
print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}") print(f"{COLOR_SUCCESS}Automated Steam setup completed successfully!{COLOR_RESET}")
@@ -1011,128 +1122,54 @@ class ModlistInstallCLI:
print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}") print(f"{COLOR_INFO}Proton prefix created at: {prefix_path}{COLOR_RESET}")
if app_id: if app_id:
print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}") print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}")
return # Continue to configuration phase
else: else:
print(f"{COLOR_WARNING}Automated Steam setup failed. Falling back to manual setup...{COLOR_RESET}") print(f"{COLOR_ERROR}Automated Steam setup failed. Result: {result}{COLOR_RESET}")
print(f"{COLOR_ERROR}Steam integration was not completed. Please check the logs for details.{COLOR_RESET}")
return
# Fallback to manual shortcut creation process # Step 3: Use SAME backend service as GUI
print(f"\n{COLOR_INFO}Using manual Steam setup workflow...{COLOR_RESET}") from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
# Use the working shortcut creation process from legacy code # Create ModlistContext with engine_installed=True (same as GUI)
from ..handlers.shortcut_handler import ShortcutHandler modlist_context = ModlistContext(
shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False) name=shortcut_name,
install_dir=Path(install_dir_str),
# Create nxmhandler.ini to suppress NXM popup download_dir=Path(install_dir_str) / "downloads", # Standard location
shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path) game_type=self.context.get('detected_game', 'Unknown'),
nexus_api_key='', # Not needed for configuration
# Create shortcut with working NativeSteamService modlist_value=self.context.get('modlist_value', ''),
from ..services.native_steam_service import NativeSteamService modlist_source=self.context.get('modlist_source', 'identifier'),
steam_service = NativeSteamService() resolution=self.context.get('resolution'),
mo2_exe_path=Path(mo2_exe_path),
success, app_id = steam_service.create_shortcut_with_proton( skip_confirmation=True, # Always skip confirmation in CLI
app_name=shortcut_name, engine_installed=True # Skip path manipulation for engine workflows
exe_path=mo2_exe_path,
start_dir=os.path.dirname(mo2_exe_path),
launch_options="%command%",
tags=["Jackify"],
proton_version="proton_experimental"
) )
if not success or not app_id: # Add app_id to context
self.logger.error("Failed to create Steam shortcut") modlist_context.app_id = app_id
print(f"{COLOR_ERROR}Failed to create Steam shortcut. Check logs for details.{COLOR_RESET}")
return
# Step 2: Handle Steam restart and manual steps (if not in GUI mode) # Step 4: Configure modlist using SAME service as GUI
if not is_gui_mode: modlist_service = ModlistService(self.system_info)
print(f"\n{COLOR_INFO}Steam shortcut created successfully!{COLOR_RESET}")
print("Steam needs to restart to detect the new shortcut. WARNING: This will close all running Steam instances, and games.")
restart_choice = input("\nRestart Steam automatically now? (Y/n): ").strip().lower()
if restart_choice == 'n':
print("\nPlease restart Steam manually and complete the Proton setup steps.")
print("You can configure this modlist later using 'Configure Existing Modlist'.")
return
# Restart Steam
print("\nRestarting Steam...")
if shortcut_handler.secure_steam_restart():
print(f"{COLOR_INFO}Steam restarted successfully.{COLOR_RESET}")
# Display manual Proton steps
from ..handlers.menu_handler import ModlistMenuHandler
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
menu_handler = ModlistMenuHandler(config_handler)
menu_handler._display_manual_proton_steps(shortcut_name)
retry_count = 0
max_retries = 3
while retry_count < max_retries:
input(f"\n{COLOR_PROMPT}Once you have completed ALL the steps above, press Enter to continue...{COLOR_RESET}")
print(f"\n{COLOR_INFO}Verifying manual steps...{COLOR_RESET}")
new_app_id = shortcut_handler.get_appid_for_shortcut(shortcut_name, mo2_exe_path)
if new_app_id and new_app_id.isdigit() and int(new_app_id) > 0:
app_id = new_app_id
from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler({}, steamdeck=self.steamdeck)
verified, status_code = modlist_handler.verify_proton_setup(app_id)
if verified:
print(f"{COLOR_SUCCESS}Manual steps verification successful!{COLOR_RESET}")
break
else:
retry_count += 1
if retry_count < max_retries:
print(f"\n{COLOR_ERROR}Verification failed: {status_code}{COLOR_RESET}")
print(f"{COLOR_WARNING}Please ensure you have completed all manual steps correctly.{COLOR_RESET}")
menu_handler._display_manual_proton_steps(shortcut_name)
else:
print(f"\n{COLOR_ERROR}Manual steps verification failed after {max_retries} attempts.{COLOR_RESET}")
print(f"{COLOR_WARNING}Configuration may not work properly.{COLOR_RESET}")
return
else:
retry_count += 1
if retry_count < max_retries:
print(f"\n{COLOR_ERROR}Could not find valid AppID after launch.{COLOR_RESET}")
print(f"{COLOR_WARNING}Please ensure you have launched the shortcut from Steam.{COLOR_RESET}")
menu_handler._display_manual_proton_steps(shortcut_name)
else:
print(f"\n{COLOR_ERROR}Could not find valid AppID after {max_retries} attempts.{COLOR_RESET}")
print(f"{COLOR_WARNING}Configuration may not work properly.{COLOR_RESET}")
return
else:
print(f"{COLOR_ERROR}Steam restart failed. Please restart manually and configure later.{COLOR_RESET}")
return
# Step 3: Build configuration context with the AppID
config_context = {
'name': shortcut_name,
'appid': app_id,
'path': install_dir_str,
'mo2_exe_path': mo2_exe_path,
'resolution': self.context.get('resolution'),
'skip_confirmation': is_gui_mode,
'manual_steps_completed': not is_gui_mode # True if we did manual steps above
}
# Step 4: Use ModlistMenuHandler to run the complete configuration
from ..handlers.menu_handler import ModlistMenuHandler
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
modlist_menu = ModlistMenuHandler(config_handler)
# Add section header for configuration phase if progress callback is available # Add section header for configuration phase if progress callback is available
if 'progress_callback' in locals() and progress_callback: if 'progress_callback' in locals() and progress_callback:
progress_callback("") # Blank line for spacing progress_callback("") # Blank line for spacing
progress_callback("=== Configuring Modlist ===") progress_callback("=== Configuration Phase ===")
self.logger.info("Running post-installation configuration phase") print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
configuration_success = modlist_menu.run_modlist_configuration_phase(config_context) self.logger.info("Running post-installation configuration phase using ModlistService")
# Configure modlist using SAME method as GUI
configuration_success = modlist_service.configure_modlist_post_steam(modlist_context)
if configuration_success: if configuration_success:
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
self.logger.info("Post-installation configuration completed successfully") self.logger.info("Post-installation configuration completed successfully")
else: else:
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
self.logger.warning("Post-installation configuration had issues") self.logger.warning("Post-installation configuration had issues")
else: else:
# Game not supported for automated configuration # Game not supported for automated configuration
@@ -1162,10 +1199,9 @@ class ModlistInstallCLI:
# Section header now provided by GUI layer to avoid duplication # Section header now provided by GUI layer to avoid duplication
try: try:
# Set GUI mode for backend operations # CLI Install: keep original GUI mode (don't force GUI mode)
import os import os
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE') original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
os.environ['JACKIFY_GUI_MODE'] = '1'
try: try:
# Build context for configuration # Build context for configuration
@@ -1176,7 +1212,7 @@ class ModlistInstallCLI:
'modlist_value': context.get('modlist_value'), 'modlist_value': context.get('modlist_value'),
'modlist_source': context.get('modlist_source'), 'modlist_source': context.get('modlist_source'),
'resolution': context.get('resolution'), 'resolution': context.get('resolution'),
'skip_confirmation': True, # GUI mode is non-interactive 'skip_confirmation': True, # CLI Install is non-interactive
'manual_steps_completed': False 'manual_steps_completed': False
} }
@@ -1258,13 +1294,16 @@ class ModlistInstallCLI:
from jackify.backend.services.native_steam_service import NativeSteamService from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = 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( success, app_id = steam_service.create_shortcut_with_proton(
app_name=config_context['name'], app_name=config_context['name'],
exe_path=config_context['mo2_exe_path'], exe_path=config_context['mo2_exe_path'],
start_dir=os.path.dirname(config_context['mo2_exe_path']), start_dir=os.path.dirname(config_context['mo2_exe_path']),
launch_options="%command%", launch_options="%command%",
tags=["Jackify"], tags=["Jackify"],
proton_version="proton_experimental" proton_version=proton_version
) )
if not success or not app_id: if not success or not app_id:
@@ -1400,8 +1439,9 @@ class ModlistInstallCLI:
# Remove status indicators to get clean line # Remove status indicators to get clean line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
# Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL] # Split from right to handle modlist names with dashes
parts = clean_line.split(' - ') # Format: "NAME - GAME - SIZES - MACHINE_URL"
parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts
if len(parts) != 4: if len(parts) != 4:
continue # Skip malformed lines continue # Skip malformed lines

View File

@@ -37,7 +37,10 @@ class ConfigHandler:
"default_install_parent_dir": None, # Parent directory for modlist installations "default_install_parent_dir": None, # Parent directory for modlist installations
"default_download_parent_dir": None, # Parent directory for downloads "default_download_parent_dir": None, # Parent directory for downloads
"modlist_install_base_dir": os.path.expanduser("~/Games"), # Configurable base directory for modlist installations "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 "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)
"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 # Load configuration if exists
@@ -46,6 +49,14 @@ class ConfigHandler:
# If steam_path is not set, detect it # If steam_path is not set, detect it
if not self.settings["steam_path"]: if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path() self.settings["steam_path"] = self._detect_steam_path()
# Auto-detect and set Proton version on first run
if 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"):
self.settings["jackify_data_dir"] = os.path.expanduser("~/Jackify")
# Save the updated settings # Save the updated settings
self.save_config() self.save_config()
@@ -213,11 +224,14 @@ class ConfigHandler:
def get_api_key(self): def get_api_key(self):
""" """
Retrieve and decode the saved Nexus API key Retrieve and decode the saved Nexus API key
Always reads fresh from disk to pick up changes from other instances
Returns: Returns:
str: Decoded API key or None if not saved str: Decoded API key or None if not saved
""" """
try: try:
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
encoded_key = self.settings.get("nexus_api_key") encoded_key = self.settings.get("nexus_api_key")
if encoded_key: if encoded_key:
# Decode the base64 encoded key # Decode the base64 encoded key
@@ -231,10 +245,13 @@ class ConfigHandler:
def has_saved_api_key(self): def has_saved_api_key(self):
""" """
Check if an API key is saved in configuration Check if an API key is saved in configuration
Always reads fresh from disk to pick up changes from other instances
Returns: Returns:
bool: True if API key exists, False otherwise bool: True if API key exists, False otherwise
""" """
# Reload config from disk to pick up changes from Settings dialog
self._load_config()
return self.settings.get("nexus_api_key") is not None return self.settings.get("nexus_api_key") is not None
def clear_api_key(self): def clear_api_key(self):
@@ -481,4 +498,88 @@ class ConfigHandler:
logger.error(f"Error saving modlist downloads base directory: {e}") logger.error(f"Error saving modlist downloads base directory: {e}")
return False 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

@@ -168,7 +168,7 @@ def main():
print(f"Error: {diagnosis['error']}") print(f"Error: {diagnosis['error']}")
return return
print(f"\n📊 Diagnosis Results:") print(f"\nDiagnosis Results:")
print(f" Average CPU: {diagnosis['avg_cpu']:.1f}% (Range: {diagnosis['min_cpu']:.1f}% - {diagnosis['max_cpu']:.1f}%)") print(f" Average CPU: {diagnosis['avg_cpu']:.1f}% (Range: {diagnosis['min_cpu']:.1f}% - {diagnosis['max_cpu']:.1f}%)")
print(f" Memory usage: {diagnosis['avg_memory_mb']:.1f}MB (Peak: {diagnosis['max_memory_mb']:.1f}MB)") print(f" Memory usage: {diagnosis['avg_memory_mb']:.1f}MB (Peak: {diagnosis['max_memory_mb']:.1f}MB)")
print(f" Low CPU samples: {diagnosis['low_cpu_samples']}/{diagnosis['samples']} " print(f" Low CPU samples: {diagnosis['low_cpu_samples']}/{diagnosis['samples']} "

View File

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

View File

@@ -71,15 +71,19 @@ class ModlistHandler:
} }
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh) # 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 = { MODLIST_WINE_COMPONENTS = {
"wildlander": ["dotnet472"], # "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
"librum": ["dotnet40", "dotnet8"], # "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"apostasy": ["dotnet40", "dotnet8"], "librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
"nordicsouls": ["dotnet40"], # "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
"livingskyrim": ["dotnet40"], "apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
"lsiv": ["dotnet40"], # "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
"ls4": ["dotnet40"], # "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
"lostlegacy": ["dotnet48"], # "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, 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 = logging.getLogger(__name__)
self.logger.propagate = False self.logger.propagate = False
self.steamdeck = steamdeck 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.steam_path: Optional[Path] = None
self.verbose = verbose # Store verbose flag self.verbose = verbose # Store verbose flag
self.mo2_path: Optional[Path] = None self.mo2_path: Optional[Path] = None
@@ -158,7 +168,10 @@ class ModlistHandler:
self.stock_game_path = None self.stock_game_path = None
# Initialize Handlers (should happen regardless of how paths were provided) # Initialize Handlers (should happen regardless of how paths were provided)
self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck, logger=self.logger) self.protontricks_handler = ProtontricksHandler(self.steamdeck, logger=self.logger)
# Initialize winetricks handler for wine component installation
from .winetricks_handler import WinetricksHandler
self.winetricks_handler = WinetricksHandler(logger=self.logger)
self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=self.verbose) self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=self.verbose)
self.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler() self.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler()
self.resolution_handler = ResolutionHandler() self.resolution_handler = ResolutionHandler()
@@ -224,44 +237,41 @@ class ModlistHandler:
discovered_modlists_info = [] discovered_modlists_info = []
try: try:
# 1. Get ALL non-Steam shortcuts from Protontricks # Get shortcuts pointing to the executable from shortcuts.vdf
# Now calls the renamed method without filtering
protontricks_shortcuts = self.protontricks_handler.list_non_steam_shortcuts()
if not protontricks_shortcuts:
self.logger.warning("Protontricks did not list any non-Steam shortcuts.")
return []
self.logger.debug(f"Protontricks non-Steam shortcuts found: {protontricks_shortcuts}")
# 2. Get shortcuts pointing to the executable from shortcuts.vdf
matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name) matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name)
if not matching_vdf_shortcuts: if not matching_vdf_shortcuts:
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.") self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.")
return [] return []
self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}") self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}")
# 3. Correlate the two lists and extract required info # Process each matching shortcut and convert signed AppID to unsigned
for vdf_shortcut in matching_vdf_shortcuts: for vdf_shortcut in matching_vdf_shortcuts:
app_name = vdf_shortcut.get('AppName') app_name = vdf_shortcut.get('AppName')
start_dir = vdf_shortcut.get('StartDir') start_dir = vdf_shortcut.get('StartDir')
signed_appid = vdf_shortcut.get('appid')
if not app_name or not start_dir: if not app_name or not start_dir:
self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}") self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}")
continue continue
if app_name in protontricks_shortcuts: if signed_appid is None:
app_id = protontricks_shortcuts[app_name] self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}")
continue
# Append dictionary with all necessary info
modlist_info = { # Convert signed AppID to unsigned AppID (the format used by Steam prefixes)
'name': app_name, if signed_appid < 0:
'appid': app_id, unsigned_appid = signed_appid + (2**32)
'path': start_dir
}
discovered_modlists_info.append(modlist_info)
self.logger.info(f"Validated shortcut: '{app_name}' (AppID: {app_id}, Path: {start_dir})")
else: else:
# Downgraded from WARNING to INFO unsigned_appid = signed_appid
self.logger.info(f"Shortcut '{app_name}' found in VDF but not listed by protontricks. Skipping.")
# 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: except Exception as e:
self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True) self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True)
@@ -315,13 +325,26 @@ class ModlistHandler:
self.modlist_dir = Path(modlist_dir_path_str) self.modlist_dir = Path(modlist_dir_path_str)
self.modlist_ini = modlist_ini_path self.modlist_ini = modlist_ini_path
# Determine if modlist is on SD card # Determine if modlist is on SD card (Steam Deck only)
# Use str() for startswith check # On non-Steam Deck systems, /media mounts should use Z: drive, not D: drive
if str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media"): 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.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: else:
self.modlist_sdcard = False 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 # Find and set compatdata path now that we have appid
# Ensure PathHandler is available (should be initialized in __init__) # Ensure PathHandler is available (should be initialized in __init__)
@@ -345,7 +368,8 @@ class ModlistHandler:
# Store engine_installed flag for conditional path manipulation # Store engine_installed flag for conditional path manipulation
self.engine_installed = modlist_info.get('engine_installed', False) self.engine_installed = modlist_info.get('engine_installed', False)
self.logger.debug(f" Engine Installed: {self.engine_installed}") self.logger.debug(f" Engine Installed: {self.engine_installed}")
# Call internal detection methods to populate more state # Call internal detection methods to populate more state
if not self._detect_game_variables(): if not self._detect_game_variables():
self.logger.warning("Failed to auto-detect game type after setting context.") self.logger.warning("Failed to auto-detect game type after setting context.")
@@ -665,6 +689,25 @@ class ModlistHandler:
return False return False
self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.") self.logger.info("Step 3: Curated user.reg.modlist and system.reg.modlist applied successfully.")
# Step 3.5: Apply universal dotnet4.x compatibility registry fixes
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Applying universal dotnet4.x compatibility fixes")
self.logger.info("Step 3.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 4: Install Wine Components # Step 4: Install Wine Components
if status_callback: if status_callback:
status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)") status_callback(f"{self._get_progress_timestamp()} Installing Wine components (this may take a while)")
@@ -685,10 +728,27 @@ class ModlistHandler:
# All modlists now use their own AppID for wine components # All modlists now use their own AppID for wine components
target_appid = self.appid target_appid = self.appid
if not self.protontricks_handler.install_wine_components(target_appid, self.game_var_full, specific_components=components): # Use user's preferred component installation method (respects settings toggle)
self.logger.error("Failed to install Wine components. Configuration aborted.") 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.") print("Error: Failed to install necessary Wine components.")
return False # Abort on failure return False
self.logger.info("Step 4: Installing Wine components... Done") self.logger.info("Step 4: Installing Wine components... Done")
# Step 5: Ensure permissions of Modlist directory # Step 5: Ensure permissions of Modlist directory
@@ -716,6 +776,14 @@ class ModlistHandler:
self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}") self.logger.info(f"ModOrganizer.ini backed up to: {backup_path}")
self.logger.info("Step 6: Backing up ModOrganizer.ini... Done") 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 # Step 7a: Detect Stock Game/Game Root path
if status_callback: if status_callback:
status_callback(f"{self._get_progress_timestamp()} Detecting stock game path") status_callback(f"{self._get_progress_timestamp()} Detecting stock game path")
@@ -758,10 +826,27 @@ 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("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.") 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 # 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
steam_libraries = [self.steam_library] if self.steam_library else None
# 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:
# self.steam_library is steamapps/common, need to go up 2 levels to get library root
steam_library_root = Path(self.steam_library).parent.parent
steam_libraries = [steam_library_root]
self.logger.debug(f"Using Steam library root: {steam_library_root}")
if not self.path_handler.edit_binary_working_paths( if not self.path_handler.edit_binary_working_paths(
modlist_ini_path=modlist_ini_path_obj, modlist_ini_path=modlist_ini_path_obj,
modlist_dir_path=modlist_dir_path_obj, modlist_dir_path=modlist_dir_path_obj,
@@ -772,7 +857,8 @@ class ModlistHandler:
print("Error: Failed to update binary and working directory paths in ModOrganizer.ini.") print("Error: Failed to update binary and working directory paths in ModOrganizer.ini.")
return False # Abort on failure return False # Abort on failure
else: 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") self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
# Step 9: Update Resolution Settings (if applicable) # Step 9: Update Resolution Settings (if applicable)
@@ -781,10 +867,16 @@ class ModlistHandler:
status_callback(f"{self._get_progress_timestamp()} Updating resolution settings") status_callback(f"{self._get_progress_timestamp()} Updating resolution settings")
# Ensure resolution_handler call uses correct args if needed # Ensure resolution_handler call uses correct args if needed
# Assuming it uses modlist_dir (str) and game_var_full (str) # Assuming it uses modlist_dir (str) and game_var_full (str)
if not self.resolution_handler.update_ini_resolution( # Construct vanilla game directory path for fallback
modlist_dir=self.modlist_dir, vanilla_game_dir = None
game_var=self.game_var_full, if self.steam_library and self.game_var_full:
set_res=self.selected_resolution vanilla_game_dir = str(Path(self.steam_library) / "steamapps" / "common" / 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
): ):
self.logger.warning("Failed to update resolution settings in some INI files.") self.logger.warning("Failed to update resolution settings in some INI files.")
print("Warning: Failed to update resolution settings.") print("Warning: Failed to update resolution settings.")
@@ -811,32 +903,55 @@ class ModlistHandler:
status_callback(f"{self._get_progress_timestamp()} Creating dxvk.conf file") status_callback(f"{self._get_progress_timestamp()} Creating dxvk.conf file")
self.logger.info("Step 10: Creating dxvk.conf file...") self.logger.info("Step 10: Creating dxvk.conf file...")
# Assuming create_dxvk_conf still uses string paths # Assuming create_dxvk_conf still uses string paths
# Construct vanilla game directory path for fallback
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.path_handler.create_dxvk_conf( if not self.path_handler.create_dxvk_conf(
modlist_dir=self.modlist_dir, modlist_dir=self.modlist_dir,
modlist_sdcard=self.modlist_sdcard, modlist_sdcard=self.modlist_sdcard,
steam_library=str(self.steam_library) if self.steam_library else None, # Pass as string or None steam_library=str(self.steam_library) if self.steam_library else None, # Pass as string or None
basegame_sdcard=self.basegame_sdcard, basegame_sdcard=self.basegame_sdcard,
game_var_full=self.game_var_full game_var_full=self.game_var_full,
vanilla_game_dir=vanilla_game_dir
): ):
self.logger.warning("Failed to create dxvk.conf file.") self.logger.warning("Failed to create dxvk.conf file.")
print("Warning: Failed to create dxvk.conf file.") print("Warning: Failed to create dxvk.conf file.")
self.logger.info("Step 10: Creating dxvk.conf... Done") 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: if status_callback:
status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugin") status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugins")
self.logger.info("Step 11a: Deleting incompatible MO2 plugin (FixGameRegKey.py)...") self.logger.info("Step 11a: Deleting incompatible MO2 plugins...")
plugin_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
if plugin_path.exists(): # Delete FixGameRegKey.py plugin
fixgamereg_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
if fixgamereg_path.exists():
try: try:
plugin_path.unlink() fixgamereg_path.unlink()
self.logger.info("FixGameRegKey.py plugin deleted successfully.") self.logger.info("FixGameRegKey.py plugin deleted successfully.")
except Exception as e: except Exception as e:
self.logger.warning(f"Failed to delete FixGameRegKey.py plugin: {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: else:
self.logger.debug("FixGameRegKey.py plugin not found (this is normal).") 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 # Step 11b: Download Font
if status_callback: if status_callback:
@@ -844,7 +959,7 @@ class ModlistHandler:
prefix_path_str = self.path_handler.find_compat_data(str(self.appid)) prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
if prefix_path_str: if prefix_path_str:
prefix_path = Path(prefix_path_str) prefix_path = Path(prefix_path_str)
fonts_dir = prefix_path / "drive_c" / "windows" / "Fonts" fonts_dir = prefix_path / "pfx" / "drive_c" / "windows" / "Fonts"
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf" font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
font_dest_path = fonts_dir / "seguisym.ttf" font_dest_path = fonts_dir / "seguisym.ttf"
@@ -879,6 +994,10 @@ class ModlistHandler:
# status_callback("Configuration completed successfully!") # status_callback("Configuration completed successfully!")
self.logger.info("Configuration steps 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 return True # Return True on success
def _detect_steam_library_info(self) -> bool: def _detect_steam_library_info(self) -> bool:
@@ -1117,6 +1236,7 @@ class ModlistHandler:
("grid-hero.png", f"{appid}_hero.png"), ("grid-hero.png", f"{appid}_hero.png"),
("grid-logo.png", f"{appid}_logo.png"), ("grid-logo.png", f"{appid}_logo.png"),
("grid-tall.png", f"{appid}.png"), ("grid-tall.png", f"{appid}.png"),
("grid-tall.png", f"{appid}p.png"),
] ]
for src_name, dest_name in images: for src_name, dest_name in images:
@@ -1143,7 +1263,7 @@ class ModlistHandler:
# Determine game type # Determine game type
game = (game_var_full or modlist_name or "").lower().replace(" ", "") game = (game_var_full or modlist_name or "").lower().replace(" ", "")
# Add game-specific extras # 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"] extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
elif "falloutnewvegas" in game or "fnv" in game or "oblivion" in game: elif "falloutnewvegas" in game or "fnv" in game or "oblivion" in game:
extras += ["d3dx9_43", "d3dx9"] extras += ["d3dx9_43", "d3dx9"]
@@ -1218,6 +1338,12 @@ class ModlistHandler:
# Check ModOrganizer.ini for indicators (nvse/enderal) as an early, robust signal # Check ModOrganizer.ini for indicators (nvse/enderal) as an early, robust signal
try: try:
mo2_ini = modlist_path / "ModOrganizer.ini" 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(): if mo2_ini.exists():
try: try:
content = mo2_ini.read_text(errors='ignore').lower() content = mo2_ini.read_text(errors='ignore').lower()
@@ -1274,4 +1400,236 @@ class ModlistHandler:
self.logger.debug("No special game type detected - standard workflow will be used") self.logger.debug("No special game type detected - standard workflow will be used")
return None 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"""
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...")
# 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
# 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
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)
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)
if result2.returncode == 0:
self.logger.info("Successfully applied OnlyUseLatestCLR=1 registry entry")
else:
self.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:
self.logger.info("Universal dotnet4.x compatibility fixes applied successfully")
return True
else:
self.logger.warning("Some dotnet4.x registry fixes failed, but continuing...")
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): def __init__(self, menu_handler: MenuHandler, steamdeck: bool = False):
self.menu_handler = menu_handler self.menu_handler = menu_handler
self.steamdeck = steamdeck self.steamdeck = steamdeck
self.protontricks_handler = ProtontricksHandler(steamdeck=steamdeck) self.protontricks_handler = ProtontricksHandler(steamdeck)
self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck) self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
self.context = {} self.context = {}
# Use standard logging (no file handler) # Use standard logging (no file handler)
@@ -616,7 +616,8 @@ class ModlistInstallCLI:
if machineid: if machineid:
# Convert machineid to filename (e.g., "Tuxborn/Tuxborn" -> "Tuxborn.wabbajack") # Convert machineid to filename (e.g., "Tuxborn/Tuxborn" -> "Tuxborn.wabbajack")
modlist_name = machineid.split('/')[-1] if '/' in machineid else machineid modlist_name = machineid.split('/')[-1] if '/' in machineid else machineid
cached_wabbajack_path = os.path.expanduser(f"~/Jackify/downloaded_mod_lists/{modlist_name}.wabbajack") from jackify.shared.paths import get_jackify_downloads_dir
cached_wabbajack_path = get_jackify_downloads_dir() / f"{modlist_name}.wabbajack"
self.logger.debug(f"Checking for cached .wabbajack file: {cached_wabbajack_path}") self.logger.debug(f"Checking for cached .wabbajack file: {cached_wabbajack_path}")
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value): if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
@@ -723,13 +724,17 @@ class ModlistInstallCLI:
if chunk == b'\n': if chunk == b'\n':
# Complete line - decode and print # Complete line - decode and print
line = buffer.decode('utf-8', errors='replace') line = buffer.decode('utf-8', errors='replace')
print(line, end='') # Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')
buffer = b'' buffer = b''
last_progress_time = time.time() last_progress_time = time.time()
elif chunk == b'\r': elif chunk == b'\r':
# Carriage return - decode and print without newline # Carriage return - decode and print without newline
line = buffer.decode('utf-8', errors='replace') line = buffer.decode('utf-8', errors='replace')
print(line, end='') # Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')
sys.stdout.flush() sys.stdout.flush()
buffer = b'' buffer = b''
last_progress_time = time.time() last_progress_time = time.time()
@@ -1020,8 +1025,9 @@ class ModlistInstallCLI:
# Remove status indicators to get clean line # Remove status indicators to get clean line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip() clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
# Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL] # Split from right to handle modlist names with dashes
parts = clean_line.split(' - ') # Format: "NAME - GAME - SIZES - MACHINE_URL"
parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts
if len(parts) != 4: if len(parts) != 4:
continue # Skip malformed lines continue # Skip malformed lines
@@ -1097,4 +1103,36 @@ class ModlistInstallCLI:
print(f"Nexus API Key: [SET]") print(f"Nexus API Key: [SET]")
else: else:
print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]") print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]")
print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}") print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}")
def _enhance_nexus_error(self, line: str) -> str:
"""
Enhance Nexus download error messages by adding the mod URL for easier troubleshooting.
"""
import re
# Pattern to match Nexus download errors with ModID and FileID
nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):"
match = re.search(nexus_error_pattern, line)
if match:
game_name = match.group(1)
mod_id = match.group(2)
# Map game names to Nexus URL segments
game_url_map = {
'SkyrimSpecialEdition': 'skyrimspecialedition',
'Skyrim': 'skyrim',
'Fallout4': 'fallout4',
'FalloutNewVegas': 'newvegas',
'Oblivion': 'oblivion',
'Starfield': 'starfield'
}
game_url = game_url_map.get(game_name, game_name.lower())
mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}"
# Add URL on next line for easier debugging
return f"{line}\n Nexus URL: {mod_url}"
return line

View File

@@ -32,14 +32,21 @@ class PathHandler:
@staticmethod @staticmethod
def _strip_sdcard_path_prefix(path_obj: Path) -> str: def _strip_sdcard_path_prefix(path_obj: Path) -> str:
""" """
Removes the '/run/media/mmcblk0p1/' prefix if present. Removes any detected SD card mount prefix dynamically.
Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns.
Returns the path as a POSIX-style string (using /). Returns the path as a POSIX-style string (using /).
""" """
path_str = path_obj.as_posix() # Work with consistent forward slashes from .wine_utils import WineUtils
if path_str.lower().startswith(SDCARD_PREFIX.lower()):
# Return the part *after* the prefix, ensuring no leading slash remains unless root path_str = path_obj.as_posix() # Work with consistent forward slashes
relative_part = path_str[len(SDCARD_PREFIX):]
return relative_part if relative_part else "." # Return '.' if it was exactly the prefix # 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 return path_str
@staticmethod @staticmethod
@@ -251,7 +258,7 @@ class PathHandler:
return False return False
@staticmethod @staticmethod
def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full): def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full, vanilla_game_dir=None):
""" """
Create dxvk.conf file in the appropriate location Create dxvk.conf file in the appropriate location
@@ -261,6 +268,7 @@ class PathHandler:
steam_library (str): Path to the Steam library steam_library (str): Path to the Steam library
basegame_sdcard (bool): Whether the base game is on an SD card basegame_sdcard (bool): Whether the base game is on an SD card
game_var_full (str): Full name of the game (e.g., "Skyrim Special Edition") game_var_full (str): Full name of the game (e.g., "Skyrim Special Edition")
vanilla_game_dir (str): Optional path to vanilla game directory for fallback
Returns: Returns:
bool: True on success, False on failure bool: True on success, False on failure
@@ -271,25 +279,35 @@ class PathHandler:
# Determine the location for dxvk.conf # Determine the location for dxvk.conf
dxvk_conf_path = None dxvk_conf_path = None
# Check for common stock game directories # Check for common stock game directories first, then vanilla as fallback
stock_game_paths = [ stock_game_paths = [
os.path.join(modlist_dir, "Stock Game"), os.path.join(modlist_dir, "Stock Game"),
os.path.join(modlist_dir, "STOCK GAME"),
os.path.join(modlist_dir, "Game Root"), os.path.join(modlist_dir, "Game Root"),
os.path.join(modlist_dir, "STOCK GAME"),
os.path.join(modlist_dir, "Stock Game Folder"),
os.path.join(modlist_dir, "Stock Folder"), os.path.join(modlist_dir, "Stock Folder"),
os.path.join(modlist_dir, "Skyrim Stock"), os.path.join(modlist_dir, "Skyrim Stock"),
os.path.join(modlist_dir, "root", "Skyrim Special Edition"), os.path.join(modlist_dir, "root", "Skyrim Special Edition")
os.path.join(steam_library, game_var_full)
] ]
# Add vanilla game directory as fallback if steam_library and game_var_full are provided
if steam_library and game_var_full:
stock_game_paths.append(os.path.join(steam_library, "steamapps", "common", game_var_full))
for path in stock_game_paths: for path in stock_game_paths:
if os.path.exists(path): if os.path.exists(path):
dxvk_conf_path = os.path.join(path, "dxvk.conf") dxvk_conf_path = os.path.join(path, "dxvk.conf")
break break
if not dxvk_conf_path: if not dxvk_conf_path:
logger.error("Could not determine location for dxvk.conf") # Fallback: Try vanilla game directory if provided
return False if vanilla_game_dir and os.path.exists(vanilla_game_dir):
logger.info(f"Attempting fallback to vanilla game directory: {vanilla_game_dir}")
dxvk_conf_path = os.path.join(vanilla_game_dir, "dxvk.conf")
logger.info(f"Using vanilla game directory for dxvk.conf: {dxvk_conf_path}")
else:
logger.error("Could not determine location for dxvk.conf")
return False
# The required line that Jackify needs # The required line that Jackify needs
required_line = "dxvk.enableGraphicsPipelineLibrary = False" required_line = "dxvk.enableGraphicsPipelineLibrary = False"
@@ -612,47 +630,30 @@ class PathHandler:
# Moved _find_shortcuts_vdf here from ShortcutHandler # Moved _find_shortcuts_vdf here from ShortcutHandler
def _find_shortcuts_vdf(self) -> Optional[str]: 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 Uses proper multi-user detection to find the correct Steam user instead
first found shortcuts.vdf file. of just taking the first found user directory.
Returns: Returns:
Optional[str]: The full path to the shortcuts.vdf file, or None if not found. Optional[str]: The full path to the shortcuts.vdf file, or None if not found.
""" """
# This implementation was moved from ShortcutHandler try:
userdata_base_paths = [ # Use native Steam service for proper multi-user detection
os.path.expanduser("~/.steam/steam/userdata"), from jackify.backend.services.native_steam_service import NativeSteamService
os.path.expanduser("~/.local/share/Steam/userdata"), steam_service = NativeSteamService()
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata") shortcuts_path = steam_service.get_shortcuts_vdf_path()
]
found_vdf_path = None if shortcuts_path:
for base_path in userdata_base_paths: logger.info(f"Found shortcuts.vdf using multi-user detection: {shortcuts_path}")
if not os.path.isdir(base_path): return str(shortcuts_path)
logger.debug(f"Userdata base path not found or not a directory: {base_path}") else:
continue logger.error("Could not determine shortcuts.vdf path using multi-user detection")
logger.debug(f"Searching for user IDs in: {base_path}") return None
try:
for item in os.listdir(base_path): except Exception as e:
user_path = os.path.join(base_path, item) logger.error(f"Error using multi-user detection for shortcuts.vdf: {e}")
if os.path.isdir(user_path) and item.isdigit(): return None
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
@staticmethod @staticmethod
def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]: def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]:
@@ -726,7 +727,7 @@ class PathHandler:
try: try:
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f: with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines() lines = f.readlines()
drive_letter = "D:" if modlist_sdcard else "Z:" drive_letter = "D:\\\\" if modlist_sdcard else "Z:\\\\"
processed_path = self._strip_sdcard_path_prefix(new_game_path) processed_path = self._strip_sdcard_path_prefix(new_game_path)
windows_style = processed_path.replace('/', '\\') windows_style = processed_path.replace('/', '\\')
windows_style_double = windows_style.replace('\\', '\\\\') windows_style_double = windows_style.replace('\\', '\\\\')
@@ -773,6 +774,39 @@ class PathHandler:
return False return False
with open(modlist_ini_path, 'r', encoding='utf-8') as f: with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines() lines = f.readlines()
# Extract existing gamePath to use as source of truth for vanilla game location
existing_game_path = None
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
new_gamepath_value = f"D:\\\\{stripped_path.replace('/', '\\\\')}"
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 game_path_updated = False
binary_paths_updated = 0 binary_paths_updated = 0
working_dirs_updated = 0 working_dirs_updated = 0
@@ -791,9 +825,16 @@ class PathHandler:
backslash_style = wd_match.group(2) backslash_style = wd_match.group(2)
working_dir_lines.append((i, stripped, index, backslash_style)) working_dir_lines.append((i, stripped, index, backslash_style))
binary_paths_by_index = {} binary_paths_by_index = {}
# Use provided steam_libraries if available, else detect # Use existing gamePath to determine correct Steam library, fallback to detection
if steam_libraries is None or not steam_libraries: if existing_game_path and '/steamapps/common/' in existing_game_path:
# Extract the Steam library root from the existing gamePath
steamapps_index = existing_game_path.find('/steamapps/common/')
steam_lib_root = existing_game_path[:steamapps_index]
steam_libraries = [Path(steam_lib_root)]
logger.info(f"Using Steam library from existing gamePath: {steam_lib_root}")
elif steam_libraries is None or not steam_libraries:
steam_libraries = PathHandler.get_all_steam_library_paths() steam_libraries = PathHandler.get_all_steam_library_paths()
logger.debug(f"Fallback to detected Steam libraries: {steam_libraries}")
for i, line, index, backslash_style in binary_lines: for i, line, index, backslash_style in binary_lines:
parts = line.split('=', 1) parts = line.split('=', 1)
if len(parts) != 2: if len(parts) != 2:
@@ -815,11 +856,12 @@ class PathHandler:
subpath = value_part[idx:].lstrip('/') subpath = value_part[idx:].lstrip('/')
correct_steam_lib = None correct_steam_lib = None
for lib in steam_libraries: for lib in steam_libraries:
if (lib / subpath.split('/')[2]).exists(): # Check if the actual game folder exists in this library
correct_steam_lib = lib.parent if len(subpath.split('/')) > 3 and (lib / subpath.split('/')[2] / subpath.split('/')[3]).exists():
correct_steam_lib = lib
break break
if not correct_steam_lib and steam_libraries: if not correct_steam_lib and steam_libraries:
correct_steam_lib = steam_libraries[0].parent correct_steam_lib = steam_libraries[0]
if correct_steam_lib: if correct_steam_lib:
new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/') new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/')
else: else:
@@ -842,9 +884,10 @@ class PathHandler:
rel_path = value_part[idx:].lstrip('/') rel_path = value_part[idx:].lstrip('/')
else: else:
rel_path = exe_name rel_path = exe_name
new_binary_path = f"{drive_prefix}/{modlist_dir_path}/{rel_path}".replace('\\', '/').replace('//', '/') processed_modlist_path = PathHandler._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
formatted_binary_path = PathHandler._format_binary_for_mo2(new_binary_path) formatted_binary_path = PathHandler._format_binary_for_mo2(new_binary_path)
new_binary_line = f"{index}{backslash_style}binary={formatted_binary_path}" new_binary_line = f"{index}{backslash_style}binary = {formatted_binary_path}"
logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}") logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}")
lines[i] = new_binary_line + "\n" lines[i] = new_binary_line + "\n"
binary_paths_updated += 1 binary_paths_updated += 1
@@ -859,7 +902,7 @@ class PathHandler:
wd_path = drive_prefix + wd_path wd_path = drive_prefix + wd_path
formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path) formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path)
key_part = f"{index}{backslash_style}workingDirectory" key_part = f"{index}{backslash_style}workingDirectory"
new_wd_line = f"{key_part}={formatted_wd_path}" new_wd_line = f"{key_part} = {formatted_wd_path}"
logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}") logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}")
lines[j] = new_wd_line + "\n" lines[j] = new_wd_line + "\n"
working_dirs_updated += 1 working_dirs_updated += 1

View File

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

View File

@@ -149,7 +149,7 @@ class ResolutionHandler:
return ["1280x720", "1280x800", "1920x1080", "1920x1200", "2560x1440"] return ["1280x720", "1280x800", "1920x1080", "1920x1200", "2560x1440"]
@staticmethod @staticmethod
def update_ini_resolution(modlist_dir: str, game_var: str, set_res: str) -> bool: def update_ini_resolution(modlist_dir: str, game_var: str, set_res: str, vanilla_game_dir: str = None) -> bool:
""" """
Updates the resolution in relevant INI files for the specified game. Updates the resolution in relevant INI files for the specified game.
@@ -157,6 +157,7 @@ class ResolutionHandler:
modlist_dir (str): Path to the modlist directory. modlist_dir (str): Path to the modlist directory.
game_var (str): The game identifier (e.g., "Skyrim Special Edition", "Fallout 4"). game_var (str): The game identifier (e.g., "Skyrim Special Edition", "Fallout 4").
set_res (str): The desired resolution (e.g., "1920x1080"). set_res (str): The desired resolution (e.g., "1920x1080").
vanilla_game_dir (str): Optional path to vanilla game directory for fallback.
Returns: Returns:
bool: True if successful or not applicable, False on error. bool: True if successful or not applicable, False on error.
@@ -211,22 +212,30 @@ class ResolutionHandler:
logger.debug(f"Processing {prefs_filenames}...") logger.debug(f"Processing {prefs_filenames}...")
prefs_files_found = [] prefs_files_found = []
# Search common locations: profiles/, stock game dirs # Search entire modlist directory recursively for all target files
search_dirs = [modlist_path / "profiles"] logger.debug(f"Searching entire modlist directory for: {prefs_filenames}")
# Add potential stock game directories dynamically (case-insensitive) for fname in prefs_filenames:
potential_stock_dirs = [d for d in modlist_path.iterdir() if d.is_dir() and found_files = list(modlist_path.rglob(fname))
d.name.lower() in ["stock game", "game root", "stock folder", "skyrim stock"]] # Add more if needed prefs_files_found.extend(found_files)
search_dirs.extend(potential_stock_dirs) if found_files:
logger.debug(f"Found {len(found_files)} {fname} files: {[str(f) for f in found_files]}")
for search_dir in search_dirs:
if search_dir.is_dir():
for fname in prefs_filenames:
prefs_files_found.extend(list(search_dir.rglob(fname)))
if not prefs_files_found: if not prefs_files_found:
logger.warning(f"No preference files ({prefs_filenames}) found in standard locations ({search_dirs}). Manual INI edit might be needed.") logger.warning(f"No preference files ({prefs_filenames}) found in modlist directory.")
# Consider this success as the main operation didn't fail?
return True # Fallback: Try vanilla game directory if provided
if vanilla_game_dir:
logger.info(f"Attempting fallback to vanilla game directory: {vanilla_game_dir}")
vanilla_path = Path(vanilla_game_dir)
for fname in prefs_filenames:
vanilla_files = list(vanilla_path.rglob(fname))
prefs_files_found.extend(vanilla_files)
if vanilla_files:
logger.info(f"Found {len(vanilla_files)} {fname} files in vanilla game directory")
if not prefs_files_found:
logger.warning("No preference files found in modlist or vanilla game directory. Manual INI edit might be needed.")
return True
for ini_file in prefs_files_found: for ini_file in prefs_files_found:
files_processed += 1 files_processed += 1
@@ -314,19 +323,23 @@ class ResolutionHandler:
new_lines = [] new_lines = []
modified = False modified = False
# Prepare the replacement strings for width and height
# Ensure correct spacing for Oblivion vs other games
# Corrected f-string syntax for conditional expression
equals_operator = "=" if is_oblivion else " = "
width_replace = f"iSize W{equals_operator}{width}\n"
height_replace = f"iSize H{equals_operator}{height}\n"
for line in lines: for line in lines:
stripped_line = line.strip() stripped_line = line.strip()
if stripped_line.lower().endswith("isize w"): if stripped_line.lower().startswith("isize w"):
# Preserve original spacing around equals sign
if " = " in stripped_line:
width_replace = f"iSize W = {width}\n"
else:
width_replace = f"iSize W={width}\n"
new_lines.append(width_replace) new_lines.append(width_replace)
modified = True modified = True
elif stripped_line.lower().endswith("isize h"): elif stripped_line.lower().startswith("isize h"):
# Preserve original spacing around equals sign
if " = " in stripped_line:
height_replace = f"iSize H = {height}\n"
else:
height_replace = f"iSize H={height}\n"
new_lines.append(height_replace) new_lines.append(height_replace)
modified = True modified = True
else: else:

View File

@@ -1,141 +0,0 @@
import os
import sys
import json
import requests
import shutil
import tempfile
import time
from pathlib import Path
GITHUB_OWNER = "Omni-guides"
GITHUB_REPO = "Jackify"
ASSET_NAME = "jackify"
CONFIG_DIR = os.path.expanduser("~/.config/jackify")
TOKEN_PATH = os.path.join(CONFIG_DIR, "github_token")
LAST_CHECK_PATH = os.path.join(CONFIG_DIR, "last_update_check.json")
THROTTLE_HOURS = 6
def get_github_token():
if os.path.exists(TOKEN_PATH):
with open(TOKEN_PATH, "r") as f:
return f.read().strip()
return None
def get_latest_release_info():
url = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest"
headers = {}
token = get_github_token()
if token:
headers["Authorization"] = f"token {token}"
resp = requests.get(url, headers=headers, verify=True)
if resp.status_code == 200:
return resp.json()
else:
raise RuntimeError(f"Failed to fetch release info: {resp.status_code} {resp.text}")
def get_current_version():
# This should match however Jackify stores its version
try:
from src import version
return version.__version__
except ImportError:
return None
def should_check_for_update():
try:
if os.path.exists(LAST_CHECK_PATH):
with open(LAST_CHECK_PATH, "r") as f:
data = json.load(f)
last_check = data.get("last_check", 0)
now = int(time.time())
if now - last_check < THROTTLE_HOURS * 3600:
return False
return True
except Exception as e:
print(f"[WARN] Could not read last update check timestamp: {e}")
return True
def record_update_check():
try:
with open(LAST_CHECK_PATH, "w") as f:
json.dump({"last_check": int(time.time())}, f)
except Exception as e:
print(f"[WARN] Could not write last update check timestamp: {e}")
def check_for_update():
if not should_check_for_update():
return False, None, None
try:
release = get_latest_release_info()
latest_version = release["tag_name"].lstrip("v")
current_version = get_current_version()
if current_version is None:
print("[WARN] Could not determine current version.")
record_update_check()
return False, None, None
if latest_version > current_version:
record_update_check()
return True, latest_version, release
record_update_check()
return False, latest_version, release
except Exception as e:
print(f"[ERROR] Update check failed: {e}")
record_update_check()
return False, None, None
def download_latest_asset(release):
token = get_github_token()
headers = {"Accept": "application/octet-stream"}
if token:
headers["Authorization"] = f"token {token}"
for asset in release["assets"]:
if asset["name"] == ASSET_NAME:
download_url = asset["url"]
resp = requests.get(download_url, headers=headers, stream=True, verify=True)
if resp.status_code == 200:
return resp.content
else:
raise RuntimeError(f"Failed to download asset: {resp.status_code} {resp.text}")
raise RuntimeError(f"Asset '{ASSET_NAME}' not found in release.")
def replace_current_binary(new_binary_bytes):
current_exe = os.path.realpath(sys.argv[0])
backup_path = current_exe + ".bak"
try:
# Write to a temp file first
with tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(current_exe)) as tmpf:
tmpf.write(new_binary_bytes)
tmp_path = tmpf.name
# Backup current binary
shutil.copy2(current_exe, backup_path)
# Replace atomically
os.replace(tmp_path, current_exe)
os.chmod(current_exe, 0o755)
print(f"[INFO] Updated binary written to {current_exe}. Backup at {backup_path}.")
return True
except Exception as e:
print(f"[ERROR] Failed to replace binary: {e}")
return False
def main():
if '--update' in sys.argv:
print("Checking for updates...")
update_available, latest_version, release = check_for_update()
if update_available:
print(f"A new version (v{latest_version}) is available. Downloading...")
try:
new_bin = download_latest_asset(release)
if replace_current_binary(new_bin):
print("Update complete! Please restart Jackify.")
else:
print("Update failed during binary replacement.")
except Exception as e:
print(f"[ERROR] Update failed: {e}")
else:
print("You are already running the latest version.")
sys.exit(0)
# For direct CLI testing
if __name__ == "__main__":
main()

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import shutil
import time import time
from pathlib import Path from pathlib import Path
import glob import glob
from typing import Optional, Tuple from typing import Optional, Tuple, List, Dict
from .subprocess_utils import get_clean_subprocess_env from .subprocess_utils import get_clean_subprocess_env
# Initialize logger # Initialize logger
@@ -197,16 +197,43 @@ class WineUtils:
logger.error(f"Error editing binary working paths: {e}") logger.error(f"Error editing binary working paths: {e}")
return False return False
@staticmethod
def _get_sd_card_mounts():
"""
Dynamically detect all current SD card mount points
Returns list of mount point paths
"""
try:
import subprocess
result = subprocess.run(['df', '-h'], capture_output=True, text=True, timeout=5)
sd_mounts = []
for line in result.stdout.split('\n'):
# Look for common SD card mount patterns
if '/run/media' in line or ('/mnt' in line and 'sdcard' in line.lower()):
parts = line.split()
if len(parts) >= 6: # df output has 6+ columns
mount_point = parts[-1] # Last column is mount point
if mount_point.startswith(('/run/media', '/mnt')):
sd_mounts.append(mount_point)
return sd_mounts
except Exception:
# Fallback to common patterns if df fails
return ['/run/media/mmcblk0p1', '/run/media/deck']
@staticmethod @staticmethod
def _strip_sdcard_path(path): def _strip_sdcard_path(path):
""" """
Strip /run/media/deck/UUID from SD card paths Strip any detected SD card mount prefix from paths
Internal helper method Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns
""" """
if path.startswith("/run/media/deck/"): sd_mounts = WineUtils._get_sd_card_mounts()
parts = path.split("/", 5)
if len(parts) >= 6: for mount in sd_mounts:
return "/" + parts[5] if path.startswith(mount):
# Strip the mount prefix and ensure proper leading slash
relative_path = path[len(mount):].lstrip('/')
return "/" + relative_path if relative_path else "/"
return path return path
@staticmethod @staticmethod
@@ -510,10 +537,7 @@ class WineUtils:
if "mods" in binary_path: if "mods" in binary_path:
# mods path type found # mods path type found
if modlist_sdcard: if modlist_sdcard:
path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir path_middle = WineUtils._strip_sdcard_path(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]
else: else:
path_middle = modlist_dir 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"]): 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 # Stock/Game Root found
if modlist_sdcard: if modlist_sdcard:
path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir path_middle = WineUtils._strip_sdcard_path(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]
else: else:
path_middle = modlist_dir path_middle = modlist_dir
@@ -562,7 +583,7 @@ class WineUtils:
elif "steamapps" in binary_path: elif "steamapps" in binary_path:
# Steamapps found # Steamapps found
if basegame_sdcard: 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:" drive_letter = "D:"
else: else:
path_middle = steam_library.split('steamapps', 1)[0] if 'steamapps' in steam_library else steam_library 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 # Clean up the version string for directory matching
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')] version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
# Standard Steam library locations
steam_common_paths = [ # Get actual Steam library paths from libraryfolders.vdf (smart detection)
Path.home() / ".steam/steam/steamapps/common", steam_common_paths = []
Path.home() / ".local/share/Steam/steamapps/common", compatibility_paths = []
Path.home() / ".steam/root/steamapps/common"
] 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 # Special handling for Proton 9: try all possible directory names
if proton_version.strip().startswith("Proton 9"): if proton_version.strip().startswith("Proton 9"):
proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"] proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"]
@@ -628,8 +686,9 @@ class WineUtils:
wine_bin = subdir / "files/bin/wine" wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file(): if wine_bin.is_file():
return str(wine_bin) return str(wine_bin)
# General case: try version patterns # General case: try version patterns in both steamapps and compatibilitytools.d
for base_path in steam_common_paths: all_paths = steam_common_paths + compatibility_paths
for base_path in all_paths:
if not base_path.is_dir(): if not base_path.is_dir():
continue continue
for pattern in version_patterns: for pattern in version_patterns:
@@ -643,7 +702,20 @@ class WineUtils:
wine_bin = subdir / "files/bin/wine" wine_bin = subdir / "files/bin/wine"
if wine_bin.is_file(): if wine_bin.is_file():
return str(wine_bin) return str(wine_bin)
# 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: for base_path in steam_common_paths:
wine_bin = base_path / "Proton - Experimental" / "files/bin/wine" wine_bin = base_path / "Proton - Experimental" / "files/bin/wine"
if wine_bin.is_file(): if wine_bin.is_file():
@@ -698,4 +770,307 @@ class WineUtils:
proton_path = str(Path(wine_bin).parent.parent) proton_path = str(Path(wine_bin).parent.parent)
logger.debug(f"Found Proton path: {proton_path}") 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

View File

@@ -0,0 +1,893 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Winetricks Handler Module
Handles wine component installation using bundled winetricks
"""
import os
import subprocess
import logging
from pathlib import Path
from typing import Optional, List
logger = logging.getLogger(__name__)
class WinetricksHandler:
"""
Handles wine component installation using bundled winetricks
"""
def __init__(self, logger=None):
self.logger = logger or logging.getLogger(__name__)
self.winetricks_path = self._get_bundled_winetricks_path()
def _get_bundled_winetricks_path(self) -> Optional[str]:
"""
Get the path to the bundled winetricks script following AppImage best practices
"""
possible_paths = []
# AppImage environment - use APPDIR (standard AppImage best practice)
if os.environ.get('APPDIR'):
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'winetricks')
possible_paths.append(appdir_path)
# Development environment - relative to module location
module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/
dev_path = module_dir / 'tools' / 'winetricks'
possible_paths.append(str(dev_path))
# Try each path until we find one that works
for path in possible_paths:
if os.path.exists(path) and os.access(path, os.X_OK):
self.logger.debug(f"Found bundled winetricks at: {path}")
return str(path)
self.logger.error(f"Bundled winetricks not found. Tried paths: {possible_paths}")
return None
def _get_bundled_cabextract(self) -> Optional[str]:
"""
Get the path to the bundled cabextract binary, checking same locations as winetricks
"""
possible_paths = []
# AppImage environment - same pattern as winetricks detection
if os.environ.get('APPDIR'):
appdir_path = os.path.join(os.environ['APPDIR'], 'opt', 'jackify', 'tools', 'cabextract')
possible_paths.append(appdir_path)
# Development environment - relative to module location, same as winetricks
module_dir = Path(__file__).parent.parent.parent # Go from handlers/ up to jackify/
dev_path = module_dir / 'tools' / 'cabextract'
possible_paths.append(str(dev_path))
# Try each path until we find one that works
for path in possible_paths:
if os.path.exists(path) and os.access(path, os.X_OK):
self.logger.debug(f"Found bundled cabextract at: {path}")
return str(path)
# Fallback to system PATH
try:
import shutil
system_cabextract = shutil.which('cabextract')
if system_cabextract:
self.logger.debug(f"Using system cabextract: {system_cabextract}")
return system_cabextract
except Exception:
pass
self.logger.warning("Bundled cabextract not found in tools directory")
return None
def is_available(self) -> bool:
"""
Check if winetricks is available and ready to use
"""
if not self.winetricks_path:
self.logger.error("Bundled winetricks not found")
return False
try:
env = os.environ.copy()
result = subprocess.run(
[self.winetricks_path, '--version'],
capture_output=True,
text=True,
env=env,
timeout=10
)
if result.returncode == 0:
self.logger.debug(f"Winetricks version: {result.stdout.strip()}")
return True
else:
self.logger.error(f"Winetricks --version failed: {result.stderr}")
return False
except Exception as e:
self.logger.error(f"Error testing winetricks: {e}")
return False
def install_wine_components(self, wineprefix: str, game_var: str, specific_components: Optional[List[str]] = None) -> bool:
"""
Install the specified Wine components into the given prefix using winetricks.
If specific_components is None, use the default set (fontsmooth=rgb, xact, xact_x64, vcrun2022).
"""
if not self.is_available():
self.logger.error("Winetricks is not available")
return False
env = os.environ.copy()
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
env['WINEPREFIX'] = wineprefix
env['WINETRICKS_GUI'] = 'none' # Suppress GUI popups
# Less aggressive popup suppression - don't completely disable display
if 'DISPLAY' in env:
# Keep DISPLAY but add window manager hints to prevent focus stealing
env['WINEDLLOVERRIDES'] = 'winemenubuilder.exe=d' # Disable Wine menu integration
else:
# No display available anyway
env['DISPLAY'] = ''
# Force winetricks to use Proton wine binary - NEVER fall back to system wine
try:
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_proton_path()
# If user selected a specific Proton, try that first
wine_binary = None
if user_proton_path != 'auto':
# Check if user-selected Proton still exists
if os.path.exists(user_proton_path):
# Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam
resolved_proton_path = os.path.realpath(user_proton_path)
# Check for wine binary in different Proton structures
valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine')
if os.path.exists(valve_proton_wine):
wine_binary = valve_proton_wine
self.logger.info(f"Using user-selected Proton: {user_proton_path}")
elif os.path.exists(ge_proton_wine):
wine_binary = ge_proton_wine
self.logger.info(f"Using user-selected GE-Proton: {user_proton_path}")
else:
self.logger.warning(f"User-selected Proton path invalid: {user_proton_path}")
else:
self.logger.warning(f"User-selected Proton no longer exists: {user_proton_path}")
# Fall back to auto-detection if user selection failed or is 'auto'
if not wine_binary:
self.logger.info("Falling back to automatic Proton detection")
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
self.logger.info(f"Auto-selected Proton: {best_proton['name']} at {best_proton['path']}")
else:
# Enhanced debugging for Proton detection failure
self.logger.error("Auto-detection failed - no Proton versions found")
available_versions = WineUtils.scan_all_proton_versions()
if available_versions:
self.logger.error(f"Available Proton versions: {[v['name'] for v in available_versions]}")
else:
self.logger.error("No Proton versions detected in standard Steam locations")
if not wine_binary:
self.logger.error("Cannot run winetricks: No compatible Proton version found")
self.logger.error("Please ensure you have Proton 9+ or GE-Proton installed through Steam")
return False
if not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)):
self.logger.error(f"Cannot run winetricks: Wine binary not found or not executable: {wine_binary}")
return False
env['WINE'] = str(wine_binary)
self.logger.info(f"Using Proton wine binary for winetricks: {wine_binary}")
# CRITICAL: Set up protontricks-compatible environment
proton_dist_path = os.path.dirname(os.path.dirname(wine_binary)) # e.g., /path/to/proton/dist/bin/wine -> /path/to/proton/dist
self.logger.debug(f"Proton dist path: {proton_dist_path}")
# Set WINEDLLPATH like protontricks does
env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine"
# Ensure Proton bin directory is first in PATH
env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}"
# Set DLL overrides exactly like protontricks
dll_overrides = {
"beclient": "b,n",
"beclient_x64": "b,n",
"dxgi": "n",
"d3d9": "n",
"d3d10core": "n",
"d3d11": "n",
"d3d12": "n",
"d3d12core": "n",
"nvapi": "n",
"nvapi64": "n",
"nvofapi64": "n",
"nvcuda": "b"
}
# Merge with existing overrides
existing_overrides = env.get('WINEDLLOVERRIDES', '')
if existing_overrides:
# Parse existing overrides
for override in existing_overrides.split(';'):
if '=' in override:
name, value = override.split('=', 1)
dll_overrides[name] = value
env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items())
# Set Wine defaults from protontricks
env['WINE_LARGE_ADDRESS_AWARE'] = '1'
env['DXVK_ENABLE_NVAPI'] = '1'
self.logger.debug(f"Set protontricks environment: WINEDLLPATH={env['WINEDLLPATH']}")
except Exception as e:
self.logger.error(f"Cannot run winetricks: Failed to get Proton wine binary: {e}")
return False
# Set up bundled cabextract for winetricks
bundled_cabextract = self._get_bundled_cabextract()
if bundled_cabextract:
env['PATH'] = f"{os.path.dirname(bundled_cabextract)}:{env.get('PATH', '')}"
self.logger.info(f"Using bundled cabextract: {bundled_cabextract}")
else:
self.logger.warning("Bundled cabextract not found, relying on system PATH")
# Set winetricks cache to jackify_data_dir for self-containment
from jackify.shared.paths import get_jackify_data_dir
jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache'
jackify_cache_dir.mkdir(parents=True, exist_ok=True)
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
if specific_components is not None:
all_components = specific_components
self.logger.info(f"Installing specific components: {all_components}")
else:
all_components = ["fontsmooth=rgb", "xact", "xact_x64", "vcrun2022"]
self.logger.info(f"Installing default components: {all_components}")
if not all_components:
self.logger.info("No Wine components to install.")
return True
# Reorder components for proper installation sequence
components_to_install = self._reorder_components_for_installation(all_components)
self.logger.info(f"WINEPREFIX: {wineprefix}, Game: {game_var}, Ordered Components: {components_to_install}")
# Check user preference for component installation method
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
use_winetricks = config_handler.get('use_winetricks_for_components', True)
# Legacy .NET Framework versions that are problematic in Wine/Proton
# DISABLED in v0.1.6.2: Universal registry fixes replace dotnet4.x installation
# legacy_dotnet_versions = ['dotnet40', 'dotnet472', 'dotnet48']
legacy_dotnet_versions = [] # ALL dotnet4.x versions disabled - universal registry fixes handle compatibility
# Check if any legacy .NET Framework versions are present
has_legacy_dotnet = any(comp in components_to_install for comp in legacy_dotnet_versions)
# Choose installation method based on user preference and components
# HYBRID APPROACH MOSTLY DISABLED: dotnet40/dotnet472 replaced with universal registry fixes
if has_legacy_dotnet:
legacy_found = [comp for comp in legacy_dotnet_versions if comp in components_to_install]
self.logger.info(f"Using hybrid approach: protontricks for legacy .NET versions {legacy_found} (reliable), {'winetricks' if use_winetricks else 'protontricks'} for other components")
return self._install_components_hybrid_approach(components_to_install, wineprefix, game_var, use_winetricks)
elif not use_winetricks:
self.logger.info("Using legacy approach: protontricks for all components")
return self._install_components_protontricks_only(components_to_install, wineprefix, game_var)
# For non-dotnet40 installations, install all components together (faster)
max_attempts = 3
for attempt in range(1, max_attempts + 1):
if attempt > 1:
self.logger.warning(f"Retrying component installation (attempt {attempt}/{max_attempts})...")
self._cleanup_wine_processes()
try:
# Build winetricks command - using --unattended for silent installation
cmd = [self.winetricks_path, '--unattended'] + components_to_install
self.logger.debug(f"Running: {' '.join(cmd)}")
self.logger.debug(f"Environment WINE={env.get('WINE', 'NOT SET')}")
self.logger.debug(f"Environment DISPLAY={env.get('DISPLAY', 'NOT SET')}")
self.logger.debug(f"Environment WINEPREFIX={env.get('WINEPREFIX', 'NOT SET')}")
result = subprocess.run(
cmd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=600
)
self.logger.debug(f"Winetricks output: {result.stdout}")
if result.returncode == 0:
self.logger.info("Wine Component installation command completed successfully.")
# Set Windows 10 mode after component installation (matches legacy script timing)
self._set_windows_10_mode(wineprefix, env.get('WINE', ''))
return True
else:
# Special handling for dotnet40 verification issue (mimics protontricks behavior)
if "dotnet40" in components_to_install and "ngen.exe not found" in result.stderr:
self.logger.warning("dotnet40 verification warning (common in Steam Proton prefixes)")
self.logger.info("Checking if dotnet40 was actually installed...")
# Check if dotnet40 appears in winetricks.log (indicates successful installation)
log_path = os.path.join(wineprefix, 'winetricks.log')
if os.path.exists(log_path):
try:
with open(log_path, 'r') as f:
log_content = f.read()
if 'dotnet40' in log_content:
self.logger.info("dotnet40 found in winetricks.log - installation succeeded despite verification warning")
return True
except Exception as e:
self.logger.warning(f"Could not read winetricks.log: {e}")
self.logger.error(f"Winetricks command failed (Attempt {attempt}/{max_attempts}). Return Code: {result.returncode}")
self.logger.error(f"Stdout: {result.stdout.strip()}")
self.logger.error(f"Stderr: {result.stderr.strip()}")
except Exception as e:
self.logger.error(f"Error during winetricks run (Attempt {attempt}/{max_attempts}): {e}", exc_info=True)
self.logger.error(f"Failed to install Wine components after {max_attempts} attempts.")
return False
def _reorder_components_for_installation(self, components: list) -> list:
"""
Reorder components for proper installation sequence.
Critical: dotnet40 must be installed before dotnet6/dotnet7 to avoid conflicts.
"""
# Simple reordering: dotnet40 first, then everything else
reordered = []
# Add dotnet40 first if it exists
if "dotnet40" in components:
reordered.append("dotnet40")
# Add all other components in original order
for component in components:
if component != "dotnet40":
reordered.append(component)
if reordered != components:
self.logger.info(f"Reordered for dotnet40 compatibility: {reordered}")
return reordered
def _prepare_prefix_for_dotnet(self, wineprefix: str, wine_binary: str) -> bool:
"""
Prepare the Wine prefix for .NET installation by mimicking protontricks preprocessing.
This removes mono components and specific symlinks that interfere with .NET installation.
"""
try:
env = os.environ.copy()
env['WINEDEBUG'] = '-all'
env['WINEPREFIX'] = wineprefix
# Step 1: Remove mono components (mimics protontricks behavior)
self.logger.info("Preparing prefix for .NET installation: removing mono")
mono_result = subprocess.run([
self.winetricks_path,
'-q',
'remove_mono'
], env=env, capture_output=True, text=True, timeout=300)
if mono_result.returncode != 0:
self.logger.warning(f"Mono removal warning (non-critical): {mono_result.stderr}")
# Step 2: Set Windows version to XP (protontricks uses winxp for dotnet40)
self.logger.info("Setting Windows version to XP for .NET compatibility")
winxp_result = subprocess.run([
self.winetricks_path,
'-q',
'winxp'
], env=env, capture_output=True, text=True, timeout=300)
if winxp_result.returncode != 0:
self.logger.warning(f"Windows XP setting warning: {winxp_result.stderr}")
# Step 3: Remove mscoree.dll symlinks (critical for .NET installation)
self.logger.info("Removing problematic mscoree.dll symlinks")
dosdevices_path = os.path.join(wineprefix, 'dosdevices', 'c:')
mscoree_paths = [
os.path.join(dosdevices_path, 'windows', 'syswow64', 'mscoree.dll'),
os.path.join(dosdevices_path, 'windows', 'system32', 'mscoree.dll')
]
for dll_path in mscoree_paths:
if os.path.exists(dll_path) or os.path.islink(dll_path):
try:
os.remove(dll_path)
self.logger.debug(f"Removed symlink: {dll_path}")
except Exception as e:
self.logger.warning(f"Could not remove {dll_path}: {e}")
self.logger.info("Prefix preparation complete for .NET installation")
return True
except Exception as e:
self.logger.error(f"Error preparing prefix for .NET: {e}")
return False
def _install_components_separately(self, components: list, wineprefix: str, wine_binary: str, base_env: dict) -> bool:
"""
Install components separately like protontricks does.
This is necessary when dotnet40 is present to avoid component conflicts.
"""
self.logger.info(f"Installing {len(components)} components separately (protontricks style)")
for i, component in enumerate(components, 1):
self.logger.info(f"Installing component {i}/{len(components)}: {component}")
# Prepare environment for this component
env = base_env.copy()
# Special preprocessing for dotnet40 only
if component == "dotnet40":
self.logger.info("Applying dotnet40 preprocessing")
if not self._prepare_prefix_for_dotnet(wineprefix, wine_binary):
self.logger.error("Failed to prepare prefix for dotnet40")
return False
else:
# For non-dotnet40 components, install in standard mode (Windows 10 will be set after all components)
self.logger.debug(f"Installing {component} in standard mode")
# Install this component
max_attempts = 3
component_success = False
for attempt in range(1, max_attempts + 1):
if attempt > 1:
self.logger.warning(f"Retrying {component} installation (attempt {attempt}/{max_attempts})")
self._cleanup_wine_processes()
try:
cmd = [self.winetricks_path, '--unattended', component]
env['WINEPREFIX'] = wineprefix
env['WINE'] = wine_binary
self.logger.debug(f"Running: {' '.join(cmd)}")
result = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=600
)
if result.returncode == 0:
self.logger.info(f"{component} installed successfully")
component_success = True
break
else:
# Special handling for dotnet40 verification issue
if component == "dotnet40" and "ngen.exe not found" in result.stderr:
self.logger.warning("dotnet40 verification warning (expected in Steam Proton)")
# Check winetricks.log for actual success
log_path = os.path.join(wineprefix, 'winetricks.log')
if os.path.exists(log_path):
try:
with open(log_path, 'r') as f:
if 'dotnet40' in f.read():
self.logger.info("dotnet40 confirmed in winetricks.log")
component_success = True
break
except Exception as e:
self.logger.warning(f"Could not read winetricks.log: {e}")
self.logger.error(f"{component} failed (attempt {attempt}): {result.stderr.strip()}")
self.logger.debug(f"Full stdout for {component}: {result.stdout.strip()}")
except Exception as e:
self.logger.error(f"Error installing {component} (attempt {attempt}): {e}")
if not component_success:
self.logger.error(f"Failed to install {component} after {max_attempts} attempts")
return False
self.logger.info("All components installed successfully using separate sessions")
# Set Windows 10 mode after all component installation (matches legacy script timing)
self._set_windows_10_mode(wineprefix, env.get('WINE', ''))
return True
def _install_components_hybrid_approach(self, components: list, wineprefix: str, game_var: str, use_winetricks: bool = True) -> bool:
"""
Hybrid approach: Install legacy .NET Framework versions with protontricks (reliable),
then install remaining components with winetricks OR protontricks based on user preference.
Args:
components: List of all components to install
wineprefix: Wine prefix path
game_var: Game variable for AppID detection
use_winetricks: Whether to use winetricks for non-legacy components
Returns:
bool: True if all installations succeeded, False otherwise
"""
self.logger.info("Starting hybrid installation approach")
# Legacy .NET Framework versions that need protontricks
legacy_dotnet_versions = ['dotnet40', 'dotnet472', 'dotnet48']
# Separate legacy .NET (protontricks) from other components (winetricks)
protontricks_components = [comp for comp in components if comp in legacy_dotnet_versions]
other_components = [comp for comp in components if comp not in legacy_dotnet_versions]
self.logger.info(f"Protontricks components: {protontricks_components}")
self.logger.info(f"Other components: {other_components}")
# Step 1: Install legacy .NET Framework versions with protontricks if present
if protontricks_components:
self.logger.info(f"Installing legacy .NET versions {protontricks_components} using protontricks...")
if not self._install_legacy_dotnet_with_protontricks(protontricks_components, wineprefix, game_var):
self.logger.error(f"Failed to install {protontricks_components} with protontricks")
return False
self.logger.info(f"{protontricks_components} installation completed successfully with protontricks")
# Step 2: Install remaining components if any
if other_components:
if use_winetricks:
self.logger.info(f"Installing remaining components with winetricks: {other_components}")
# Use existing winetricks logic for other components
env = self._prepare_winetricks_environment(wineprefix)
if not env:
return False
return self._install_components_with_winetricks(other_components, wineprefix, env)
else:
self.logger.info(f"Installing remaining components with protontricks: {other_components}")
return self._install_components_protontricks_only(other_components, wineprefix, game_var)
self.logger.info("Hybrid component installation completed successfully")
# Set Windows 10 mode after all component installation (matches legacy script timing)
wine_binary = self._get_wine_binary_for_prefix(wineprefix)
self._set_windows_10_mode(wineprefix, wine_binary)
return True
def _install_legacy_dotnet_with_protontricks(self, legacy_components: list, wineprefix: str, game_var: str) -> bool:
"""
Install legacy .NET Framework versions using protontricks (known to work more reliably).
Args:
legacy_components: List of legacy .NET components to install (dotnet40, dotnet472, dotnet48)
wineprefix: Wine prefix path
game_var: Game variable for AppID detection
Returns:
bool: True if installation succeeded, False otherwise
"""
try:
# Extract AppID from wineprefix path (e.g., /path/to/compatdata/123456789/pfx -> 123456789)
appid = None
if 'compatdata' in wineprefix:
# Standard Steam compatdata structure
path_parts = Path(wineprefix).parts
for i, part in enumerate(path_parts):
if part == 'compatdata' and i + 1 < len(path_parts):
potential_appid = path_parts[i + 1]
if potential_appid.isdigit():
appid = potential_appid
break
if not appid:
self.logger.error(f"Could not extract AppID from wineprefix path: {wineprefix}")
return False
self.logger.info(f"Using AppID {appid} for protontricks dotnet40 installation")
# Import and use protontricks handler
from .protontricks_handler import ProtontricksHandler
# Determine if we're on Steam Deck (for protontricks handler)
steamdeck = os.path.exists('/home/deck')
protontricks_handler = ProtontricksHandler(steamdeck, logger=self.logger)
# Detect protontricks availability
if not protontricks_handler.detect_protontricks():
self.logger.error(f"Protontricks not available for legacy .NET installation: {legacy_components}")
return False
# Install legacy .NET components using protontricks
success = protontricks_handler.install_wine_components(appid, game_var, legacy_components)
if success:
self.logger.info(f"Legacy .NET components {legacy_components} installed successfully with protontricks")
# Enable dotfiles and symlinks for the prefix
if protontricks_handler.enable_dotfiles(appid):
self.logger.info("Enabled dotfiles and symlinks support")
else:
self.logger.warning("Failed to enable dotfiles/symlinks (non-critical)")
return True
else:
self.logger.error(f"Legacy .NET components {legacy_components} installation failed with protontricks")
return False
except Exception as e:
self.logger.error(f"Error installing legacy .NET components {legacy_components} with protontricks: {e}", exc_info=True)
return False
def _prepare_winetricks_environment(self, wineprefix: str) -> Optional[dict]:
"""
Prepare the environment for winetricks installation.
This reuses the existing environment setup logic.
Args:
wineprefix: Wine prefix path
Returns:
dict: Environment variables for winetricks, or None if failed
"""
try:
env = os.environ.copy()
env['WINEDEBUG'] = '-all'
env['WINEPREFIX'] = wineprefix
env['WINETRICKS_GUI'] = 'none'
# Existing Proton detection logic
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_proton_path()
wine_binary = None
if user_proton_path != 'auto':
if os.path.exists(user_proton_path):
resolved_proton_path = os.path.realpath(user_proton_path)
valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine')
if os.path.exists(valve_proton_wine):
wine_binary = valve_proton_wine
elif os.path.exists(ge_proton_wine):
wine_binary = ge_proton_wine
if not wine_binary:
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
if not wine_binary or not (os.path.exists(wine_binary) and os.access(wine_binary, os.X_OK)):
self.logger.error(f"Cannot prepare winetricks environment: No compatible Proton found")
return None
env['WINE'] = str(wine_binary)
# Set up protontricks-compatible environment (existing logic)
proton_dist_path = os.path.dirname(os.path.dirname(wine_binary))
env['WINEDLLPATH'] = f"{proton_dist_path}/lib64/wine:{proton_dist_path}/lib/wine"
env['PATH'] = f"{proton_dist_path}/bin:{env.get('PATH', '')}"
# Existing DLL overrides
dll_overrides = {
"beclient": "b,n", "beclient_x64": "b,n", "dxgi": "n", "d3d9": "n",
"d3d10core": "n", "d3d11": "n", "d3d12": "n", "d3d12core": "n",
"nvapi": "n", "nvapi64": "n", "nvofapi64": "n", "nvcuda": "b"
}
env['WINEDLLOVERRIDES'] = ';'.join(f"{name}={setting}" for name, setting in dll_overrides.items())
env['WINE_LARGE_ADDRESS_AWARE'] = '1'
env['DXVK_ENABLE_NVAPI'] = '1'
# Set up winetricks cache
from jackify.shared.paths import get_jackify_data_dir
jackify_cache_dir = get_jackify_data_dir() / 'winetricks_cache'
jackify_cache_dir.mkdir(parents=True, exist_ok=True)
env['WINETRICKS_CACHE'] = str(jackify_cache_dir)
return env
except Exception as e:
self.logger.error(f"Failed to prepare winetricks environment: {e}")
return None
def _install_components_with_winetricks(self, components: list, wineprefix: str, env: dict) -> bool:
"""
Install components using winetricks with the prepared environment.
Args:
components: List of components to install
wineprefix: Wine prefix path
env: Prepared environment variables
Returns:
bool: True if installation succeeded, False otherwise
"""
max_attempts = 3
for attempt in range(1, max_attempts + 1):
if attempt > 1:
self.logger.warning(f"Retrying winetricks installation (attempt {attempt}/{max_attempts})")
self._cleanup_wine_processes()
try:
cmd = [self.winetricks_path, '--unattended'] + components
self.logger.debug(f"Running winetricks: {' '.join(cmd)}")
result = subprocess.run(
cmd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=600
)
if result.returncode == 0:
self.logger.info(f"Winetricks components installed successfully: {components}")
# Set Windows 10 mode after component installation (matches legacy script timing)
wine_binary = env.get('WINE', '')
self._set_windows_10_mode(env.get('WINEPREFIX', ''), wine_binary)
return True
else:
self.logger.error(f"Winetricks failed (attempt {attempt}): {result.stderr.strip()}")
except Exception as e:
self.logger.error(f"Error during winetricks run (attempt {attempt}): {e}")
self.logger.error(f"Failed to install components with winetricks after {max_attempts} attempts")
return False
def _set_windows_10_mode(self, wineprefix: str, wine_binary: str):
"""
Set Windows 10 mode for the prefix after component installation (matches legacy script timing).
This should be called AFTER all Wine components are installed, not before.
"""
try:
env = os.environ.copy()
env['WINEPREFIX'] = wineprefix
env['WINE'] = wine_binary
self.logger.info("Setting Windows 10 mode after component installation (matching legacy script)")
result = subprocess.run([
self.winetricks_path, '-q', 'win10'
], env=env, capture_output=True, text=True, timeout=300)
if result.returncode == 0:
self.logger.info("Windows 10 mode set successfully")
else:
self.logger.warning(f"Could not set Windows 10 mode: {result.stderr}")
except Exception as e:
self.logger.warning(f"Error setting Windows 10 mode: {e}")
def _install_components_protontricks_only(self, components: list, wineprefix: str, game_var: str) -> bool:
"""
Legacy approach: Install all components using protontricks only.
This matches the behavior of the original bash script.
"""
try:
self.logger.info(f"Installing all components with protontricks (legacy method): {components}")
# Import protontricks handler
from ..handlers.protontricks_handler import ProtontricksHandler
# Determine if we're on Steam Deck (for protontricks handler)
steamdeck = os.path.exists('/home/deck')
protontricks_handler = ProtontricksHandler(steamdeck, logger=self.logger)
# Get AppID from wineprefix
appid = self._extract_appid_from_wineprefix(wineprefix)
if not appid:
self.logger.error("Could not extract AppID from wineprefix for protontricks installation")
return False
self.logger.info(f"Using AppID {appid} for protontricks installation")
# Detect protontricks availability
if not protontricks_handler.detect_protontricks():
self.logger.error("Protontricks not available for component installation")
return False
# Install all components using protontricks
success = protontricks_handler.install_wine_components(appid, game_var, components)
if success:
self.logger.info("All components installed successfully with protontricks")
# Set Windows 10 mode after component installation
wine_binary = self._get_wine_binary_for_prefix(wineprefix)
self._set_windows_10_mode(wineprefix, wine_binary)
return True
else:
self.logger.error("Component installation failed with protontricks")
return False
except Exception as e:
self.logger.error(f"Error installing components with protontricks: {e}", exc_info=True)
return False
def _extract_appid_from_wineprefix(self, wineprefix: str) -> Optional[str]:
"""
Extract AppID from wineprefix path.
Args:
wineprefix: Wine prefix path
Returns:
AppID as string, or None if extraction fails
"""
try:
if 'compatdata' in wineprefix:
# Standard Steam compatdata structure
path_parts = Path(wineprefix).parts
for i, part in enumerate(path_parts):
if part == 'compatdata' and i + 1 < len(path_parts):
potential_appid = path_parts[i + 1]
if potential_appid.isdigit():
return potential_appid
self.logger.error(f"Could not extract AppID from wineprefix path: {wineprefix}")
return None
except Exception as e:
self.logger.error(f"Error extracting AppID from wineprefix: {e}")
return None
def _get_wine_binary_for_prefix(self, wineprefix: str) -> str:
"""
Get the wine binary path for a given prefix.
Args:
wineprefix: Wine prefix path
Returns:
Wine binary path as string
"""
try:
from ..handlers.config_handler import ConfigHandler
from ..handlers.wine_utils import WineUtils
config = ConfigHandler()
user_proton_path = config.get_proton_path()
# If user selected a specific Proton, try that first
wine_binary = None
if user_proton_path != 'auto':
if os.path.exists(user_proton_path):
resolved_proton_path = os.path.realpath(user_proton_path)
valve_proton_wine = os.path.join(resolved_proton_path, 'dist', 'bin', 'wine')
ge_proton_wine = os.path.join(resolved_proton_path, 'files', 'bin', 'wine')
if os.path.exists(valve_proton_wine):
wine_binary = valve_proton_wine
elif os.path.exists(ge_proton_wine):
wine_binary = ge_proton_wine
# Fall back to auto-detection if user selection failed or is 'auto'
if not wine_binary:
best_proton = WineUtils.select_best_proton()
if best_proton:
wine_binary = WineUtils.find_proton_binary(best_proton['name'])
return wine_binary if wine_binary else ""
except Exception as e:
self.logger.error(f"Error getting wine binary for prefix: {e}")
return ""
def _cleanup_wine_processes(self):
"""
Internal method to clean up wine processes during component installation
Only cleanup winetricks processes - NEVER kill all wine processes
"""
try:
# Only cleanup winetricks processes - do NOT kill other wine apps
subprocess.run("pkill -f winetricks", shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.logger.debug("Cleaned up winetricks processes only")
except Exception as e:
self.logger.error(f"Error cleaning up winetricks processes: {e}")

View File

@@ -38,6 +38,65 @@ class AutomatedPrefixService:
"""Get consistent progress timestamp""" """Get consistent progress timestamp"""
from jackify.shared.timing import get_timestamp from jackify.shared.timing import get_timestamp
return 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, 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}") logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}")
launch_options = "%command%" 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 # Create shortcut with Proton using native service
success, app_id = steam_service.create_shortcut_with_proton( success, app_id = steam_service.create_shortcut_with_proton(
app_name=shortcut_name, app_name=shortcut_name,
@@ -94,14 +156,14 @@ class AutomatedPrefixService:
start_dir=modlist_install_dir, start_dir=modlist_install_dir,
launch_options=launch_options, launch_options=launch_options,
tags=["Jackify"], tags=["Jackify"],
proton_version="proton_experimental" proton_version=proton_version
) )
if success and app_id: if success and app_id:
logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}") logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}")
return True, app_id return True, app_id
else: else:
logger.error("Native Steam service failed to create shortcut") logger.error("Native Steam service failed to create shortcut")
return False, None return False, None
except Exception as e: except Exception as e:
@@ -292,15 +354,18 @@ class AutomatedPrefixService:
logger.error(f"Steam userdata directory not found: {userdata_dir}") logger.error(f"Steam userdata directory not found: {userdata_dir}")
return None return None
# Find the first user directory (most systems have only one user) # Use NativeSteamService for proper user detection
user_dirs = [d for d in userdata_dir.iterdir() if d.is_dir() and d.name.isdigit()] from ..services.native_steam_service import NativeSteamService
if not user_dirs: steam_service = NativeSteamService()
logger.error("No Steam user directories found in userdata")
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 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}") logger.debug(f"Looking for shortcuts.vdf at: {shortcuts_path}")
if not shortcuts_path.exists(): if not shortcuts_path.exists():
@@ -407,7 +472,7 @@ exit"""
if shortcut_name in name: if shortcut_name in name:
appid = shortcut.get('appid') appid = shortcut.get('appid')
exe_path = shortcut.get('Exe', '') exe_path = shortcut.get('Exe', '').strip('"')
logger.info(f"Found shortcut: {name}") logger.info(f"Found shortcut: {name}")
logger.info(f" AppID: {appid}") logger.info(f" AppID: {appid}")
@@ -443,7 +508,7 @@ exit"""
try: try:
# Use the existing protontricks handler # Use the existing protontricks handler
from jackify.backend.handlers.protontricks_handler import ProtontricksHandler 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') result = protontricks_handler.run_protontricks('-l')
if result.returncode == 0: if result.returncode == 0:
@@ -471,7 +536,7 @@ exit"""
logger.warning(f"Error running protontricks -l on attempt {i+1}: {e}") logger.warning(f"Error running protontricks -l on attempt {i+1}: {e}")
time.sleep(1) time.sleep(1)
logger.error(f"Shortcut '{shortcut_name}' not found in protontricks after 30 seconds") logger.error(f"Shortcut '{shortcut_name}' not found in protontricks after 30 seconds")
return None return None
except Exception as e: except Exception as e:
@@ -939,7 +1004,7 @@ echo Prefix creation complete.
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.TimeoutExpired): except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.TimeoutExpired):
continue continue
logger.info(" No more processes to kill") logger.info("No more processes to kill")
return True return True
except Exception as e: except Exception as e:
@@ -1296,7 +1361,7 @@ echo Prefix creation complete.
time.sleep(1) time.sleep(1)
logger.warning(f"Timeout waiting for prefix completion after {timeout} seconds") logger.warning(f"Timeout waiting for prefix completion after {timeout} seconds")
return False return False
except Exception as e: except Exception as e:
@@ -1356,7 +1421,7 @@ echo Prefix creation complete.
if killed_count > 0: if killed_count > 0:
logger.info(f" Killed {killed_count} ModOrganizer processes") logger.info(f" Killed {killed_count} ModOrganizer processes")
else: else:
logger.warning("No ModOrganizer processes found to kill") logger.warning("No ModOrganizer processes found to kill")
return killed_count return killed_count
@@ -1512,6 +1577,9 @@ echo Prefix creation complete.
if progress_callback: if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Steam Configuration complete!") 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") logger.info(" Simple automated prefix creation workflow completed successfully")
return True, prefix_path, actual_appid return True, prefix_path, actual_appid
@@ -1624,11 +1692,11 @@ echo Prefix creation complete.
return True return True
logger.error(f"Shortcut '{shortcut_name}' not found for CompatTool setting") logger.error(f"Shortcut '{shortcut_name}' not found for CompatTool setting")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error setting CompatTool on shortcut: {e}") logger.error(f"Error setting CompatTool on shortcut: {e}")
return False return False
def _set_proton_on_shortcut(self, shortcut_name: str) -> bool: def _set_proton_on_shortcut(self, shortcut_name: str) -> bool:
@@ -1718,21 +1786,15 @@ echo Prefix creation complete.
progress_callback("=== Steam Integration ===") progress_callback("=== Steam Integration ===")
progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service") progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service")
# Dual approach: Registry injection for FNV, launch options for Enderal # Registry injection approach for both FNV and Enderal
from ..handlers.modlist_handler import ModlistHandler from ..handlers.modlist_handler import ModlistHandler
modlist_handler = ModlistHandler() modlist_handler = ModlistHandler()
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir) special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
# Generate launch options only for Enderal (FNV uses registry injection) # No launch options needed - both FNV and Enderal use registry injection
custom_launch_options = None custom_launch_options = None
if special_game_type == "enderal": if special_game_type in ["fnv", "enderal"]:
custom_launch_options = self._generate_special_game_launch_options(special_game_type, modlist_install_dir) logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
if not custom_launch_options:
logger.error(f"Failed to generate launch options for Enderal modlist")
return False, None, None, None
logger.info("Using launch options approach for Enderal modlist")
elif special_game_type == "fnv":
logger.info("Using registry injection approach for FNV modlist")
else: else:
logger.debug("Standard modlist - no special game handling needed") logger.debug("Standard modlist - no special game handling needed")
@@ -1808,23 +1870,19 @@ echo Prefix creation complete.
if progress_callback: if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Setup verification completed") progress_callback(f"{self._get_progress_timestamp()} Setup verification completed")
# Step 5: Inject game registry entries for FNV modlists (Enderal uses launch options) # Step 5: Inject game registry entries for FNV and Enderal modlists
# Get prefix path (needed for logging regardless of game type) # Get prefix path (needed for logging regardless of game type)
prefix_path = self.get_prefix_path(appid) prefix_path = self.get_prefix_path(appid)
if special_game_type == "fnv": if special_game_type in ["fnv", "enderal"]:
logger.info("Step 5: Injecting FNV game registry entries") logger.info(f"Step 5: Injecting {special_game_type.upper()} game registry entries")
if progress_callback: if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Injecting FNV game registry entries...") progress_callback(f"{self._get_progress_timestamp()} Injecting {special_game_type.upper()} game registry entries...")
if prefix_path: if prefix_path:
self._inject_game_registry_entries(str(prefix_path)) self._inject_game_registry_entries(str(prefix_path))
else: else:
logger.warning("Could not find prefix path for registry injection") logger.warning("Could not find prefix path for registry injection")
elif special_game_type == "enderal":
logger.info("Step 5: Skipping registry injection for Enderal (using launch options)")
if progress_callback:
progress_callback(f"{self._get_progress_timestamp()} Skipping registry injection for Enderal")
else: else:
logger.info("Step 5: Skipping registry injection for standard modlist") logger.info("Step 5: Skipping registry injection for standard modlist")
if progress_callback: if progress_callback:
@@ -1835,6 +1893,11 @@ echo Prefix creation complete.
if progress_callback: if progress_callback:
progress_callback(f"{last_timestamp} Steam integration complete") progress_callback(f"{last_timestamp} Steam integration complete")
progress_callback("") # Blank line after 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("") # Extra blank line to span across Configuration Summary
progress_callback("") # And one more to create space before Prefix Configuration progress_callback("") # And one more to create space before Prefix Configuration
@@ -2496,15 +2559,31 @@ echo Prefix creation complete.
Returns: Returns:
Path to localconfig.vdf or None if not found Path to localconfig.vdf or None if not found
""" """
# Try the standard Steam userdata path # Use NativeSteamService for proper user detection
steam_userdata_path = Path.home() / ".steam" / "steam" / "userdata" try:
if steam_userdata_path.exists(): from ..services.native_steam_service import NativeSteamService
# Find the first user directory (usually only one on Steam Deck) steam_service = NativeSteamService()
user_dirs = [d for d in steam_userdata_path.iterdir() if d.is_dir() and d.name.isdigit()]
if user_dirs: if steam_service.find_steam_user():
localconfig_path = user_dirs[0] / "config" / "localconfig.vdf" localconfig_path = steam_service.user_config_path / "localconfig.vdf"
if localconfig_path.exists(): if localconfig_path.exists():
return str(localconfig_path) 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") logger.error("Could not find localconfig.vdf")
return None return None
@@ -2601,8 +2680,11 @@ echo Prefix creation complete.
env = os.environ.copy() env = os.environ.copy()
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root) env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root)
env['STEAM_COMPAT_DATA_PATH'] = str(compatdata_dir / str(abs(appid))) 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['DISPLAY'] = ''
env['WAYLAND_DISPLAY'] = ''
env['WINEDEBUG'] = '-all'
env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d'
# Create the compatdata directory # Create the compatdata directory
compat_dir = compatdata_dir / str(abs(appid)) compat_dir = compatdata_dir / str(abs(appid))
@@ -2615,8 +2697,19 @@ echo Prefix creation complete.
# Run proton run wineboot -u to initialize the prefix # Run proton run wineboot -u to initialize the prefix
cmd = [str(proton_path), 'run', 'wineboot', '-u'] cmd = [str(proton_path), 'run', 'wineboot', '-u']
logger.info(f"Running: {' '.join(cmd)}") 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}") logger.info(f"Proton exit code: {result.returncode}")
if result.stdout: if result.stdout:
@@ -2633,7 +2726,7 @@ echo Prefix creation complete.
logger.info(f" Proton prefix created at: {pfx}") logger.info(f" Proton prefix created at: {pfx}")
return True return True
else: else:
logger.warning(f"⚠️ Proton prefix not found at: {pfx}") logger.warning(f"Proton prefix not found at: {pfx}")
return False return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -2644,31 +2737,64 @@ echo Prefix creation complete.
return False return False
def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]: def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]:
"""Locate a Proton wrapper script to use (prefer Experimental).""" """Locate a Proton wrapper script to use, respecting user's configuration."""
candidates = [] try:
preferred = [ from jackify.backend.handlers.config_handler import ConfigHandler
"Proton - Experimental", from jackify.backend.handlers.wine_utils import WineUtils
"Proton 9.0",
"Proton 8.0", config = ConfigHandler()
"Proton Hotfix", user_proton_path = config.get_game_proton_path()
]
# If user selected a specific Proton, try that first
for name in preferred: if user_proton_path != 'auto':
p = proton_common_dir / name / "proton" # Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam
if p.exists(): resolved_proton_path = os.path.realpath(user_proton_path)
candidates.append(p)
# Check for wine binary in different Proton structures
# As a fallback, scan all Proton* dirs valve_proton_wine = Path(resolved_proton_path) / "dist" / "bin" / "wine"
if not candidates and proton_common_dir.exists(): ge_proton_wine = Path(resolved_proton_path) / "files" / "bin" / "wine"
for p in proton_common_dir.glob("Proton*/proton"):
candidates.append(p) if valve_proton_wine.exists() or ge_proton_wine.exists():
# Found user's Proton, now find the proton wrapper script
if not candidates: proton_wrapper = Path(resolved_proton_path) / "proton"
logger.error("No Proton wrapper found under steamapps/common") 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 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]]: 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: 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: Args:
appid: The AppID to check appid: The AppID to check
Returns: Returns:
True if compatibility tool is set, False otherwise True if compatibility tool is correctly set, False otherwise
""" """
try: try:
config_path = Path.home() / ".steam/steam/config/config.vdf" config_path = Path.home() / ".steam/steam/config/config.vdf"
with open(config_path, 'r') as f: if not config_path.exists():
content = f.read() logger.warning("Steam config.vdf not found")
if f'"{appid}"' in content:
logger.info(" Compatibility tool persists")
return True
else:
logger.warning("⚠️ Compatibility tool not found")
return False return False
with open(config_path, 'r', encoding='utf-8') as f:
content = f.read()
# Check if AppID exists and has a Proton version set
if f'"{appid}"' in content:
# Get the expected Proton version
expected_proton = self._get_user_proton_version()
# Look for the Proton version in the compatibility tool mapping
if expected_proton in content:
logger.info(f" Compatibility tool persists: {expected_proton}")
return True
else:
logger.warning(f"AppID {appid} found but Proton version '{expected_proton}' not set")
return False
else:
logger.warning("Compatibility tool not found")
return False
except Exception as e: except Exception as e:
logger.error(f"Error verifying compatibility tool: {e}") logger.error(f"Error verifying compatibility tool: {e}")
return False return False
@@ -2851,14 +2990,115 @@ echo Prefix creation complete.
logger.error(f"Failed to update registry path: {e}") logger.error(f"Failed to update registry path: {e}")
return False 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)
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)
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): 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") system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg")
if not os.path.exists(system_reg_path): if not os.path.exists(system_reg_path):
logger.warning("system.reg not found, skipping game path injection") logger.warning("system.reg not found, skipping game path injection")
return 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 # Game configurations
games_config = { games_config = {
@@ -2889,10 +3129,107 @@ echo Prefix creation complete.
) )
if success: if success:
logger.info(f"Updated registry entry for {config['name']}") logger.info(f"Updated registry entry for {config['name']}")
# Special handling for Enderal: Create required user directory
if app_id == "976620": # Enderal Special Edition
try:
enderal_docs_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser", "Documents", "My Games", "Enderal Special Edition")
os.makedirs(enderal_docs_path, exist_ok=True)
logger.info(f"Created Enderal user directory: {enderal_docs_path}")
except Exception as e:
logger.warning(f"Failed to create Enderal user directory: {e}")
else: else:
logger.warning(f"Failed to update registry entry for {config['name']}") logger.warning(f"Failed to update registry entry for {config['name']}")
else: else:
logger.debug(f"{config['name']} not found in Steam libraries") logger.debug(f"{config['name']} not found in Steam libraries")
logger.info("Game registry injection completed") 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.""" """Lazy initialization of modlist handler."""
if self._modlist_handler is None: if self._modlist_handler is None:
from ..handlers.modlist_handler import ModlistHandler from ..handlers.modlist_handler import ModlistHandler
# Initialize with proper dependencies from ..services.platform_detection_service import PlatformDetectionService
self._modlist_handler = ModlistHandler() # 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 return self._modlist_handler
def _get_wabbajack_handler(self): def _get_wabbajack_handler(self):
@@ -293,15 +295,7 @@ class ModlistService:
elif context.get('machineid'): elif context.get('machineid'):
cmd += ['-m', context['machineid']] cmd += ['-m', context['machineid']]
cmd += ['-o', install_dir_str, '-d', download_dir_str] cmd += ['-o', install_dir_str, '-d', download_dir_str]
# Check for debug mode and add --debug flag
from ..handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
cmd.append('--debug')
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
# NOTE: API key is passed via environment variable only, not as command line argument # NOTE: API key is passed via environment variable only, not as command line argument
# Store original environment values (copied from working code) # Store original environment values (copied from working code)
@@ -637,8 +631,13 @@ class ModlistService:
'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'), 'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'),
'resolution': getattr(context, 'resolution', None), 'resolution': getattr(context, 'resolution', None),
'skip_confirmation': True, # Service layer should be non-interactive '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 # Run the complete configuration phase
success = modlist_menu.run_modlist_configuration_phase(config_context) 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 pathlib import Path
from typing import Optional, Tuple, Dict, Any, List from typing import Optional, Tuple, Dict, Any, List
from ..handlers.vdf_handler import VDFHandler
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NativeSteamService: class NativeSteamService:
@@ -28,37 +30,153 @@ class NativeSteamService:
""" """
def __init__(self): def __init__(self):
self.steam_path = Path.home() / ".steam" / "steam" self.steam_paths = [
self.userdata_path = self.steam_path / "userdata" 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_id = None
self.user_config_path = None self.user_config_path = None
def find_steam_user(self) -> bool: 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: try:
if not self.userdata_path.exists(): # Step 1: Find Steam installation using Steam's own file structure
logger.error("Steam userdata directory not found") if not self._find_steam_installation():
logger.error("No Steam installation found")
return False return False
# Find the first user directory (usually there's only one) # Step 2: Parse loginusers.vdf to get the most recent user (SteamID64)
user_dirs = [d for d in self.userdata_path.iterdir() if d.is_dir() and d.name.isdigit()] steamid64 = self._get_most_recent_user_from_loginusers()
if not user_dirs: if not steamid64:
logger.error("No Steam user directories found") 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 return False
# Use the first user directory config_dir = user_dir / "config"
user_dir = user_dirs[0] if not config_dir.exists():
self.user_id = user_dir.name logger.error(f"User config directory does not exist: {config_dir}")
self.user_config_path = user_dir / "config" return False
logger.info(f"Found Steam user: {self.user_id}") # 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"User config path: {self.user_config_path}")
logger.info(f"Shortcuts.vdf will be at: {self.user_config_path / 'shortcuts.vdf'}")
return True return True
except Exception as e: 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 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]: def get_shortcuts_vdf_path(self) -> Optional[Path]:
"""Get the path to shortcuts.vdf""" """Get the path to shortcuts.vdf"""
if not self.user_config_path: if not self.user_config_path:
@@ -228,29 +346,35 @@ class NativeSteamService:
# Write back to file # Write back to file
if self.write_shortcuts_vdf(data): if self.write_shortcuts_vdf(data):
logger.info(f"Shortcut created successfully at index {next_index}") logger.info(f"Shortcut created successfully at index {next_index}")
return True, unsigned_app_id return True, unsigned_app_id
else: else:
logger.error("Failed to write shortcut to VDF") logger.error("Failed to write shortcut to VDF")
return False, None return False, None
except Exception as e: except Exception as e:
logger.error(f"Error creating shortcut: {e}") logger.error(f"Error creating shortcut: {e}")
return False, None return False, None
def set_proton_version(self, app_id: int, proton_version: str = "proton_experimental") -> bool: 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. Set the Proton version for a specific app using ONLY config.vdf like steam-conductor does.
Args: Args:
app_id: The unsigned AppID app_id: The unsigned AppID
proton_version: The Proton version to set proton_version: The Proton version to set
Returns: Returns:
True if successful 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") logger.info(f"Setting Proton version '{proton_version}' for AppID {app_id} using STL-compatible format")
try: try:
# Step 1: Write to the main config.vdf for CompatToolMapping # Step 1: Write to the main config.vdf for CompatToolMapping
config_path = self.steam_path / "config" / "config.vdf" config_path = self.steam_path / "config" / "config.vdf"
@@ -272,8 +396,27 @@ class NativeSteamService:
# Find the CompatToolMapping section # Find the CompatToolMapping section
compat_start = config_text.find('"CompatToolMapping"') compat_start = config_text.find('"CompatToolMapping"')
if compat_start == -1: if compat_start == -1:
logger.error("CompatToolMapping section not found in config.vdf") logger.warning("CompatToolMapping section not found in config.vdf, creating it")
return False # 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 # Find the closing brace for CompatToolMapping
# Look for the opening brace after CompatToolMapping # Look for the opening brace after CompatToolMapping
@@ -320,24 +463,34 @@ class NativeSteamService:
with open(config_path, 'w', encoding='utf-8') as f: with open(config_path, 'w', encoding='utf-8') as f:
f.write(new_config_text) f.write(new_config_text)
logger.info(f"Successfully set Proton version '{proton_version}' for AppID {app_id} using config.vdf only (steam-conductor method)") logger.info(f"Successfully set Proton version '{proton_version}' for AppID {app_id} using config.vdf only (steam-conductor method)")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error setting Proton version: {e}") logger.error(f"Error setting Proton version: {e}")
return False 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, 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. Complete workflow: Create shortcut and set Proton version.
This is the main method that replaces STL entirely. This is the main method that replaces STL entirely.
Returns: Returns:
(success, app_id) - Success status and the AppID (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}'") logger.info(f"Creating shortcut with Proton: '{app_name}' -> '{proton_version}'")
# Step 1: Create the shortcut # Step 1: Create the shortcut
@@ -351,7 +504,7 @@ class NativeSteamService:
logger.error("Failed to set Proton version (shortcut still created)") logger.error("Failed to set Proton version (shortcut still created)")
return False, app_id # Shortcut exists but Proton setting failed return False, app_id # Shortcut exists but Proton setting failed
logger.info(f"Complete workflow successful: '{app_name}' with '{proton_version}'") logger.info(f"Complete workflow successful: '{app_name}' with '{proton_version}'")
return True, app_id return True, app_id
def list_shortcuts(self) -> Dict[str, str]: def list_shortcuts(self) -> Dict[str, str]:
@@ -388,12 +541,12 @@ class NativeSteamService:
# Write back # Write back
if self.write_shortcuts_vdf(data): if self.write_shortcuts_vdf(data):
logger.info(f"Removed shortcut '{app_name}'") logger.info(f"Removed shortcut '{app_name}'")
return True return True
else: else:
logger.error("Failed to write updated shortcuts") logger.error("Failed to write updated shortcuts")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Error removing shortcut: {e}") logger.error(f"Error removing shortcut: {e}")
return False return False

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: def _get_protontricks_handler(self) -> ProtontricksHandler:
"""Get or create ProtontricksHandler instance""" """Get or create ProtontricksHandler instance"""
if self._protontricks_handler is None: if self._protontricks_handler is None:
self._protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck) self._protontricks_handler = ProtontricksHandler(self.steamdeck)
return self._protontricks_handler return self._protontricks_handler
def detect_protontricks(self, use_cache: bool = True) -> Tuple[bool, str, str]: def detect_protontricks(self, use_cache: bool = True) -> Tuple[bool, str, str]:

View File

@@ -0,0 +1,437 @@
"""
Update service for checking and applying Jackify updates.
This service handles checking for updates via GitHub releases API
and coordinating the update process.
"""
import json
import logging
import os
import subprocess
import tempfile
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Callable
import requests
from ...shared.appimage_utils import get_appimage_path, is_appimage, can_self_update
logger = logging.getLogger(__name__)
@dataclass
class UpdateInfo:
"""Information about an available update."""
version: str
tag_name: str
release_date: str
changelog: str
download_url: str
file_size: Optional[int] = None
is_critical: bool = False
is_delta_update: bool = False
class UpdateService:
"""Service for checking and applying Jackify updates."""
def __init__(self, current_version: str):
"""
Initialize the update service.
Args:
current_version: Current version of Jackify (e.g. "0.1.1")
"""
self.current_version = current_version
self.github_repo = "Omni-guides/Jackify"
self.github_api_base = "https://api.github.com"
self.update_check_timeout = 10 # seconds
def check_for_updates(self) -> Optional[UpdateInfo]:
"""
Check for available updates via GitHub releases API.
Returns:
UpdateInfo if update available, None otherwise
"""
try:
url = f"{self.github_api_base}/repos/{self.github_repo}/releases/latest"
headers = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': f'Jackify/{self.current_version}'
}
logger.debug(f"Checking for updates at {url}")
response = requests.get(url, headers=headers, timeout=self.update_check_timeout)
response.raise_for_status()
release_data = response.json()
latest_version = release_data['tag_name'].lstrip('v')
if self._is_newer_version(latest_version):
# Check if this version was skipped
if self._is_version_skipped(latest_version):
logger.debug(f"Version {latest_version} was skipped by user")
return None
# Find AppImage asset (prefer delta update if available)
download_url = None
file_size = None
# Look for delta update first (smaller download)
for asset in release_data.get('assets', []):
if asset['name'].endswith('.AppImage.delta') or 'delta' in asset['name'].lower():
download_url = asset['browser_download_url']
file_size = asset['size']
logger.debug(f"Found delta update: {asset['name']} ({file_size} bytes)")
break
# Fallback to full AppImage if no delta available
if not download_url:
for asset in release_data.get('assets', []):
if asset['name'].endswith('.AppImage'):
download_url = asset['browser_download_url']
file_size = asset['size']
logger.debug(f"Found full AppImage: {asset['name']} ({file_size} bytes)")
break
if download_url:
# Determine if this is a delta update
is_delta = '.delta' in download_url or 'delta' in download_url.lower()
# 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}")
return None
except requests.RequestException as e:
logger.error(f"Failed to check for updates: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error checking for updates: {e}")
return None
def _is_newer_version(self, version: str) -> bool:
"""
Compare versions to determine if update is newer.
Args:
version: Version to compare against current
Returns:
bool: True if version is newer than current
"""
try:
# Simple version comparison for semantic versioning
def version_tuple(v):
return tuple(map(int, v.split('.')))
return version_tuple(version) > version_tuple(self.current_version)
except ValueError:
logger.warning(f"Could not parse version: {version}")
return False
def _is_version_skipped(self, version: str) -> bool:
"""
Check if a version was skipped by the user.
Args:
version: Version to check
Returns:
bool: True if version was skipped, False otherwise
"""
try:
from ...backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
skipped_versions = config_handler.get('skipped_versions', [])
return version in skipped_versions
except Exception as e:
logger.warning(f"Error checking skipped versions: {e}")
return False
def check_for_updates_async(self, callback: Callable[[Optional[UpdateInfo]], None]) -> None:
"""
Check for updates in background thread.
Args:
callback: Function to call with update info (or None)
"""
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)
thread.start()
def can_update(self) -> bool:
"""
Check if updating is possible in current environment.
Returns:
bool: True if updating is possible
"""
if not is_appimage():
logger.debug("Not running as AppImage - updates not supported")
return False
appimage_path = get_appimage_path()
if not appimage_path:
logger.debug("AppImage path validation failed - updates not supported")
return False
if not can_self_update():
logger.debug("Cannot write to AppImage - updates not possible")
return False
logger.debug(f"Self-updating enabled for AppImage: {appimage_path}")
return True
def download_update(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
"""
Download update using full AppImage replacement.
Since we can't rely on external tools being available, we use a reliable
full replacement approach that works on all systems without dependencies.
Args:
update_info: Information about the update to download
progress_callback: Optional callback for download progress (bytes_downloaded, total_bytes)
Returns:
Path to downloaded file, or None if download failed
"""
try:
logger.info(f"Downloading update {update_info.version} (full replacement)")
return self._download_update_manual(update_info, progress_callback)
except Exception as e:
logger.error(f"Failed to download update: {e}")
return None
def _download_update_manual(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
"""
Fallback manual download method.
Args:
update_info: Information about the update to download
progress_callback: Optional callback for download progress
Returns:
Path to downloaded file, or None if download failed
"""
try:
logger.info(f"Manual download of update {update_info.version} from {update_info.download_url}")
response = requests.get(update_info.download_url, stream=True)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
# Create update directory in user's home directory
home_dir = Path.home()
update_dir = home_dir / "Jackify" / "updates"
update_dir.mkdir(parents=True, exist_ok=True)
temp_file = update_dir / f"Jackify-{update_info.version}.AppImage"
with open(temp_file, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
if progress_callback:
progress_callback(downloaded_size, total_size)
# Make executable
temp_file.chmod(0o755)
logger.info(f"Manual update downloaded successfully to {temp_file}")
return temp_file
except Exception as e:
logger.error(f"Failed to download update manually: {e}")
return None
def apply_update(self, new_appimage_path: Path) -> bool:
"""
Apply update by replacing current AppImage.
This creates a helper script that waits for Jackify to exit,
then replaces the AppImage and restarts it.
Args:
new_appimage_path: Path to downloaded update
Returns:
bool: True if update application was initiated successfully
"""
current_appimage = get_appimage_path()
if not current_appimage:
logger.error("Cannot determine current AppImage path")
return False
try:
# Create update helper script
helper_script = self._create_update_helper(current_appimage, new_appimage_path)
if helper_script:
# Launch helper script and exit
logger.info("Launching update helper and exiting")
subprocess.Popen(['nohup', 'bash', str(helper_script)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
return True
return False
except Exception as e:
logger.error(f"Failed to apply update: {e}")
return False
def _create_update_helper(self, current_appimage: Path, new_appimage: Path) -> Optional[Path]:
"""
Create helper script for update replacement.
Args:
current_appimage: Path to current AppImage
new_appimage: Path to new AppImage
Returns:
Path to helper script, or None if creation failed
"""
try:
# Create update directory in user's home directory
home_dir = Path.home()
update_dir = home_dir / "Jackify" / "updates"
update_dir.mkdir(parents=True, exist_ok=True)
helper_script = update_dir / "update_helper.sh"
script_content = f'''#!/bin/bash
# Jackify Update Helper Script
# This script safely replaces the current AppImage with the new version
CURRENT_APPIMAGE="{current_appimage}"
NEW_APPIMAGE="{new_appimage}"
TEMP_NAME="$CURRENT_APPIMAGE.updating"
echo "Jackify Update Helper"
echo "Waiting for Jackify to exit..."
# Wait longer for Jackify to fully exit and unmount
sleep 5
echo "Validating new AppImage..."
# Validate new AppImage exists and is executable
if [ ! -f "$NEW_APPIMAGE" ]; then
echo "ERROR: New AppImage not found: $NEW_APPIMAGE"
exit 1
fi
# Test that new AppImage can execute --version
if ! timeout 10 "$NEW_APPIMAGE" --version >/dev/null 2>&1; then
echo "ERROR: New AppImage failed validation test"
exit 1
fi
echo "New AppImage validated successfully"
echo "Performing safe replacement..."
# Backup current version
if [ -f "$CURRENT_APPIMAGE" ]; then
cp "$CURRENT_APPIMAGE" "$CURRENT_APPIMAGE.backup"
fi
# Safe replacement: copy to temp name first, then atomic move
if cp "$NEW_APPIMAGE" "$TEMP_NAME"; then
chmod +x "$TEMP_NAME"
# Atomic move to replace
if mv "$TEMP_NAME" "$CURRENT_APPIMAGE"; then
echo "Update completed successfully!"
# Clean up
rm -f "$NEW_APPIMAGE"
rm -f "$CURRENT_APPIMAGE.backup"
# Restart Jackify
echo "Restarting Jackify..."
sleep 1
exec "$CURRENT_APPIMAGE"
else
echo "ERROR: Failed to move updated AppImage"
rm -f "$TEMP_NAME"
# Restore backup
if [ -f "$CURRENT_APPIMAGE.backup" ]; then
mv "$CURRENT_APPIMAGE.backup" "$CURRENT_APPIMAGE"
echo "Restored original AppImage"
fi
exit 1
fi
else
echo "ERROR: Failed to copy new AppImage"
exit 1
fi
# Clean up this script
rm -f "{helper_script}"
'''
with open(helper_script, 'w') as f:
f.write(script_content)
# Make executable
helper_script.chmod(0o755)
logger.debug(f"Created update helper script: {helper_script}")
return helper_script
except Exception as e:
logger.error(f"Failed to create update helper script: {e}")
return None

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -7,7 +7,7 @@
"targets": { "targets": {
".NETCoreApp,Version=v8.0": {}, ".NETCoreApp,Version=v8.0": {},
".NETCoreApp,Version=v8.0/linux-x64": { ".NETCoreApp,Version=v8.0/linux-x64": {
"jackify-engine/0.3.12": { "jackify-engine/0.3.17": {
"dependencies": { "dependencies": {
"Markdig": "0.40.0", "Markdig": "0.40.0",
"Microsoft.Extensions.Configuration.Json": "9.0.1", "Microsoft.Extensions.Configuration.Json": "9.0.1",
@@ -22,16 +22,16 @@
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"System.CommandLine": "2.0.0-beta4.22272.1", "System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.CLI.Builder": "0.3.12", "Wabbajack.CLI.Builder": "0.3.17",
"Wabbajack.Downloaders.Bethesda": "0.3.12", "Wabbajack.Downloaders.Bethesda": "0.3.17",
"Wabbajack.Downloaders.Dispatcher": "0.3.12", "Wabbajack.Downloaders.Dispatcher": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.12", "Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Networking.Discord": "0.3.12", "Wabbajack.Networking.Discord": "0.3.17",
"Wabbajack.Networking.GitHub": "0.3.12", "Wabbajack.Networking.GitHub": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12", "Wabbajack.Paths.IO": "0.3.17",
"Wabbajack.Server.Lib": "0.3.12", "Wabbajack.Server.Lib": "0.3.17",
"Wabbajack.Services.OSIntegrated": "0.3.12", "Wabbajack.Services.OSIntegrated": "0.3.17",
"Wabbajack.VFS": "0.3.12", "Wabbajack.VFS": "0.3.17",
"MegaApiClient": "1.0.0.0", "MegaApiClient": "1.0.0.0",
"runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.19" "runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.19"
}, },
@@ -1781,7 +1781,7 @@
} }
} }
}, },
"Wabbajack.CLI.Builder/0.3.12": { "Wabbajack.CLI.Builder/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.Configuration.Json": "9.0.1", "Microsoft.Extensions.Configuration.Json": "9.0.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -1791,109 +1791,109 @@
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.CommandLine": "2.0.0-beta4.22272.1", "System.CommandLine": "2.0.0-beta4.22272.1",
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1", "System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
"Wabbajack.Paths": "0.3.12" "Wabbajack.Paths": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.CLI.Builder.dll": {} "Wabbajack.CLI.Builder.dll": {}
} }
}, },
"Wabbajack.Common/0.3.12": { "Wabbajack.Common/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"System.Reactive": "6.0.1", "System.Reactive": "6.0.1",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Networking.Http": "0.3.12", "Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12" "Wabbajack.Paths.IO": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Common.dll": {} "Wabbajack.Common.dll": {}
} }
}, },
"Wabbajack.Compiler/0.3.12": { "Wabbajack.Compiler/0.3.17": {
"dependencies": { "dependencies": {
"F23.StringSimilarity": "6.0.0", "F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Dispatcher": "0.3.12", "Wabbajack.Downloaders.Dispatcher": "0.3.17",
"Wabbajack.Installer": "0.3.12", "Wabbajack.Installer": "0.3.17",
"Wabbajack.VFS": "0.3.12", "Wabbajack.VFS": "0.3.17",
"ini-parser-netstandard": "2.5.2" "ini-parser-netstandard": "2.5.2"
}, },
"runtime": { "runtime": {
"Wabbajack.Compiler.dll": {} "Wabbajack.Compiler.dll": {}
} }
}, },
"Wabbajack.Compression.BSA/0.3.12": { "Wabbajack.Compression.BSA/0.3.17": {
"dependencies": { "dependencies": {
"K4os.Compression.LZ4.Streams": "1.3.8", "K4os.Compression.LZ4.Streams": "1.3.8",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2", "SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.12" "Wabbajack.DTOs": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Compression.BSA.dll": {} "Wabbajack.Compression.BSA.dll": {}
} }
}, },
"Wabbajack.Compression.Zip/0.3.12": { "Wabbajack.Compression.Zip/0.3.17": {
"dependencies": { "dependencies": {
"Wabbajack.IO.Async": "0.3.12" "Wabbajack.IO.Async": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Compression.Zip.dll": {} "Wabbajack.Compression.Zip.dll": {}
} }
}, },
"Wabbajack.Configuration/0.3.12": { "Wabbajack.Configuration/0.3.17": {
"runtime": { "runtime": {
"Wabbajack.Configuration.dll": {} "Wabbajack.Configuration.dll": {}
} }
}, },
"Wabbajack.Downloaders.Bethesda/0.3.12": { "Wabbajack.Downloaders.Bethesda/0.3.17": {
"dependencies": { "dependencies": {
"LibAES-CTR": "1.1.0", "LibAES-CTR": "1.1.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SharpZipLib": "1.4.2", "SharpZipLib": "1.4.2",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.BethesdaNet": "0.3.12" "Wabbajack.Networking.BethesdaNet": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Bethesda.dll": {} "Wabbajack.Downloaders.Bethesda.dll": {}
} }
}, },
"Wabbajack.Downloaders.Dispatcher/0.3.12": { "Wabbajack.Downloaders.Dispatcher/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Bethesda": "0.3.12", "Wabbajack.Downloaders.Bethesda": "0.3.17",
"Wabbajack.Downloaders.GameFile": "0.3.12", "Wabbajack.Downloaders.GameFile": "0.3.17",
"Wabbajack.Downloaders.GoogleDrive": "0.3.12", "Wabbajack.Downloaders.GoogleDrive": "0.3.17",
"Wabbajack.Downloaders.Http": "0.3.12", "Wabbajack.Downloaders.Http": "0.3.17",
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.3.12", "Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Downloaders.Manual": "0.3.12", "Wabbajack.Downloaders.Manual": "0.3.17",
"Wabbajack.Downloaders.MediaFire": "0.3.12", "Wabbajack.Downloaders.MediaFire": "0.3.17",
"Wabbajack.Downloaders.Mega": "0.3.12", "Wabbajack.Downloaders.Mega": "0.3.17",
"Wabbajack.Downloaders.ModDB": "0.3.12", "Wabbajack.Downloaders.ModDB": "0.3.17",
"Wabbajack.Downloaders.Nexus": "0.3.12", "Wabbajack.Downloaders.Nexus": "0.3.17",
"Wabbajack.Downloaders.VerificationCache": "0.3.12", "Wabbajack.Downloaders.VerificationCache": "0.3.17",
"Wabbajack.Downloaders.WabbajackCDN": "0.3.12", "Wabbajack.Downloaders.WabbajackCDN": "0.3.17",
"Wabbajack.Networking.WabbajackClientApi": "0.3.12" "Wabbajack.Networking.WabbajackClientApi": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Dispatcher.dll": {} "Wabbajack.Downloaders.Dispatcher.dll": {}
} }
}, },
"Wabbajack.Downloaders.GameFile/0.3.12": { "Wabbajack.Downloaders.GameFile/0.3.17": {
"dependencies": { "dependencies": {
"GameFinder.StoreHandlers.EADesktop": "4.5.0", "GameFinder.StoreHandlers.EADesktop": "4.5.0",
"GameFinder.StoreHandlers.EGS": "4.5.0", "GameFinder.StoreHandlers.EGS": "4.5.0",
@@ -1903,360 +1903,360 @@
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.VFS": "0.3.12" "Wabbajack.VFS": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.GameFile.dll": {} "Wabbajack.Downloaders.GameFile.dll": {}
} }
}, },
"Wabbajack.Downloaders.GoogleDrive/0.3.12": { "Wabbajack.Downloaders.GoogleDrive/0.3.17": {
"dependencies": { "dependencies": {
"HtmlAgilityPack": "1.11.72", "HtmlAgilityPack": "1.11.72",
"Microsoft.AspNetCore.Http.Extensions": "2.3.0", "Microsoft.AspNetCore.Http.Extensions": "2.3.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http": "0.3.12", "Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12" "Wabbajack.Networking.Http.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.GoogleDrive.dll": {} "Wabbajack.Downloaders.GoogleDrive.dll": {}
} }
}, },
"Wabbajack.Downloaders.Http/0.3.12": { "Wabbajack.Downloaders.Http/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.BethesdaNet": "0.3.12", "Wabbajack.Networking.BethesdaNet": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12", "Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12" "Wabbajack.Paths.IO": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Http.dll": {} "Wabbajack.Downloaders.Http.dll": {}
} }
}, },
"Wabbajack.Downloaders.Interfaces/0.3.12": { "Wabbajack.Downloaders.Interfaces/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Compression.Zip": "0.3.12", "Wabbajack.Compression.Zip": "0.3.17",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12" "Wabbajack.Paths.IO": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Interfaces.dll": {} "Wabbajack.Downloaders.Interfaces.dll": {}
} }
}, },
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.12": { "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.17": {
"dependencies": { "dependencies": {
"F23.StringSimilarity": "6.0.0", "F23.StringSimilarity": "6.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http": "0.3.12", "Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12" "Wabbajack.Networking.Http.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {} "Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {}
} }
}, },
"Wabbajack.Downloaders.Manual/0.3.12": { "Wabbajack.Downloaders.Manual/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12" "Wabbajack.Downloaders.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Manual.dll": {} "Wabbajack.Downloaders.Manual.dll": {}
} }
}, },
"Wabbajack.Downloaders.MediaFire/0.3.12": { "Wabbajack.Downloaders.MediaFire/0.3.17": {
"dependencies": { "dependencies": {
"HtmlAgilityPack": "1.11.72", "HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12" "Wabbajack.Networking.Http.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.MediaFire.dll": {} "Wabbajack.Downloaders.MediaFire.dll": {}
} }
}, },
"Wabbajack.Downloaders.Mega/0.3.12": { "Wabbajack.Downloaders.Mega/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12" "Wabbajack.Paths.IO": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Mega.dll": {} "Wabbajack.Downloaders.Mega.dll": {}
} }
}, },
"Wabbajack.Downloaders.ModDB/0.3.12": { "Wabbajack.Downloaders.ModDB/0.3.17": {
"dependencies": { "dependencies": {
"HtmlAgilityPack": "1.11.72", "HtmlAgilityPack": "1.11.72",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http": "0.3.12", "Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12" "Wabbajack.Networking.Http.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.ModDB.dll": {} "Wabbajack.Downloaders.ModDB.dll": {}
} }
}, },
"Wabbajack.Downloaders.Nexus/0.3.12": { "Wabbajack.Downloaders.Nexus/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.12", "Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Networking.Http": "0.3.12", "Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12", "Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Networking.NexusApi": "0.3.12", "Wabbajack.Networking.NexusApi": "0.3.17",
"Wabbajack.Paths": "0.3.12" "Wabbajack.Paths": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.Nexus.dll": {} "Wabbajack.Downloaders.Nexus.dll": {}
} }
}, },
"Wabbajack.Downloaders.VerificationCache/0.3.12": { "Wabbajack.Downloaders.VerificationCache/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Stub.System.Data.SQLite.Core.NetStandard": "1.0.119", "Stub.System.Data.SQLite.Core.NetStandard": "1.0.119",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12" "Wabbajack.Paths.IO": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.VerificationCache.dll": {} "Wabbajack.Downloaders.VerificationCache.dll": {}
} }
}, },
"Wabbajack.Downloaders.WabbajackCDN/0.3.12": { "Wabbajack.Downloaders.WabbajackCDN/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Microsoft.Toolkit.HighPerformance": "7.1.2", "Microsoft.Toolkit.HighPerformance": "7.1.2",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Networking.Http": "0.3.12", "Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.RateLimiter": "0.3.12" "Wabbajack.RateLimiter": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Downloaders.WabbajackCDN.dll": {} "Wabbajack.Downloaders.WabbajackCDN.dll": {}
} }
}, },
"Wabbajack.DTOs/0.3.12": { "Wabbajack.DTOs/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.Hashing.xxHash64": "0.3.12", "Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Paths": "0.3.12" "Wabbajack.Paths": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.DTOs.dll": {} "Wabbajack.DTOs.dll": {}
} }
}, },
"Wabbajack.FileExtractor/0.3.12": { "Wabbajack.FileExtractor/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"OMODFramework": "3.0.1", "OMODFramework": "3.0.1",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Compression.BSA": "0.3.12", "Wabbajack.Compression.BSA": "0.3.17",
"Wabbajack.Hashing.PHash": "0.3.12", "Wabbajack.Hashing.PHash": "0.3.17",
"Wabbajack.Paths": "0.3.12" "Wabbajack.Paths": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.FileExtractor.dll": {} "Wabbajack.FileExtractor.dll": {}
} }
}, },
"Wabbajack.Hashing.PHash/0.3.12": { "Wabbajack.Hashing.PHash/0.3.17": {
"dependencies": { "dependencies": {
"BCnEncoder.Net.ImageSharp": "1.1.1", "BCnEncoder.Net.ImageSharp": "1.1.1",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Shipwreck.Phash": "0.5.0", "Shipwreck.Phash": "0.5.0",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Paths": "0.3.12", "Wabbajack.Paths": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12" "Wabbajack.Paths.IO": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Hashing.PHash.dll": {} "Wabbajack.Hashing.PHash.dll": {}
} }
}, },
"Wabbajack.Hashing.xxHash64/0.3.12": { "Wabbajack.Hashing.xxHash64/0.3.17": {
"dependencies": { "dependencies": {
"Wabbajack.Paths": "0.3.12", "Wabbajack.Paths": "0.3.17",
"Wabbajack.RateLimiter": "0.3.12" "Wabbajack.RateLimiter": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Hashing.xxHash64.dll": {} "Wabbajack.Hashing.xxHash64.dll": {}
} }
}, },
"Wabbajack.Installer/0.3.12": { "Wabbajack.Installer/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"Octopus.Octodiff": "2.0.548", "Octopus.Octodiff": "2.0.548",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Downloaders.Dispatcher": "0.3.12", "Wabbajack.Downloaders.Dispatcher": "0.3.17",
"Wabbajack.Downloaders.GameFile": "0.3.12", "Wabbajack.Downloaders.GameFile": "0.3.17",
"Wabbajack.FileExtractor": "0.3.12", "Wabbajack.FileExtractor": "0.3.17",
"Wabbajack.Networking.WabbajackClientApi": "0.3.12", "Wabbajack.Networking.WabbajackClientApi": "0.3.17",
"Wabbajack.Paths": "0.3.12", "Wabbajack.Paths": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12", "Wabbajack.Paths.IO": "0.3.17",
"Wabbajack.VFS": "0.3.12", "Wabbajack.VFS": "0.3.17",
"ini-parser-netstandard": "2.5.2" "ini-parser-netstandard": "2.5.2"
}, },
"runtime": { "runtime": {
"Wabbajack.Installer.dll": {} "Wabbajack.Installer.dll": {}
} }
}, },
"Wabbajack.IO.Async/0.3.12": { "Wabbajack.IO.Async/0.3.17": {
"runtime": { "runtime": {
"Wabbajack.IO.Async.dll": {} "Wabbajack.IO.Async.dll": {}
} }
}, },
"Wabbajack.Networking.BethesdaNet/0.3.12": { "Wabbajack.Networking.BethesdaNet/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Networking.Http": "0.3.12", "Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12" "Wabbajack.Networking.Http.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.BethesdaNet.dll": {} "Wabbajack.Networking.BethesdaNet.dll": {}
} }
}, },
"Wabbajack.Networking.Discord/0.3.12": { "Wabbajack.Networking.Discord/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.Networking.Http.Interfaces": "0.3.12" "Wabbajack.Networking.Http.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.Discord.dll": {} "Wabbajack.Networking.Discord.dll": {}
} }
}, },
"Wabbajack.Networking.GitHub/0.3.12": { "Wabbajack.Networking.GitHub/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0", "Octokit": "14.0.0",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12" "Wabbajack.Networking.Http.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.GitHub.dll": {} "Wabbajack.Networking.GitHub.dll": {}
} }
}, },
"Wabbajack.Networking.Http/0.3.12": { "Wabbajack.Networking.Http/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Http": "9.0.1", "Microsoft.Extensions.Http": "9.0.1",
"Microsoft.Extensions.Logging": "9.0.1", "Microsoft.Extensions.Logging": "9.0.1",
"Wabbajack.Configuration": "0.3.12", "Wabbajack.Configuration": "0.3.17",
"Wabbajack.Downloaders.Interfaces": "0.3.12", "Wabbajack.Downloaders.Interfaces": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.12", "Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12", "Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Paths": "0.3.12", "Wabbajack.Paths": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12" "Wabbajack.Paths.IO": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.Http.dll": {} "Wabbajack.Networking.Http.dll": {}
} }
}, },
"Wabbajack.Networking.Http.Interfaces/0.3.12": { "Wabbajack.Networking.Http.Interfaces/0.3.17": {
"dependencies": { "dependencies": {
"Wabbajack.Hashing.xxHash64": "0.3.12" "Wabbajack.Hashing.xxHash64": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.Http.Interfaces.dll": {} "Wabbajack.Networking.Http.Interfaces.dll": {}
} }
}, },
"Wabbajack.Networking.NexusApi/0.3.12": { "Wabbajack.Networking.NexusApi/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Networking.Http": "0.3.12", "Wabbajack.Networking.Http": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12", "Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Networking.WabbajackClientApi": "0.3.12" "Wabbajack.Networking.WabbajackClientApi": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.NexusApi.dll": {} "Wabbajack.Networking.NexusApi.dll": {}
} }
}, },
"Wabbajack.Networking.WabbajackClientApi/0.3.12": { "Wabbajack.Networking.WabbajackClientApi/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"Octokit": "14.0.0", "Octokit": "14.0.0",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12", "Wabbajack.Paths.IO": "0.3.17",
"Wabbajack.VFS.Interfaces": "0.3.12", "Wabbajack.VFS.Interfaces": "0.3.17",
"YamlDotNet": "16.3.0" "YamlDotNet": "16.3.0"
}, },
"runtime": { "runtime": {
"Wabbajack.Networking.WabbajackClientApi.dll": {} "Wabbajack.Networking.WabbajackClientApi.dll": {}
} }
}, },
"Wabbajack.Paths/0.3.12": { "Wabbajack.Paths/0.3.17": {
"runtime": { "runtime": {
"Wabbajack.Paths.dll": {} "Wabbajack.Paths.dll": {}
} }
}, },
"Wabbajack.Paths.IO/0.3.12": { "Wabbajack.Paths.IO/0.3.17": {
"dependencies": { "dependencies": {
"Wabbajack.Paths": "0.3.12", "Wabbajack.Paths": "0.3.17",
"shortid": "4.0.0" "shortid": "4.0.0"
}, },
"runtime": { "runtime": {
"Wabbajack.Paths.IO.dll": {} "Wabbajack.Paths.IO.dll": {}
} }
}, },
"Wabbajack.RateLimiter/0.3.12": { "Wabbajack.RateLimiter/0.3.17": {
"runtime": { "runtime": {
"Wabbajack.RateLimiter.dll": {} "Wabbajack.RateLimiter.dll": {}
} }
}, },
"Wabbajack.Server.Lib/0.3.12": { "Wabbajack.Server.Lib/0.3.17": {
"dependencies": { "dependencies": {
"FluentFTP": "52.0.0", "FluentFTP": "52.0.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
@@ -2264,58 +2264,58 @@
"Nettle": "3.0.0", "Nettle": "3.0.0",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.Networking.Http.Interfaces": "0.3.12", "Wabbajack.Networking.Http.Interfaces": "0.3.17",
"Wabbajack.Services.OSIntegrated": "0.3.12" "Wabbajack.Services.OSIntegrated": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Server.Lib.dll": {} "Wabbajack.Server.Lib.dll": {}
} }
}, },
"Wabbajack.Services.OSIntegrated/0.3.12": { "Wabbajack.Services.OSIntegrated/0.3.17": {
"dependencies": { "dependencies": {
"DeviceId": "6.8.0", "DeviceId": "6.8.0",
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Newtonsoft.Json": "13.0.3", "Newtonsoft.Json": "13.0.3",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"Wabbajack.Compiler": "0.3.12", "Wabbajack.Compiler": "0.3.17",
"Wabbajack.Downloaders.Dispatcher": "0.3.12", "Wabbajack.Downloaders.Dispatcher": "0.3.17",
"Wabbajack.Installer": "0.3.12", "Wabbajack.Installer": "0.3.17",
"Wabbajack.Networking.BethesdaNet": "0.3.12", "Wabbajack.Networking.BethesdaNet": "0.3.17",
"Wabbajack.Networking.Discord": "0.3.12", "Wabbajack.Networking.Discord": "0.3.17",
"Wabbajack.VFS": "0.3.12" "Wabbajack.VFS": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.Services.OSIntegrated.dll": {} "Wabbajack.Services.OSIntegrated.dll": {}
} }
}, },
"Wabbajack.VFS/0.3.12": { "Wabbajack.VFS/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Microsoft.Extensions.Logging.Abstractions": "9.0.1", "Microsoft.Extensions.Logging.Abstractions": "9.0.1",
"SixLabors.ImageSharp": "3.1.6", "SixLabors.ImageSharp": "3.1.6",
"System.Data.SQLite.Core": "1.0.119", "System.Data.SQLite.Core": "1.0.119",
"Wabbajack.Common": "0.3.12", "Wabbajack.Common": "0.3.17",
"Wabbajack.FileExtractor": "0.3.12", "Wabbajack.FileExtractor": "0.3.17",
"Wabbajack.Hashing.PHash": "0.3.12", "Wabbajack.Hashing.PHash": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.12", "Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Paths": "0.3.12", "Wabbajack.Paths": "0.3.17",
"Wabbajack.Paths.IO": "0.3.12", "Wabbajack.Paths.IO": "0.3.17",
"Wabbajack.VFS.Interfaces": "0.3.12" "Wabbajack.VFS.Interfaces": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.VFS.dll": {} "Wabbajack.VFS.dll": {}
} }
}, },
"Wabbajack.VFS.Interfaces/0.3.12": { "Wabbajack.VFS.Interfaces/0.3.17": {
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.1", "Microsoft.Extensions.DependencyInjection": "9.0.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
"Wabbajack.DTOs": "0.3.12", "Wabbajack.DTOs": "0.3.17",
"Wabbajack.Hashing.xxHash64": "0.3.12", "Wabbajack.Hashing.xxHash64": "0.3.17",
"Wabbajack.Paths": "0.3.12" "Wabbajack.Paths": "0.3.17"
}, },
"runtime": { "runtime": {
"Wabbajack.VFS.Interfaces.dll": {} "Wabbajack.VFS.Interfaces.dll": {}
@@ -2332,7 +2332,7 @@
} }
}, },
"libraries": { "libraries": {
"jackify-engine/0.3.12": { "jackify-engine/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
@@ -3021,202 +3021,202 @@
"path": "yamldotnet/16.3.0", "path": "yamldotnet/16.3.0",
"hashPath": "yamldotnet.16.3.0.nupkg.sha512" "hashPath": "yamldotnet.16.3.0.nupkg.sha512"
}, },
"Wabbajack.CLI.Builder/0.3.12": { "Wabbajack.CLI.Builder/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Common/0.3.12": { "Wabbajack.Common/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Compiler/0.3.12": { "Wabbajack.Compiler/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Compression.BSA/0.3.12": { "Wabbajack.Compression.BSA/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Compression.Zip/0.3.12": { "Wabbajack.Compression.Zip/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Configuration/0.3.12": { "Wabbajack.Configuration/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Bethesda/0.3.12": { "Wabbajack.Downloaders.Bethesda/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Dispatcher/0.3.12": { "Wabbajack.Downloaders.Dispatcher/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.GameFile/0.3.12": { "Wabbajack.Downloaders.GameFile/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.GoogleDrive/0.3.12": { "Wabbajack.Downloaders.GoogleDrive/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Http/0.3.12": { "Wabbajack.Downloaders.Http/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Interfaces/0.3.12": { "Wabbajack.Downloaders.Interfaces/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.12": { "Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Manual/0.3.12": { "Wabbajack.Downloaders.Manual/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.MediaFire/0.3.12": { "Wabbajack.Downloaders.MediaFire/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Mega/0.3.12": { "Wabbajack.Downloaders.Mega/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.ModDB/0.3.12": { "Wabbajack.Downloaders.ModDB/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.Nexus/0.3.12": { "Wabbajack.Downloaders.Nexus/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.VerificationCache/0.3.12": { "Wabbajack.Downloaders.VerificationCache/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Downloaders.WabbajackCDN/0.3.12": { "Wabbajack.Downloaders.WabbajackCDN/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.DTOs/0.3.12": { "Wabbajack.DTOs/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.FileExtractor/0.3.12": { "Wabbajack.FileExtractor/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Hashing.PHash/0.3.12": { "Wabbajack.Hashing.PHash/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Hashing.xxHash64/0.3.12": { "Wabbajack.Hashing.xxHash64/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Installer/0.3.12": { "Wabbajack.Installer/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.IO.Async/0.3.12": { "Wabbajack.IO.Async/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.BethesdaNet/0.3.12": { "Wabbajack.Networking.BethesdaNet/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.Discord/0.3.12": { "Wabbajack.Networking.Discord/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.GitHub/0.3.12": { "Wabbajack.Networking.GitHub/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.Http/0.3.12": { "Wabbajack.Networking.Http/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.Http.Interfaces/0.3.12": { "Wabbajack.Networking.Http.Interfaces/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.NexusApi/0.3.12": { "Wabbajack.Networking.NexusApi/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Networking.WabbajackClientApi/0.3.12": { "Wabbajack.Networking.WabbajackClientApi/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Paths/0.3.12": { "Wabbajack.Paths/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Paths.IO/0.3.12": { "Wabbajack.Paths.IO/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.RateLimiter/0.3.12": { "Wabbajack.RateLimiter/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Server.Lib/0.3.12": { "Wabbajack.Server.Lib/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.Services.OSIntegrated/0.3.12": { "Wabbajack.Services.OSIntegrated/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.VFS/0.3.12": { "Wabbajack.VFS/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "sha512": ""
}, },
"Wabbajack.VFS.Interfaces/0.3.12": { "Wabbajack.VFS.Interfaces/0.3.17": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,
"sha512": "" "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 # Import our command handlers
from .commands.configure_modlist import ConfigureModlistCommand from .commands.configure_modlist import ConfigureModlistCommand
from .commands.install_modlist import InstallModlistCommand from .commands.install_modlist import InstallModlistCommand
from .commands.tuxborn import TuxbornCommand
# Import our menu handlers # Import our menu handlers
from .menus.main_menu import MainMenuHandler from .menus.main_menu import MainMenuHandler
from .menus.tuxborn_menu import TuxbornMenuHandler
from .menus.wabbajack_menu import WabbajackMenuHandler from .menus.wabbajack_menu import WabbajackMenuHandler
from .menus.hoolamike_menu import HoolamikeMenuHandler from .menus.hoolamike_menu import HoolamikeMenuHandler
from .menus.additional_menu import AdditionalMenuHandler from .menus.additional_menu import AdditionalMenuHandler
@@ -174,13 +172,103 @@ class JackifyCLI:
Returns: Returns:
Dictionary of backend service instances Dictionary of backend service instances
""" """
# For now, create a basic modlist service # Initialize update service
# TODO: Add other services as needed from jackify.backend.services.update_service import UpdateService
update_service = UpdateService(jackify_version)
services = { services = {
'modlist_service': ModlistService(self.system_info) 'modlist_service': ModlistService(self.system_info),
'update_service': update_service
} }
return services return services
def _check_for_updates_on_startup(self):
"""Check for updates on startup in background thread"""
try:
self._debug_print("Checking for updates on startup...")
def update_check_callback(update_info):
"""Handle update check results"""
try:
if update_info:
print(f"\n{COLOR_INFO}Update available: v{update_info.version}{COLOR_RESET}")
print(f"Current version: v{jackify_version}")
print(f"Release date: {update_info.release_date}")
if update_info.changelog:
print(f"Changelog: {update_info.changelog[:200]}...")
print(f"Download size: {update_info.file_size / (1024*1024):.1f} MB" if update_info.file_size else "Download size: Unknown")
print(f"\nTo update, run: jackify --update")
print("Or visit: https://github.com/Omni-guides/Jackify/releases")
else:
self._debug_print("No updates available")
except Exception as e:
self._debug_print(f"Error showing update info: {e}")
# Check for updates in background
self.backend_services['update_service'].check_for_updates_async(update_check_callback)
except Exception as e:
self._debug_print(f"Error checking for updates on startup: {e}")
# Continue anyway - don't block startup on update check errors
def _handle_update(self):
"""Handle manual update check and installation"""
try:
print("Checking for updates...")
update_service = self.backend_services['update_service']
# Check if updating is possible
if not update_service.can_update():
print(f"{COLOR_ERROR}Update not possible: not running as AppImage or insufficient permissions{COLOR_RESET}")
return 1
# Check for updates
update_info = update_service.check_for_updates()
if update_info:
print(f"{COLOR_INFO}Update available: v{update_info.version}{COLOR_RESET}")
print(f"Current version: v{jackify_version}")
print(f"Release date: {update_info.release_date}")
if update_info.changelog:
print(f"Changelog: {update_info.changelog}")
print(f"Download size: {update_info.file_size / (1024*1024):.1f} MB" if update_info.file_size else "Download size: Unknown")
# Ask for confirmation
response = input("\nDo you want to download and install this update? (y/N): ").strip().lower()
if response in ['y', 'yes']:
print("Downloading update...")
def progress_callback(downloaded, total):
if total > 0:
percentage = int((downloaded / total) * 100)
downloaded_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
print(f"\rDownloaded {downloaded_mb:.1f} MB of {total_mb:.1f} MB ({percentage}%)", end='', flush=True)
downloaded_path = update_service.download_update(update_info, progress_callback)
if downloaded_path:
print(f"\nDownload completed. Installing update...")
if update_service.apply_update(downloaded_path):
print(f"{COLOR_INFO}Update applied successfully! Jackify will restart...{COLOR_RESET}")
return 0
else:
print(f"{COLOR_ERROR}Failed to apply update{COLOR_RESET}")
return 1
else:
print(f"\n{COLOR_ERROR}Failed to download update{COLOR_RESET}")
return 1
else:
print("Update cancelled.")
return 0
else:
print(f"{COLOR_INFO}You are already running the latest version (v{jackify_version}){COLOR_RESET}")
return 0
except Exception as e:
print(f"{COLOR_ERROR}Update failed: {e}{COLOR_RESET}")
return 1
def _initialize_command_handlers(self): def _initialize_command_handlers(self):
"""Initialize command handler instances. """Initialize command handler instances.
@@ -190,7 +278,6 @@ class JackifyCLI:
commands = { commands = {
'configure_modlist': ConfigureModlistCommand(self.backend_services), 'configure_modlist': ConfigureModlistCommand(self.backend_services),
'install_modlist': InstallModlistCommand(self.backend_services, self.system_info), 'install_modlist': InstallModlistCommand(self.backend_services, self.system_info),
'tuxborn': TuxbornCommand(self.backend_services, self.system_info)
} }
return commands return commands
@@ -202,7 +289,6 @@ class JackifyCLI:
""" """
menus = { menus = {
'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)), 'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)),
'tuxborn': TuxbornMenuHandler(),
'wabbajack': WabbajackMenuHandler(), 'wabbajack': WabbajackMenuHandler(),
'hoolamike': HoolamikeMenuHandler(), 'hoolamike': HoolamikeMenuHandler(),
'additional': AdditionalMenuHandler() 'additional': AdditionalMenuHandler()
@@ -271,15 +357,16 @@ class JackifyCLI:
self._debug_print('JackifyCLI.run() called') self._debug_print('JackifyCLI.run() called')
self._debug_print(f'Parsed args: {self.args}') self._debug_print(f'Parsed args: {self.args}')
# Handle update functionality
if getattr(self.args, 'update', False):
self._debug_print('Entering update workflow')
return self._handle_update()
# Handle legacy restart-steam functionality (temporary) # Handle legacy restart-steam functionality (temporary)
if getattr(self.args, 'restart_steam', False): if getattr(self.args, 'restart_steam', False):
self._debug_print('Entering restart_steam workflow') self._debug_print('Entering restart_steam workflow')
return self._handle_restart_steam() return self._handle_restart_steam()
# Handle Tuxborn auto mode
if getattr(self.args, 'tuxborn_auto', False):
self._debug_print('Entering Tuxborn workflow')
return self.commands['tuxborn'].execute(self.args)
# Handle install-modlist top-level functionality # Handle install-modlist top-level functionality
if getattr(self.args, 'install_modlist', False): if getattr(self.args, 'install_modlist', False):
@@ -290,6 +377,9 @@ class JackifyCLI:
if getattr(self.args, 'command', None): if getattr(self.args, 'command', None):
return self._run_command(self.args.command, self.args) return self._run_command(self.args.command, self.args)
# Check for updates on startup (non-blocking)
self._check_for_updates_on_startup()
# Run interactive mode (legacy for now) # Run interactive mode (legacy for now)
self._run_interactive() self._run_interactive()
@@ -303,9 +393,9 @@ class JackifyCLI:
parser.add_argument("--resolution", type=str, help="Resolution to set (optional)") parser.add_argument("--resolution", type=str, help="Resolution to set (optional)")
parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)') parser.add_argument('--restart-steam', action='store_true', help='Restart Steam (native, for GUI integration)')
parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)') parser.add_argument('--dev', action='store_true', help='Enable development features (show hidden menu items)')
parser.add_argument('--update', action='store_true', help='Check for and install updates')
# Add command-specific arguments # Add command-specific arguments
self.commands['tuxborn'].add_args(parser)
self.commands['install_modlist'].add_top_level_args(parser) self.commands['install_modlist'].add_top_level_args(parser)
# Add subcommands # Add subcommands
@@ -360,8 +450,6 @@ class JackifyCLI:
return 0 return 0
elif choice == "wabbajack": elif choice == "wabbajack":
self.menus['wabbajack'].show_wabbajack_tasks_menu(self) self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
elif choice == "tuxborn":
self.menus['tuxborn'].show_tuxborn_installer_menu(self)
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY # HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
# elif choice == "hoolamike": # elif choice == "hoolamike":
# self.menus['hoolamike'].show_hoolamike_menu(self) # self.menus['hoolamike'].show_hoolamike_menu(self)

View File

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

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

@@ -32,9 +32,9 @@ class WabbajackMenuHandler:
print_section_header("Modlist and Wabbajack Tasks") print_section_header("Modlist and Wabbajack Tasks")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist (Automated)") print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist (Automated)")
print(f" {COLOR_ACTION}Uses jackify-engine for a full install flow{COLOR_RESET}") print(f" {COLOR_ACTION}Install a modlist in full: Select from a list or provide a .wabbajack file{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure New Modlist (Post-Download)") print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure New Modlist (Post-Download)")
print(f" {COLOR_ACTION}→ Modlist .wabbajack file downloaded? Configure it for Steam{COLOR_RESET}") print(f" {COLOR_ACTION}→ Modlist already downloaded? Configure and add to Steam{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Configure Existing Modlist (In Steam)") print(f"{COLOR_SELECTION}3.{COLOR_RESET} Configure Existing Modlist (In Steam)")
print(f" {COLOR_ACTION}→ Modlist already in Steam? Re-configure it here{COLOR_RESET}") print(f" {COLOR_ACTION}→ Modlist already in Steam? Re-configure it here{COLOR_RESET}")
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY # HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY

View File

@@ -0,0 +1,400 @@
"""
About dialog for Jackify.
This dialog displays system information, version details, and provides
access to update checking and external links.
"""
import logging
import os
import platform
import subprocess
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QGroupBox, QTextEdit, QApplication
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QFont, QClipboard
from ....backend.services.update_service import UpdateService
from ....backend.models.configuration import SystemInfo
from .... import __version__
logger = logging.getLogger(__name__)
class UpdateCheckThread(QThread):
"""Background thread for checking updates."""
update_check_finished = Signal(object) # UpdateInfo or None
def __init__(self, update_service: UpdateService):
super().__init__()
self.update_service = update_service
def run(self):
"""Check for updates in background."""
try:
update_info = self.update_service.check_for_updates()
self.update_check_finished.emit(update_info)
except Exception as e:
logger.error(f"Error checking for updates: {e}")
self.update_check_finished.emit(None)
class AboutDialog(QDialog):
"""About dialog showing system info and app details."""
def __init__(self, system_info: SystemInfo, parent=None):
super().__init__(parent)
self.system_info = system_info
self.update_service = UpdateService(__version__)
self.update_check_thread = None
self.setup_ui()
self.setup_connections()
def setup_ui(self):
"""Set up the dialog UI."""
self.setWindowTitle("About Jackify")
self.setModal(True)
self.setFixedSize(520, 520)
layout = QVBoxLayout(self)
# Header
header_layout = QVBoxLayout()
# App icon/name
title_label = QLabel("Jackify")
title_font = QFont()
title_font.setPointSize(18)
title_font.setBold(True)
title_label.setFont(title_font)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("color: #3fd0ea; margin: 10px;")
header_layout.addWidget(title_label)
subtitle_label = QLabel(f"v{__version__}")
subtitle_font = QFont()
subtitle_font.setPointSize(12)
subtitle_label.setFont(subtitle_font)
subtitle_label.setAlignment(Qt.AlignCenter)
subtitle_label.setStyleSheet("color: #666; margin-bottom: 10px;")
header_layout.addWidget(subtitle_label)
tagline_label = QLabel("Simplifying Wabbajack modlist installation and configuration on Linux")
tagline_label.setAlignment(Qt.AlignCenter)
tagline_label.setStyleSheet("color: #888; margin-bottom: 20px;")
header_layout.addWidget(tagline_label)
layout.addLayout(header_layout)
# System Information Group
system_group = QGroupBox("System Information")
system_layout = QVBoxLayout(system_group)
system_info_text = self._get_system_info_text()
system_info_label = QLabel(system_info_text)
system_info_label.setStyleSheet("font-family: monospace; font-size: 10pt; color: #ccc;")
system_info_label.setWordWrap(True)
system_layout.addWidget(system_info_label)
layout.addWidget(system_group)
# Jackify Information Group
jackify_group = QGroupBox("Jackify Information")
jackify_layout = QVBoxLayout(jackify_group)
jackify_info_text = self._get_jackify_info_text()
jackify_info_label = QLabel(jackify_info_text)
jackify_info_label.setStyleSheet("font-family: monospace; font-size: 10pt; color: #ccc;")
jackify_layout.addWidget(jackify_info_label)
layout.addWidget(jackify_group)
# Update status
self.update_status_label = QLabel("")
self.update_status_label.setStyleSheet("color: #666; font-size: 10pt; margin: 5px;")
self.update_status_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.update_status_label)
# Buttons
button_layout = QHBoxLayout()
# Update check button
self.update_button = QPushButton("Check for Updates")
self.update_button.clicked.connect(self.check_for_updates)
self.update_button.setStyleSheet("""
QPushButton {
background-color: #23272e;
color: #3fd0ea;
font-weight: bold;
padding: 8px 16px;
border-radius: 4px;
border: 2px solid #3fd0ea;
}
QPushButton:hover {
background-color: #3fd0ea;
color: #23272e;
}
QPushButton:pressed {
background-color: #2bb8d6;
color: #23272e;
}
QPushButton:disabled {
background-color: #444;
color: #666;
border-color: #666;
}
""")
button_layout.addWidget(self.update_button)
button_layout.addStretch()
# Copy Info button
copy_button = QPushButton("Copy Info")
copy_button.clicked.connect(self.copy_system_info)
button_layout.addWidget(copy_button)
# External links
github_button = QPushButton("GitHub")
github_button.clicked.connect(self.open_github)
button_layout.addWidget(github_button)
nexus_button = QPushButton("Nexus")
nexus_button.clicked.connect(self.open_nexus)
button_layout.addWidget(nexus_button)
layout.addLayout(button_layout)
# Close button
close_layout = QHBoxLayout()
close_layout.addStretch()
close_button = QPushButton("Close")
close_button.setDefault(True)
close_button.clicked.connect(self.accept)
close_layout.addWidget(close_button)
layout.addLayout(close_layout)
def setup_connections(self):
"""Set up signal connections."""
pass
def _get_system_info_text(self) -> str:
"""Get formatted system information."""
try:
# OS info
os_info = self._get_os_info()
kernel = platform.release()
# Desktop environment
desktop = self._get_desktop_environment()
# Display server
display_server = self._get_display_server()
return f"• OS: {os_info}\n• Kernel: {kernel}\n• Desktop: {desktop}\n• Display: {display_server}"
except Exception as e:
logger.error(f"Error getting system info: {e}")
return "• System info unavailable"
def _get_jackify_info_text(self) -> str:
"""Get formatted Jackify information."""
try:
# Engine version
engine_version = self._get_engine_version()
# Python version
python_version = platform.python_version()
return f"• Engine: {engine_version}\n• Python: {python_version}"
except Exception as e:
logger.error(f"Error getting Jackify info: {e}")
return "• Jackify info unavailable"
def _get_os_info(self) -> str:
"""Get OS distribution name and version."""
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
lines = f.readlines()
pretty_name = None
name = None
version = None
for line in lines:
line = line.strip()
if line.startswith("PRETTY_NAME="):
pretty_name = line.split("=", 1)[1].strip('"')
elif line.startswith("NAME="):
name = line.split("=", 1)[1].strip('"')
elif line.startswith("VERSION="):
version = line.split("=", 1)[1].strip('"')
# Prefer PRETTY_NAME, fallback to NAME + VERSION
if pretty_name:
return pretty_name
elif name and version:
return f"{name} {version}"
elif name:
return name
# Fallback to platform info
return f"{platform.system()} {platform.release()}"
except Exception as e:
logger.error(f"Error getting OS info: {e}")
return "Unknown Linux"
def _get_desktop_environment(self) -> str:
"""Get desktop environment."""
try:
# Try XDG_CURRENT_DESKTOP first
desktop = os.environ.get("XDG_CURRENT_DESKTOP")
if desktop:
return desktop
# Fallback to DESKTOP_SESSION
desktop = os.environ.get("DESKTOP_SESSION")
if desktop:
return desktop
# Try detecting common DEs
if os.environ.get("KDE_FULL_SESSION"):
return "KDE"
elif os.environ.get("GNOME_DESKTOP_SESSION_ID"):
return "GNOME"
elif os.environ.get("XFCE4_SESSION"):
return "XFCE"
return "Unknown"
except Exception as e:
logger.error(f"Error getting desktop environment: {e}")
return "Unknown"
def _get_display_server(self) -> str:
"""Get display server type (Wayland or X11)."""
try:
# Check XDG_SESSION_TYPE first
session_type = os.environ.get("XDG_SESSION_TYPE")
if session_type:
return session_type.capitalize()
# Check for Wayland display
if os.environ.get("WAYLAND_DISPLAY"):
return "Wayland"
# Check for X11 display
if os.environ.get("DISPLAY"):
return "X11"
return "Unknown"
except Exception as e:
logger.error(f"Error getting display server: {e}")
return "Unknown"
def _get_engine_version(self) -> str:
"""Get jackify-engine version."""
try:
# Try to execute jackify-engine --version
engine_path = Path(__file__).parent.parent.parent.parent / "engine" / "jackify-engine"
if engine_path.exists():
result = subprocess.run([str(engine_path), "--version"],
capture_output=True, text=True, timeout=5)
if result.returncode == 0:
version = result.stdout.strip()
# Extract just the version number (before the +commit hash)
if '+' in version:
version = version.split('+')[0]
return f"v{version}"
return "Unknown"
except Exception as e:
logger.error(f"Error getting engine version: {e}")
return "Unknown"
def check_for_updates(self):
"""Check for updates in background."""
if self.update_check_thread and self.update_check_thread.isRunning():
return
self.update_button.setEnabled(False)
self.update_button.setText("Checking...")
self.update_status_label.setText("Checking for updates...")
self.update_check_thread = UpdateCheckThread(self.update_service)
self.update_check_thread.update_check_finished.connect(self.update_check_finished)
self.update_check_thread.start()
def update_check_finished(self, update_info):
"""Handle update check completion."""
self.update_button.setEnabled(True)
self.update_button.setText("Check for Updates")
if update_info:
self.update_status_label.setText(f"Update available: v{update_info.version}")
self.update_status_label.setStyleSheet("color: #3fd0ea; font-size: 10pt; margin: 5px;")
# Show update dialog
from .update_dialog import UpdateDialog
update_dialog = UpdateDialog(update_info, self.update_service, self)
update_dialog.exec()
else:
self.update_status_label.setText("You're running the latest version")
self.update_status_label.setStyleSheet("color: #666; font-size: 10pt; margin: 5px;")
def copy_system_info(self):
"""Copy system information to clipboard."""
try:
info_text = f"""Jackify v{__version__} (Engine {self._get_engine_version()})
OS: {self._get_os_info()} ({platform.release()})
Desktop: {self._get_desktop_environment()} ({self._get_display_server()})
Python: {platform.python_version()}"""
clipboard = QApplication.clipboard()
clipboard.setText(info_text)
# Briefly update button text
sender = self.sender()
original_text = sender.text()
sender.setText("Copied!")
# Reset button text after delay
from PySide6.QtCore import QTimer
QTimer.singleShot(1000, lambda: sender.setText(original_text))
except Exception as e:
logger.error(f"Error copying system info: {e}")
def open_github(self):
"""Open GitHub repository."""
try:
import webbrowser
webbrowser.open("https://github.com/Omni-guides/Jackify")
except Exception as e:
logger.error(f"Error opening GitHub: {e}")
def open_nexus(self):
"""Open Nexus Mods page."""
try:
import webbrowser
webbrowser.open("https://www.nexusmods.com/site/mods/1427")
except Exception as e:
logger.error(f"Error opening Nexus: {e}")
def closeEvent(self, event):
"""Handle dialog close event."""
if self.update_check_thread and self.update_check_thread.isRunning():
self.update_check_thread.terminate()
self.update_check_thread.wait()
event.accept()

View File

@@ -100,7 +100,7 @@ class UlimitGuidanceDialog(QDialog):
status_text = "✓ Optimal" status_text = "✓ Optimal"
status_color = "#4caf50" # Green status_color = "#4caf50" # Green
elif self.status['can_increase']: elif self.status['can_increase']:
status_text = "Can Improve" status_text = "Can Improve"
status_color = "#ff9800" # Orange status_color = "#ff9800" # Orange
else: else:
status_text = "✗ Needs Manual Fix" status_text = "✗ Needs Manual Fix"
@@ -222,7 +222,7 @@ class UlimitGuidanceDialog(QDialog):
# Warning # Warning
warning_label = QLabel( warning_label = QLabel(
"⚠️ WARNING: These commands require root/sudo privileges and modify system files. " "WARNING: These commands require root/sudo privileges and modify system files. "
"Make sure you understand what each command does before running it." "Make sure you understand what each command does before running it."
) )
warning_label.setWordWrap(True) warning_label.setWordWrap(True)
@@ -478,7 +478,7 @@ class UlimitGuidanceDialog(QDialog):
status_text = "✓ Optimal" status_text = "✓ Optimal"
status_color = "#4caf50" # Green status_color = "#4caf50" # Green
elif self.status['can_increase']: elif self.status['can_increase']:
status_text = "Can Improve" status_text = "Can Improve"
status_color = "#ff9800" # Orange status_color = "#ff9800" # Orange
else: else:
status_text = "✗ Needs Manual Fix" status_text = "✗ Needs Manual Fix"

View File

@@ -0,0 +1,343 @@
"""
Update notification and download dialog for Jackify.
This dialog handles notifying users about available updates and
managing the download/installation process.
"""
import logging
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QTextEdit, QProgressBar, QGroupBox, QCheckBox
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QPixmap, QFont
from ....backend.services.update_service import UpdateService, UpdateInfo
logger = logging.getLogger(__name__)
class UpdateDownloadThread(QThread):
"""Background thread for downloading updates."""
progress_updated = Signal(int, int) # downloaded, total
download_finished = Signal(object) # Path or None
def __init__(self, update_service: UpdateService, update_info: UpdateInfo):
super().__init__()
self.update_service = update_service
self.update_info = update_info
self.downloaded_path = None
def run(self):
"""Download the update in background."""
try:
def progress_callback(downloaded: int, total: int):
self.progress_updated.emit(downloaded, total)
self.downloaded_path = self.update_service.download_update(
self.update_info, progress_callback
)
self.download_finished.emit(self.downloaded_path)
except Exception as e:
logger.error(f"Error in download thread: {e}")
self.download_finished.emit(None)
class UpdateDialog(QDialog):
"""Dialog for notifying users about updates and handling downloads."""
def __init__(self, update_info: UpdateInfo, update_service: UpdateService, parent=None):
super().__init__(parent)
self.update_info = update_info
self.update_service = update_service
self.downloaded_path = None
self.download_thread = None
self.setup_ui()
self.setup_connections()
def setup_ui(self):
"""Set up the dialog UI."""
self.setWindowTitle("Jackify Update Available")
self.setModal(True)
self.setMinimumSize(500, 400)
self.setMaximumSize(600, 600)
layout = QVBoxLayout(self)
# Header
header_layout = QHBoxLayout()
# Update icon (if available)
icon_label = QLabel()
icon_label.setText("^") # Update arrow symbol
icon_label.setStyleSheet("font-size: 24px; color: #3fd0ea; font-weight: bold;")
header_layout.addWidget(icon_label)
# Update title
title_layout = QVBoxLayout()
title_label = QLabel(f"Update Available: v{self.update_info.version}")
title_font = QFont()
title_font.setPointSize(14)
title_font.setBold(True)
title_label.setFont(title_font)
title_label.setStyleSheet("color: #3fd0ea;")
title_layout.addWidget(title_label)
subtitle_label = QLabel(f"Current version: v{self.update_service.current_version}")
subtitle_label.setStyleSheet("color: #666;")
title_layout.addWidget(subtitle_label)
header_layout.addLayout(title_layout)
header_layout.addStretch()
layout.addLayout(header_layout)
# File size info
if self.update_info.file_size:
size_mb = self.update_info.file_size / (1024 * 1024)
update_type = "Delta update" if self.update_info.is_delta_update else "Full update"
size_label = QLabel(f"{update_type} - Download size: {size_mb:.1f} MB")
size_label.setStyleSheet("color: #666; margin-bottom: 10px;")
layout.addWidget(size_label)
# Changelog group
changelog_group = QGroupBox("What's New")
changelog_layout = QVBoxLayout(changelog_group)
self.changelog_text = QTextEdit()
self.changelog_text.setPlainText(self.update_info.changelog or "No changelog available.")
self.changelog_text.setMaximumHeight(150)
self.changelog_text.setReadOnly(True)
changelog_layout.addWidget(self.changelog_text)
layout.addWidget(changelog_group)
# Progress section (initially hidden)
self.progress_group = QGroupBox("Download Progress")
progress_layout = QVBoxLayout(self.progress_group)
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
progress_layout.addWidget(self.progress_bar)
self.progress_label = QLabel("Preparing download...")
self.progress_label.setVisible(False)
progress_layout.addWidget(self.progress_label)
layout.addWidget(self.progress_group)
self.progress_group.setVisible(False)
# Options
options_group = QGroupBox("Update Options")
options_layout = QVBoxLayout(options_group)
self.auto_restart_checkbox = QCheckBox("Automatically restart Jackify after update")
self.auto_restart_checkbox.setChecked(True)
options_layout.addWidget(self.auto_restart_checkbox)
layout.addWidget(options_group)
# Buttons
button_layout = QHBoxLayout()
self.later_button = QPushButton("Remind Me Later")
self.later_button.clicked.connect(self.remind_later)
button_layout.addWidget(self.later_button)
self.skip_button = QPushButton("Skip This Version")
self.skip_button.clicked.connect(self.skip_version)
button_layout.addWidget(self.skip_button)
button_layout.addStretch()
self.download_button = QPushButton("Download && Install Update")
self.download_button.setDefault(True)
self.download_button.clicked.connect(self.start_download)
button_layout.addWidget(self.download_button)
self.install_button = QPushButton("Install && Restart")
self.install_button.setVisible(False)
self.install_button.clicked.connect(self.install_update)
self.install_button.setStyleSheet("""
QPushButton {
background-color: #23272e;
color: #3fd0ea;
font-weight: bold;
padding: 8px 16px;
border-radius: 4px;
border: 2px solid #3fd0ea;
}
QPushButton:hover {
background-color: #3fd0ea;
color: #23272e;
}
QPushButton:pressed {
background-color: #2bb8d6;
color: #23272e;
}
""")
button_layout.addWidget(self.install_button)
layout.addLayout(button_layout)
# Style the download button to match Jackify theme (dark with blue text)
self.download_button.setStyleSheet("""
QPushButton {
background-color: #23272e;
color: #3fd0ea;
font-weight: bold;
padding: 8px 16px;
border-radius: 4px;
border: 2px solid #3fd0ea;
}
QPushButton:hover {
background-color: #3fd0ea;
color: #23272e;
}
QPushButton:pressed {
background-color: #2bb8d6;
color: #23272e;
}
""")
def setup_connections(self):
"""Set up signal connections."""
pass
def start_download(self):
"""Start downloading the update."""
if not self.update_service.can_update():
self.show_error("Update not possible",
"Cannot update: not running as AppImage or insufficient permissions.")
return
# Show progress UI
self.progress_group.setVisible(True)
self.progress_bar.setVisible(True)
self.progress_label.setVisible(True)
self.progress_label.setText("Starting download...")
# Disable buttons during download
self.download_button.setEnabled(False)
self.later_button.setEnabled(False)
self.skip_button.setEnabled(False)
# Start download thread
self.download_thread = UpdateDownloadThread(self.update_service, self.update_info)
self.download_thread.progress_updated.connect(self.update_progress)
self.download_thread.download_finished.connect(self.download_completed)
self.download_thread.start()
def update_progress(self, downloaded: int, total: int):
"""Update download progress."""
if total > 0:
percentage = int((downloaded / total) * 100)
self.progress_bar.setValue(percentage)
downloaded_mb = downloaded / (1024 * 1024)
total_mb = total / (1024 * 1024)
self.progress_label.setText(f"Downloaded {downloaded_mb:.1f} MB of {total_mb:.1f} MB ({percentage}%)")
else:
self.progress_label.setText(f"Downloaded {downloaded / (1024 * 1024):.1f} MB...")
def download_completed(self, downloaded_path: Optional[Path]):
"""Handle download completion."""
if downloaded_path:
self.downloaded_path = downloaded_path
self.progress_label.setText("Download completed successfully!")
self.progress_bar.setValue(100)
# 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)
self.later_button.setEnabled(True)
self.skip_button.setEnabled(True)
def install_update(self):
"""Install the downloaded update."""
if not self.downloaded_path:
self.show_error("No Download", "No update has been downloaded.")
return
self.progress_label.setText("Installing update...")
if self.update_service.apply_update(self.downloaded_path):
self.progress_label.setText("Update applied successfully! Jackify will restart...")
# Close dialog and exit application (update helper will restart)
self.accept()
# The update helper script will handle the restart
import sys
sys.exit(0)
else:
self.show_error("Installation Failed", "Failed to apply the update. Please try again.")
def remind_later(self):
"""Close dialog and remind later."""
self.reject()
def skip_version(self):
"""Skip this version and save preference."""
try:
# Save the skipped version to config
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
# Get current skipped versions
skipped_versions = config_handler.get('skipped_versions', [])
# Add this version to skipped list
if self.update_info.version not in skipped_versions:
skipped_versions.append(self.update_info.version)
config_handler.set('skipped_versions', skipped_versions)
config_handler.save()
logger.info(f"Skipped version {self.update_info.version}")
except Exception as e:
logger.error(f"Error saving skip preference: {e}")
self.reject()
def show_error(self, title: str, message: str):
"""Show error message to user."""
from PySide6.QtWidgets import QMessageBox
QMessageBox.warning(self, title, message)
def closeEvent(self, event):
"""Handle dialog close event."""
if self.download_thread and self.download_thread.isRunning():
# Cancel download if in progress
self.download_thread.terminate()
self.download_thread.wait()
event.accept()

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,7 +5,6 @@ Contains all the GUI screen components for Jackify.
""" """
from .main_menu import MainMenu from .main_menu import MainMenu
from .tuxborn_installer import TuxbornInstallerScreen
from .modlist_tasks import ModlistTasksScreen from .modlist_tasks import ModlistTasksScreen
from .install_modlist import InstallModlistScreen from .install_modlist import InstallModlistScreen
from .configure_new_modlist import ConfigureNewModlistScreen from .configure_new_modlist import ConfigureNewModlistScreen
@@ -13,7 +12,6 @@ from .configure_existing_modlist import ConfigureExistingModlistScreen
__all__ = [ __all__ = [
'MainMenu', 'MainMenu',
'TuxbornInstallerScreen',
'ModlistTasksScreen', 'ModlistTasksScreen',
'InstallModlistScreen', 'InstallModlistScreen',
'ConfigureNewModlistScreen', 'ConfigureNewModlistScreen',

View File

@@ -34,11 +34,12 @@ class ConfigureExistingModlistScreen(QWidget):
self.stacked_widget = stacked_widget self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index self.main_menu_index = main_menu_index
self.debug = DEBUG_BORDERS self.debug = DEBUG_BORDERS
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Configure_Existing_Modlist_workflow.log') self.refresh_paths()
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
# --- Detect Steam Deck --- # --- 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) self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
# Initialize services early # Initialize services early
@@ -120,7 +121,7 @@ class ConfigureExistingModlistScreen(QWidget):
self.shortcut_combo.addItem("Please Select...") self.shortcut_combo.addItem("Please Select...")
self.shortcut_map = [] self.shortcut_map = []
for shortcut in self.mo2_shortcuts: for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})" display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
self.shortcut_combo.addItem(display) self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut) self.shortcut_map.append(shortcut)
@@ -297,6 +298,41 @@ class ConfigureExistingModlistScreen(QWidget):
# Time tracking for workflow completion # Time tracking for workflow completion
self._workflow_start_time = None self._workflow_start_time = None
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
self._actionable_controls = [
# Main action button
self.start_btn,
# Form fields
self.shortcut_combo,
# Resolution controls
self.resolution_combo,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during configure operations (except Cancel)"""
for control in self._actionable_controls:
if control:
control.setEnabled(False)
def _enable_controls_after_operation(self):
"""Re-enable all actionable controls after configure operations complete"""
for control in self._actionable_controls:
if control:
control.setEnabled(True)
def refresh_paths(self):
"""Refresh cached paths when config changes."""
from jackify.shared.paths import get_jackify_logs_dir
self.modlist_log_path = get_jackify_logs_dir() / 'Configure_Existing_Modlist_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def resizeEvent(self, event): def resizeEvent(self, event):
"""Handle window resize to prioritize form over console""" """Handle window resize to prioritize form over console"""
@@ -382,17 +418,22 @@ class ConfigureExistingModlistScreen(QWidget):
log_handler = LoggingHandler() log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
# Disable controls during configuration
self._disable_controls_during_operation()
# Get selected shortcut # Get selected shortcut
idx = self.shortcut_combo.currentIndex() - 1 # Account for 'Please Select...' idx = self.shortcut_combo.currentIndex() - 1 # Account for 'Please Select...'
from jackify.frontends.gui.services.message_service import MessageService from jackify.frontends.gui.services.message_service import MessageService
if idx < 0 or idx >= len(self.shortcut_map): if idx < 0 or idx >= len(self.shortcut_map):
MessageService.critical(self, "No Shortcut Selected", "Please select a ModOrganizer.exe Steam shortcut to configure.", safety_level="medium") MessageService.critical(self, "No Shortcut Selected", "Please select a ModOrganizer.exe Steam shortcut to configure.", safety_level="medium")
self._enable_controls_after_operation()
return return
shortcut = self.shortcut_map[idx] shortcut = self.shortcut_map[idx]
modlist_name = shortcut.get('AppName', '') modlist_name = shortcut.get('AppName', shortcut.get('appname', ''))
install_dir = shortcut.get('StartDir', '') install_dir = shortcut.get('StartDir', shortcut.get('startdir', ''))
if not modlist_name or not install_dir: if not modlist_name or not install_dir:
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium") MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
self._enable_controls_after_operation()
return return
resolution = self.resolution_combo.currentText() resolution = self.resolution_combo.currentText()
# Handle resolution saving # Handle resolution saving
@@ -460,6 +501,7 @@ class ConfigureExistingModlistScreen(QWidget):
# For existing modlists, add resolution if specified # For existing modlists, add resolution if specified
if self.resolution != "Leave unchanged": if self.resolution != "Leave unchanged":
modlist_context.resolution = self.resolution.split()[0] modlist_context.resolution = self.resolution.split()[0]
# Note: If "Leave unchanged" is selected, resolution stays None (no fallback needed)
# Define callbacks # Define callbacks
def progress_callback(message): def progress_callback(message):
@@ -505,6 +547,9 @@ class ConfigureExistingModlistScreen(QWidget):
def on_configuration_complete(self, success, message, modlist_name): def on_configuration_complete(self, success, message, modlist_name):
"""Handle configuration completion""" """Handle configuration completion"""
# Re-enable all controls when workflow completes
self._enable_controls_after_operation()
if success: if success:
# Calculate time taken # Calculate time taken
time_taken = self._calculate_time_taken() time_taken = self._calculate_time_taken()
@@ -525,6 +570,9 @@ class ConfigureExistingModlistScreen(QWidget):
def on_configuration_error(self, error_message): def on_configuration_error(self, error_message):
"""Handle configuration error""" """Handle configuration error"""
# Re-enable all controls on error
self._enable_controls_after_operation()
self._safe_append_text(f"Configuration error: {error_message}") self._safe_append_text(f"Configuration error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")
@@ -559,8 +607,8 @@ class ConfigureExistingModlistScreen(QWidget):
if self.config_process and self.config_process.state() == QProcess.Running: if self.config_process and self.config_process.state() == QProcess.Running:
self.config_process.terminate() self.config_process.terminate()
self.config_process.waitForFinished(2000) self.config_process.waitForFinished(2000)
# Reset button states # Re-enable all controls
self.start_btn.setEnabled(True) self._enable_controls_after_operation()
self.cancel_btn.setVisible(True) self.cancel_btn.setVisible(True)
def show_next_steps_dialog(self, message): def show_next_steps_dialog(self, message):
@@ -665,7 +713,7 @@ class ConfigureExistingModlistScreen(QWidget):
self.shortcut_map.clear() self.shortcut_map.clear()
for shortcut in self.mo2_shortcuts: for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})" display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
self.shortcut_combo.addItem(display) self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut) self.shortcut_map.append(shortcut)
@@ -693,6 +741,30 @@ class ConfigureExistingModlistScreen(QWidget):
else: else:
return f"{elapsed_seconds_remainder} seconds" 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): def cleanup(self):
"""Clean up any running threads when the screen is closed""" """Clean up any running threads when the screen is closed"""
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread") debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")

View File

@@ -1,7 +1,7 @@
""" """
ConfigureNewModlistScreen for Jackify GUI ConfigureNewModlistScreen for Jackify GUI
""" """
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
from PySide6.QtGui import QPixmap, QTextCursor from PySide6.QtGui import QPixmap, QTextCursor
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
@@ -22,6 +22,7 @@ from jackify.backend.handlers.config_handler import ConfigHandler
from ..dialogs import SuccessDialog from ..dialogs import SuccessDialog
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from jackify.frontends.gui.services.message_service import MessageService from jackify.frontends.gui.services.message_service import MessageService
from jackify.shared.resolution_utils import get_resolution_fallback
def debug_print(message): def debug_print(message):
"""Print debug message only if debug mode is enabled""" """Print debug message only if debug mode is enabled"""
@@ -106,8 +107,7 @@ class ConfigureNewModlistScreen(QWidget):
self.protontricks_service = ProtontricksDetectionService() self.protontricks_service = ProtontricksDetectionService()
# Path for workflow log # Path for workflow log
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Configure_New_Modlist_workflow.log') self.refresh_paths()
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
# Scroll tracking for professional auto-scroll behavior # Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False self._user_manually_scrolled = False
@@ -211,7 +211,6 @@ class ConfigureNewModlistScreen(QWidget):
"7680x4320" "7680x4320"
]) ])
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.resolution_combo, 2, 1)
# Load saved resolution if available # Load saved resolution if available
saved_resolution = self.resolution_service.get_saved_resolution() saved_resolution = self.resolution_service.get_saved_resolution()
@@ -236,6 +235,27 @@ class ConfigureNewModlistScreen(QWidget):
else: else:
self.resolution_combo.setCurrentIndex(0) self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0) # Otherwise, default is 'Leave unchanged' (index 0)
# Horizontal layout for resolution dropdown and auto-restart checkbox
resolution_and_restart_layout = QHBoxLayout()
resolution_and_restart_layout.setSpacing(12)
# Resolution dropdown (made smaller)
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
resolution_and_restart_layout.addWidget(self.resolution_combo)
# Add stretch to push checkbox to the right
resolution_and_restart_layout.addStretch()
# Auto-accept Steam restart checkbox (right-aligned)
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended configuration")
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
# Update the form grid to use the combined layout
form_grid.addLayout(resolution_and_restart_layout, 2, 1)
form_section_widget = QWidget() form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid) form_section_widget.setLayout(form_grid)
@@ -338,6 +358,44 @@ class ConfigureNewModlistScreen(QWidget):
self.start_btn.clicked.connect(self.validate_and_start_configure) self.start_btn.clicked.connect(self.validate_and_start_configure)
# --- Connect steam_restart_finished signal --- # --- Connect steam_restart_finished signal ---
self.steam_restart_finished.connect(self._on_steam_restart_finished) self.steam_restart_finished.connect(self._on_steam_restart_finished)
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
self._actionable_controls = [
# Main action button
self.start_btn,
# Form fields
self.modlist_name_edit,
self.install_dir_edit,
# Resolution controls
self.resolution_combo,
# Checkboxes
self.auto_restart_checkbox,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during configure operations (except Cancel)"""
for control in self._actionable_controls:
if control:
control.setEnabled(False)
def _enable_controls_after_operation(self):
"""Re-enable all actionable controls after configure operations complete"""
for control in self._actionable_controls:
if control:
control.setEnabled(True)
def refresh_paths(self):
"""Refresh cached paths when config changes."""
from jackify.shared.paths import get_jackify_logs_dir
self.modlist_log_path = get_jackify_logs_dir() / 'Configure_New_Modlist_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def resizeEvent(self, event): def resizeEvent(self, event):
"""Handle window resize to prioritize form over console""" """Handle window resize to prioritize form over console"""
@@ -423,7 +481,7 @@ class ConfigureNewModlistScreen(QWidget):
def go_back(self): def go_back(self):
if self.stacked_widget: if self.stacked_widget:
self.stacked_widget.setCurrentIndex(3) # Return to Modlist Tasks menu self.stacked_widget.setCurrentIndex(self.main_menu_index)
def update_top_panel(self): def update_top_panel(self):
try: try:
@@ -522,23 +580,40 @@ class ConfigureNewModlistScreen(QWidget):
# Start time tracking # Start time tracking
self._workflow_start_time = time.time() self._workflow_start_time = time.time()
# Disable controls during configuration (after validation passes)
self._disable_controls_during_operation()
# Validate modlist name # Validate modlist name
modlist_name = self.modlist_name_edit.text().strip() modlist_name = self.modlist_name_edit.text().strip()
if not modlist_name: if not modlist_name:
MessageService.warning(self, "Missing Name", "Please specify a name for your modlist", safety_level="low") MessageService.warning(self, "Missing Name", "Please specify a name for your modlist", safety_level="low")
self._enable_controls_after_operation()
return return
# --- Shortcut creation will be handled by automated workflow --- # --- Shortcut creation will be handled by automated workflow ---
from jackify.backend.handlers.shortcut_handler import ShortcutHandler 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 shortcut_handler = ShortcutHandler(steamdeck=steamdeck) # Still needed for Steam restart
# --- User confirmation before restarting Steam ---
reply = MessageService.question( # Check if auto-restart is enabled
self, "Ready to Configure Modlist", auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
"Would you like to restart Steam and begin post-install configuration now? Restarting Steam could close any games you have open!",
safety_level="medium" if auto_restart_enabled:
) # Auto-accept Steam restart - proceed without dialog
print(f"DEBUG: Steam restart dialog returned: {reply!r}") self._safe_append_text("Auto-accepting Steam restart (unattended mode enabled)")
reply = QMessageBox.Yes # Simulate user clicking Yes
else:
# --- User confirmation before restarting Steam ---
reply = MessageService.question(
self, "Ready to Configure Modlist",
"Would you like to restart Steam and begin post-install configuration now? Restarting Steam could close any games you have open!",
safety_level="medium"
)
debug_print(f"DEBUG: Steam restart dialog returned: {reply!r}")
if reply not in (QMessageBox.Yes, QMessageBox.Ok, QMessageBox.AcceptRole): if reply not in (QMessageBox.Yes, QMessageBox.Ok, QMessageBox.AcceptRole):
self._enable_controls_after_operation()
if self.stacked_widget: if self.stacked_widget:
self.stacked_widget.setCurrentIndex(0) self.stacked_widget.setCurrentIndex(0)
return return
@@ -562,7 +637,6 @@ class ConfigureNewModlistScreen(QWidget):
progress.setMinimumDuration(0) progress.setMinimumDuration(0)
progress.setValue(0) progress.setValue(0)
progress.show() progress.show()
self.setEnabled(False)
def do_restart(): def do_restart():
try: try:
ok = shortcut_handler.secure_steam_restart() ok = shortcut_handler.secure_steam_restart()
@@ -579,7 +653,7 @@ class ConfigureNewModlistScreen(QWidget):
if hasattr(self, '_steam_restart_progress'): if hasattr(self, '_steam_restart_progress'):
self._steam_restart_progress.close() self._steam_restart_progress.close()
del self._steam_restart_progress del self._steam_restart_progress
self.setEnabled(True) self._enable_controls_after_operation()
if success: if success:
self._safe_append_text("Steam restarted successfully.") self._safe_append_text("Steam restarted successfully.")
@@ -651,16 +725,10 @@ class ConfigureNewModlistScreen(QWidget):
except Exception as e: except Exception as e:
self.error_occurred.emit(str(e)) self.error_occurred.emit(str(e))
# Detect Steam Deck once # Detect Steam Deck once using centralized service
try: from jackify.backend.services.platform_detection_service import PlatformDetectionService
import os platform_service = PlatformDetectionService.get_instance()
_is_steamdeck = False _is_steamdeck = platform_service.is_steamdeck
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
# Create and start the thread # Create and start the thread
self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path, _is_steamdeck) self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path, _is_steamdeck)
@@ -722,7 +790,7 @@ class ConfigureNewModlistScreen(QWidget):
"""Handle error from the automated prefix workflow""" """Handle error from the automated prefix workflow"""
self._safe_append_text(f"Error during automated Steam setup: {error_message}") self._safe_append_text(f"Error during automated Steam setup: {error_message}")
self._safe_append_text("Please check the logs for details.") self._safe_append_text("Please check the logs for details.")
self.start_btn.setEnabled(True) self._enable_controls_after_operation()
def show_shortcut_conflict_dialog(self, conflicts): def show_shortcut_conflict_dialog(self, conflicts):
"""Show dialog to resolve shortcut name conflicts""" """Show dialog to resolve shortcut name conflicts"""
@@ -856,7 +924,10 @@ class ConfigureNewModlistScreen(QWidget):
# Steam assigns a NEW AppID during restart, different from the one we initially created # 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...") self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
from jackify.backend.handlers.shortcut_handler import ShortcutHandler 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) current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path)
if not current_appid or not current_appid.isdigit(): if not current_appid or not current_appid.isdigit():
@@ -880,7 +951,12 @@ class ConfigureNewModlistScreen(QWidget):
# Initialize ModlistHandler with correct parameters # Initialize ModlistHandler with correct parameters
path_handler = PathHandler() 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 # Set required properties manually after initialization
modlist_handler.modlist_dir = install_dir modlist_handler.modlist_dir = install_dir
@@ -962,7 +1038,7 @@ class ConfigureNewModlistScreen(QWidget):
try: try:
# Get resolution from UI # Get resolution from UI
resolution = self.resolution_combo.currentText() 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) # Update the context with the new AppID (same format as manual steps)
mo2_exe_path = self.install_dir_edit.text().strip() mo2_exe_path = self.install_dir_edit.text().strip()
@@ -1011,7 +1087,7 @@ class ConfigureNewModlistScreen(QWidget):
nexus_api_key='', # Not needed for configuration nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value'), modlist_value=self.context.get('modlist_value'),
modlist_source=self.context.get('modlist_source', 'identifier'), modlist_source=self.context.get('modlist_source', 'identifier'),
resolution=self.context.get('resolution', '2560x1600'), resolution=self.context.get('resolution') or get_resolution_fallback(None),
skip_confirmation=True skip_confirmation=True
) )
@@ -1112,7 +1188,7 @@ class ConfigureNewModlistScreen(QWidget):
nexus_api_key='', # Not needed for configuration nexus_api_key='', # Not needed for configuration
modlist_value='', # Not needed for existing modlist modlist_value='', # Not needed for existing modlist
modlist_source='existing', modlist_source='existing',
resolution=self.context.get('resolution'), resolution=self.context.get('resolution') or get_resolution_fallback(None),
skip_confirmation=True skip_confirmation=True
) )
@@ -1162,8 +1238,8 @@ class ConfigureNewModlistScreen(QWidget):
def on_configuration_complete(self, success, message, modlist_name): def on_configuration_complete(self, success, message, modlist_name):
"""Handle configuration completion (same as Tuxborn)""" """Handle configuration completion (same as Tuxborn)"""
# Always re-enable the start button when workflow completes # Re-enable all controls when workflow completes
self.start_btn.setEnabled(True) self._enable_controls_after_operation()
if success: if success:
# Calculate time taken # Calculate time taken
@@ -1185,8 +1261,8 @@ class ConfigureNewModlistScreen(QWidget):
def on_configuration_error(self, error_message): def on_configuration_error(self, error_message):
"""Handle configuration error""" """Handle configuration error"""
# Re-enable the start button on error # Re-enable all controls on error
self.start_btn.setEnabled(True) self._enable_controls_after_operation()
self._safe_append_text(f"Configuration error: {error_message}") self._safe_append_text(f"Configuration error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium") MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}", safety_level="medium")
@@ -1254,6 +1330,27 @@ class ConfigureNewModlistScreen(QWidget):
btn_exit.clicked.connect(on_exit) btn_exit.clicked.connect(on_exit)
dlg.exec() 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): def cleanup(self):
"""Clean up any running threads when the screen is closed""" """Clean up any running threads when the screen is closed"""
debug_print("DEBUG: cleanup called - cleaning up threads") debug_print("DEBUG: cleanup called - cleaning up threads")

View File

@@ -355,9 +355,8 @@ class InstallModlistScreen(QWidget):
self.online_modlists = {} # {game_type: [modlist_dict, ...]} self.online_modlists = {} # {game_type: [modlist_dict, ...]}
self.modlist_details = {} # {modlist_name: modlist_dict} self.modlist_details = {} # {modlist_name: modlist_dict}
# Path for workflow log # Initialize log path (can be refreshed via refresh_paths method)
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Modlist_Install_workflow.log') self.refresh_paths()
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
# Initialize services early # Initialize services early
from jackify.backend.services.api_key_service import APIKeyService from jackify.backend.services.api_key_service import APIKeyService
@@ -368,6 +367,10 @@ class InstallModlistScreen(QWidget):
self.resolution_service = ResolutionService() self.resolution_service = ResolutionService()
self.config_handler = ConfigHandler() self.config_handler = ConfigHandler()
self.protontricks_service = ProtontricksDetectionService() self.protontricks_service = ProtontricksDetectionService()
# Somnium guidance tracking
self._show_somnium_guidance = False
self._somnium_install_dir = None
# Scroll tracking for professional auto-scroll behavior # Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False self._user_manually_scrolled = False
@@ -459,11 +462,11 @@ class InstallModlistScreen(QWidget):
file_layout.setContentsMargins(0, 0, 0, 0) file_layout.setContentsMargins(0, 0, 0, 0)
self.file_edit = QLineEdit() self.file_edit = QLineEdit()
self.file_edit.setMinimumWidth(400) self.file_edit.setMinimumWidth(400)
file_btn = QPushButton("Browse") self.file_btn = QPushButton("Browse")
file_btn.clicked.connect(self.browse_wabbajack_file) self.file_btn.clicked.connect(self.browse_wabbajack_file)
file_layout.addWidget(QLabel(".wabbajack File:")) file_layout.addWidget(QLabel(".wabbajack File:"))
file_layout.addWidget(self.file_edit) file_layout.addWidget(self.file_edit)
file_layout.addWidget(file_btn) file_layout.addWidget(self.file_btn)
self.file_group.setLayout(file_layout) self.file_group.setLayout(file_layout)
file_tab_vbox.addWidget(self.file_group) file_tab_vbox.addWidget(self.file_group)
file_tab.setLayout(file_tab_vbox) file_tab.setLayout(file_tab_vbox)
@@ -484,22 +487,22 @@ class InstallModlistScreen(QWidget):
install_dir_label = QLabel("Install Directory:") install_dir_label = QLabel("Install Directory:")
self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir()) self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir())
self.install_dir_edit.setMaximumHeight(25) # Force compact height self.install_dir_edit.setMaximumHeight(25) # Force compact height
browse_install_btn = QPushButton("Browse") self.browse_install_btn = QPushButton("Browse")
browse_install_btn.clicked.connect(self.browse_install_dir) self.browse_install_btn.clicked.connect(self.browse_install_dir)
install_dir_hbox = QHBoxLayout() install_dir_hbox = QHBoxLayout()
install_dir_hbox.addWidget(self.install_dir_edit) install_dir_hbox.addWidget(self.install_dir_edit)
install_dir_hbox.addWidget(browse_install_btn) install_dir_hbox.addWidget(self.browse_install_btn)
form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(install_dir_hbox, 1, 1) form_grid.addLayout(install_dir_hbox, 1, 1)
# Downloads Dir # Downloads Dir
downloads_dir_label = QLabel("Downloads Directory:") downloads_dir_label = QLabel("Downloads Directory:")
self.downloads_dir_edit = QLineEdit(self.config_handler.get_modlist_downloads_base_dir()) self.downloads_dir_edit = QLineEdit(self.config_handler.get_modlist_downloads_base_dir())
self.downloads_dir_edit.setMaximumHeight(25) # Force compact height self.downloads_dir_edit.setMaximumHeight(25) # Force compact height
browse_downloads_btn = QPushButton("Browse") self.browse_downloads_btn = QPushButton("Browse")
browse_downloads_btn.clicked.connect(self.browse_downloads_dir) self.browse_downloads_btn.clicked.connect(self.browse_downloads_dir)
downloads_dir_hbox = QHBoxLayout() downloads_dir_hbox = QHBoxLayout()
downloads_dir_hbox.addWidget(self.downloads_dir_edit) downloads_dir_hbox.addWidget(self.downloads_dir_edit)
downloads_dir_hbox.addWidget(browse_downloads_btn) downloads_dir_hbox.addWidget(self.browse_downloads_btn)
form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(downloads_dir_hbox, 2, 1) form_grid.addLayout(downloads_dir_hbox, 2, 1)
# Nexus API Key # Nexus API Key
@@ -603,7 +606,25 @@ class InstallModlistScreen(QWidget):
self.resolution_combo.setCurrentIndex(0) self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0) # Otherwise, default is 'Leave unchanged' (index 0)
form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.resolution_combo, 5, 1)
# Horizontal layout for resolution dropdown and auto-restart checkbox
resolution_and_restart_layout = QHBoxLayout()
resolution_and_restart_layout.setSpacing(12)
# Resolution dropdown (made smaller)
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
resolution_and_restart_layout.addWidget(self.resolution_combo)
# Add stretch to push checkbox to the right
resolution_and_restart_layout.addStretch()
# Auto-accept Steam restart checkbox (right-aligned)
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation")
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
form_grid.addLayout(resolution_and_restart_layout, 5, 1)
form_section_widget = QWidget() form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid) form_section_widget.setLayout(form_grid)
@@ -723,6 +744,57 @@ class InstallModlistScreen(QWidget):
# Initialize process tracking # Initialize process tracking
self.process = None self.process = None
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
def _collect_actionable_controls(self):
"""Collect all actionable controls that should be disabled during operations (except Cancel)"""
self._actionable_controls = [
# Main action button
self.start_btn,
# Game/modlist selection
self.game_type_btn,
self.modlist_btn,
# Source tabs (entire tab widget)
self.source_tabs,
# Form fields
self.modlist_name_edit,
self.install_dir_edit,
self.downloads_dir_edit,
self.api_key_edit,
self.file_edit,
# Browse buttons
self.browse_install_btn,
self.browse_downloads_btn,
self.file_btn,
# Resolution controls
self.resolution_combo,
# Checkboxes
self.save_api_key_checkbox,
self.auto_restart_checkbox,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during install/configure operations (except Cancel)"""
for control in self._actionable_controls:
if control:
control.setEnabled(False)
def _enable_controls_after_operation(self):
"""Re-enable all actionable controls after install/configure operations complete"""
for control in self._actionable_controls:
if control:
control.setEnabled(True)
def refresh_paths(self):
"""Refresh cached paths when config changes."""
from jackify.shared.paths import get_jackify_logs_dir
self.modlist_log_path = get_jackify_logs_dir() / 'Modlist_Install_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
def _open_url_safe(self, url): def _open_url_safe(self, url):
"""Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller""" """Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller"""
@@ -745,19 +817,22 @@ class InstallModlistScreen(QWidget):
self.console.setMinimumHeight(50) # Keep minimum height for usability self.console.setMinimumHeight(50) # Keep minimum height for usability
def showEvent(self, event): def showEvent(self, event):
"""Called when the widget becomes visible - reload saved API key only""" """Called when the widget becomes visible - always reload saved API key"""
super().showEvent(event) super().showEvent(event)
# Reload saved API key if available and field is empty # Always reload saved API key to pick up changes from Settings dialog
if not self.api_key_edit.text().strip() or (self.api_key_is_obfuscated and not self.api_key_original_text.strip()): saved_key = self.api_key_service.get_saved_api_key()
saved_key = self.api_key_service.get_saved_api_key() if saved_key:
if saved_key: self.api_key_original_text = saved_key
self.api_key_original_text = saved_key self.api_key_edit.setText(saved_key)
self.api_key_edit.setText(saved_key) self.api_key_is_obfuscated = False # Start unobfuscated
self.api_key_is_obfuscated = False # Start unobfuscated # Set checkbox state
# Set checkbox state self.save_api_key_checkbox.setChecked(True)
self.save_api_key_checkbox.setChecked(True) # Immediately obfuscate saved keys (don't wait 3 seconds)
# Start obfuscation timer self._obfuscate_api_key()
self.api_key_obfuscation_timer.start(3000) elif not self.api_key_edit.text().strip():
# Only clear if no saved key and field is empty
self.api_key_original_text = ""
self.save_api_key_checkbox.setChecked(False)
# Do NOT load saved parent directories # Do NOT load saved parent directories
def _load_saved_parent_directories(self): def _load_saved_parent_directories(self):
@@ -982,7 +1057,7 @@ class InstallModlistScreen(QWidget):
def go_back(self): def go_back(self):
if self.stacked_widget: if self.stacked_widget:
self.stacked_widget.setCurrentIndex(3) # Return to Modlist Tasks menu self.stacked_widget.setCurrentIndex(self.main_menu_index)
def update_top_panel(self): def update_top_panel(self):
try: try:
@@ -1071,7 +1146,7 @@ class InstallModlistScreen(QWidget):
self.save_api_key_checkbox.setChecked(False) self.save_api_key_checkbox.setChecked(False)
debug_print("DEBUG: Failed to save API key immediately") debug_print("DEBUG: Failed to save API key immediately")
else: else:
self._show_api_key_feedback("Enter an API key first", is_success=False) self._show_api_key_feedback("Enter an API key first", is_success=False)
# Uncheck the checkbox since no key to save # Uncheck the checkbox since no key to save
self.save_api_key_checkbox.setChecked(False) self.save_api_key_checkbox.setChecked(False)
else: else:
@@ -1118,6 +1193,9 @@ class InstallModlistScreen(QWidget):
if not self._check_protontricks(): if not self._check_protontricks():
return return
# Disable all controls during installation (except Cancel)
self._disable_controls_during_operation()
try: try:
tab_index = self.source_tabs.currentIndex() tab_index = self.source_tabs.currentIndex()
install_mode = 'online' install_mode = 'online'
@@ -1125,12 +1203,14 @@ class InstallModlistScreen(QWidget):
modlist = self.file_edit.text().strip() modlist = self.file_edit.text().strip()
if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'): if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'):
MessageService.warning(self, "Invalid Modlist", "Please select a valid .wabbajack file.") MessageService.warning(self, "Invalid Modlist", "Please select a valid .wabbajack file.")
self._enable_controls_after_operation()
return return
install_mode = 'file' install_mode = 'file'
else: else:
modlist = self.modlist_btn.text().strip() modlist = self.modlist_btn.text().strip()
if not modlist or modlist in ("Select Modlist", "Fetching modlists...", "No modlists found.", "Error fetching modlists."): if not modlist or modlist in ("Select Modlist", "Fetching modlists...", "No modlists found.", "Error fetching modlists."):
MessageService.warning(self, "Invalid Modlist", "Please select a valid modlist.") MessageService.warning(self, "Invalid Modlist", "Please select a valid modlist.")
self._enable_controls_after_operation()
return return
# For online modlists, use machine_url instead of display name # For online modlists, use machine_url instead of display name
@@ -1156,6 +1236,7 @@ class InstallModlistScreen(QWidget):
missing_fields.append("Nexus API Key") missing_fields.append("Nexus API Key")
if missing_fields: if missing_fields:
MessageService.warning(self, "Missing Required Fields", f"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields)) MessageService.warning(self, "Missing Required Fields", f"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields))
self._enable_controls_after_operation()
return return
validation_handler = ValidationHandler() validation_handler = ValidationHandler()
from pathlib import Path from pathlib import Path
@@ -1279,7 +1360,8 @@ class InstallModlistScreen(QWidget):
'oblivion': 'oblivion', 'oblivion': 'oblivion',
'starfield': 'starfield', 'starfield': 'starfield',
'oblivion_remastered': 'oblivion_remastered', 'oblivion_remastered': 'oblivion_remastered',
'enderal': 'enderal' 'enderal': 'enderal',
'enderal special edition': 'enderal'
} }
game_type = game_mapping.get(game_name.lower()) game_type = game_mapping.get(game_name.lower())
debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'") debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
@@ -1296,6 +1378,7 @@ class InstallModlistScreen(QWidget):
# Check if game is supported # Check if game is supported
debug_print(f"DEBUG: Checking if game_type '{game_type}' 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 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}") debug_print(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
@@ -1321,14 +1404,11 @@ class InstallModlistScreen(QWidget):
debug_print(f"DEBUG: Exception in validate_and_start_install: {e}") debug_print(f"DEBUG: Exception in validate_and_start_install: {e}")
import traceback import traceback
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
# Re-enable the button in case of exception # Re-enable all controls after exception
self.start_btn.setEnabled(True) self._enable_controls_after_operation()
self.cancel_btn.setVisible(True) self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False) self.cancel_install_btn.setVisible(False)
# Also re-enable the entire widget debug_print(f"DEBUG: Controls re-enabled in exception handler")
self.setEnabled(True)
debug_print(f"DEBUG: Widget re-enabled in exception handler, widget enabled: {self.isEnabled()}")
print(f"DEBUG: Widget re-enabled in exception handler, widget enabled: {self.isEnabled()}") # Always print
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online'): def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online'):
debug_print('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER') debug_print('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
@@ -1498,12 +1578,21 @@ class InstallModlistScreen(QWidget):
self._safe_append_text(f"\nModlist installation completed successfully.") self._safe_append_text(f"\nModlist installation completed successfully.")
self._safe_append_text(f"\nWarning: Post-install configuration skipped for unsupported game: {game_name or game_type}") self._safe_append_text(f"\nWarning: Post-install configuration skipped for unsupported game: {game_name or game_type}")
else: else:
# Show the normal install complete dialog for supported games # Check if auto-restart is enabled
reply = MessageService.question( auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
self, "Modlist Install Complete!",
"Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!", if auto_restart_enabled:
critical=False # Non-critical, won't steal focus # Auto-accept Steam restart - proceed without dialog
) self._safe_append_text("\nAuto-accepting Steam restart (unattended mode enabled)")
reply = QMessageBox.Yes # Simulate user clicking Yes
else:
# Show the normal install complete dialog for supported games
reply = MessageService.question(
self, "Modlist Install Complete!",
"Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!",
critical=False # Non-critical, won't steal focus
)
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
# --- Create Steam shortcut BEFORE restarting Steam --- # --- Create Steam shortcut BEFORE restarting Steam ---
# Proceed directly to automated prefix creation # Proceed directly to automated prefix creation
@@ -1519,6 +1608,8 @@ class InstallModlistScreen(QWidget):
"You can manually add the modlist to Steam later if desired.", "You can manually add the modlist to Steam later if desired.",
safety_level="medium" safety_level="medium"
) )
# Re-enable controls since operation is complete
self._enable_controls_after_operation()
else: else:
# Check for user cancellation first # Check for user cancellation first
last_output = self.console.toPlainText() last_output = self.console.toPlainText()
@@ -1608,9 +1699,6 @@ class InstallModlistScreen(QWidget):
progress.setMinimumDuration(0) progress.setMinimumDuration(0)
progress.setValue(0) progress.setValue(0)
progress.show() progress.show()
self.setEnabled(False)
debug_print(f"DEBUG: Widget disabled in restart_steam_and_configure, widget enabled: {self.isEnabled()}")
print(f"DEBUG: Widget disabled in restart_steam_and_configure, widget enabled: {self.isEnabled()}") # Always print
def do_restart(): def do_restart():
debug_print("DEBUG: do_restart thread started - using direct backend service") debug_print("DEBUG: do_restart thread started - using direct backend service")
@@ -1648,9 +1736,7 @@ class InstallModlistScreen(QWidget):
finally: finally:
self._steam_restart_progress = None self._steam_restart_progress = None
self.setEnabled(True) # Controls are managed by the proper control management system
debug_print(f"DEBUG: Widget re-enabled in _on_steam_restart_finished, widget enabled: {self.isEnabled()}")
print(f"DEBUG: Widget re-enabled in _on_steam_restart_finished, widget enabled: {self.isEnabled()}") # Always print
if success: if success:
self._safe_append_text("Steam restarted successfully.") self._safe_append_text("Steam restarted successfully.")
@@ -1660,7 +1746,14 @@ class InstallModlistScreen(QWidget):
# Save resolution for later use in configuration # Save resolution for later use in configuration
resolution = self.resolution_combo.currentText() resolution = self.resolution_combo.currentText()
self._current_resolution = resolution.split()[0] if resolution != "Leave unchanged" else "2560x1600" # 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 # Use automated prefix creation instead of manual steps
debug_print("DEBUG: Starting automated prefix creation workflow") debug_print("DEBUG: Starting automated prefix creation workflow")
@@ -1671,17 +1764,46 @@ class InstallModlistScreen(QWidget):
MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.") MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.")
def start_automated_prefix_workflow(self): def start_automated_prefix_workflow(self):
# 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
"""Start the automated prefix creation workflow""" """Start the automated prefix creation workflow"""
try: try:
# Disable controls during installation
self._disable_controls_during_operation()
modlist_name = self.modlist_name_edit.text().strip() modlist_name = self.modlist_name_edit.text().strip()
install_dir = self.install_dir_edit.text().strip() install_dir = self.install_dir_edit.text().strip()
final_exe_path = os.path.join(install_dir, "ModOrganizer.exe") final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
if not os.path.exists(final_exe_path): if not os.path.exists(final_exe_path):
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}") # Check if this is Somnium specifically (uses files/ subdirectory)
MessageService.critical(self, "ModOrganizer.exe Not Found", modlist_name_lower = modlist_name.lower()
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.") if "somnium" in modlist_name_lower:
return 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 # Run automated prefix creation in separate thread
from PySide6.QtCore import QThread, Signal from PySide6.QtCore import QThread, Signal
@@ -1781,33 +1903,43 @@ class InstallModlistScreen(QWidget):
import traceback import traceback
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}") self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}")
# Re-enable controls on exception
self._enable_controls_after_operation()
def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None): def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None):
"""Handle completion of automated prefix creation""" """Handle completion of automated prefix creation"""
if success: try:
debug_print(f"SUCCESS: Automated prefix creation completed!") if success:
debug_print(f"Prefix created at: {prefix_path}") debug_print(f"SUCCESS: Automated prefix creation completed!")
if new_appid_str and new_appid_str != "0": debug_print(f"Prefix created at: {prefix_path}")
debug_print(f"AppID: {new_appid_str}") if new_appid_str and new_appid_str != "0":
debug_print(f"AppID: {new_appid_str}")
# Convert string AppID back to integer for configuration
new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None # Convert string AppID back to integer for configuration
new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None
# Continue with configuration using the new AppID and timestamp
modlist_name = self.modlist_name_edit.text().strip() # Continue with configuration using the new AppID and timestamp
install_dir = self.install_dir_edit.text().strip() modlist_name = self.modlist_name_edit.text().strip()
self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp) install_dir = self.install_dir_edit.text().strip()
else: self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
self._safe_append_text(f"ERROR: Automated prefix creation failed") else:
self._safe_append_text("Please check the logs for details") self._safe_append_text(f"ERROR: Automated prefix creation failed")
MessageService.critical(self, "Automated Setup Failed", self._safe_append_text("Please check the logs for details")
"Automated prefix creation failed. Please check the console output for details.") MessageService.critical(self, "Automated Setup Failed",
"Automated prefix creation failed. Please check the console output for details.")
# Re-enable controls on failure
self._enable_controls_after_operation()
finally:
# Always ensure controls are re-enabled when workflow truly completes
pass
def on_automated_prefix_error(self, error_msg): def on_automated_prefix_error(self, error_msg):
"""Handle error in automated prefix creation""" """Handle error in automated prefix creation"""
self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}") self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}")
MessageService.critical(self, "Automated Setup Error", MessageService.critical(self, "Automated Setup Error",
f"Error during automated prefix creation: {error_msg}") f"Error during automated prefix creation: {error_msg}")
# Re-enable controls on error
self._enable_controls_after_operation()
def on_automated_prefix_progress(self, progress_msg): def on_automated_prefix_progress(self, progress_msg):
"""Handle progress updates from automated prefix creation""" """Handle progress updates from automated prefix creation"""
@@ -1828,7 +1960,6 @@ class InstallModlistScreen(QWidget):
self.steam_restart_progress.setMinimumDuration(0) self.steam_restart_progress.setMinimumDuration(0)
self.steam_restart_progress.setValue(0) self.steam_restart_progress.setValue(0)
self.steam_restart_progress.show() self.steam_restart_progress.show()
self.setEnabled(False)
def hide_steam_restart_progress(self): def hide_steam_restart_progress(self):
"""Hide Steam restart progress dialog""" """Hide Steam restart progress dialog"""
@@ -1840,45 +1971,57 @@ class InstallModlistScreen(QWidget):
pass pass
finally: finally:
self.steam_restart_progress = None self.steam_restart_progress = None
self.setEnabled(True) # Controls are managed by the proper control management system
def on_configuration_complete(self, success, message, modlist_name): def on_configuration_complete(self, success, message, modlist_name):
"""Handle configuration completion on main thread""" """Handle configuration completion on main thread"""
if success: try:
# Show celebration SuccessDialog after the entire workflow # Re-enable controls now that installation/configuration is complete
from ..dialogs import SuccessDialog self._enable_controls_after_operation()
import time
if not hasattr(self, '_install_workflow_start_time'): if success:
self._install_workflow_start_time = time.time() # Check if we need to show Somnium guidance
time_taken = int(time.time() - self._install_workflow_start_time) if self._show_somnium_guidance:
mins, secs = divmod(time_taken, 60) self._show_somnium_post_install_guidance()
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
display_names = { # Show celebration SuccessDialog after the entire workflow
'skyrim': 'Skyrim', from ..dialogs import SuccessDialog
'fallout4': 'Fallout 4', import time
'falloutnv': 'Fallout New Vegas', if not hasattr(self, '_install_workflow_start_time'):
'oblivion': 'Oblivion', self._install_workflow_start_time = time.time()
'starfield': 'Starfield', time_taken = int(time.time() - self._install_workflow_start_time)
'oblivion_remastered': 'Oblivion Remastered', mins, secs = divmod(time_taken, 60)
'enderal': 'Enderal' time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
} display_names = {
game_name = display_names.get(self._current_game_type, self._current_game_name) 'skyrim': 'Skyrim',
success_dialog = SuccessDialog( 'fallout4': 'Fallout 4',
modlist_name=modlist_name, 'falloutnv': 'Fallout New Vegas',
workflow_type="install", 'oblivion': 'Oblivion',
time_taken=time_str, 'starfield': 'Starfield',
game_name=game_name, 'oblivion_remastered': 'Oblivion Remastered',
parent=self 'enderal': 'Enderal'
) }
success_dialog.show() game_name = display_names.get(self._current_game_type, self._current_game_name)
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3: success_dialog = SuccessDialog(
# Max retries reached - show failure message modlist_name=modlist_name,
MessageService.critical(self, "Manual Steps Failed", workflow_type="install",
"Manual steps validation failed after multiple attempts.") time_taken=time_str,
else: game_name=game_name,
# Configuration failed for other reasons parent=self
MessageService.critical(self, "Configuration Failed", )
"Post-install configuration failed. Please check the console output.") success_dialog.show()
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
# Max retries reached - show failure message
MessageService.critical(self, "Manual Steps Failed",
"Manual steps validation failed after multiple attempts.")
else:
# Configuration failed for other reasons
MessageService.critical(self, "Configuration Failed",
"Post-install configuration failed. Please check the console output.")
except Exception as e:
# Ensure controls are re-enabled even on unexpected errors
self._enable_controls_after_operation()
raise
# Clean up thread # Clean up thread
if hasattr(self, 'config_thread') and self.config_thread is not None: if hasattr(self, 'config_thread') and self.config_thread is not None:
# Disconnect all signals to prevent "Internal C++ object already deleted" errors # Disconnect all signals to prevent "Internal C++ object already deleted" errors
@@ -1898,7 +2041,10 @@ class InstallModlistScreen(QWidget):
"""Handle configuration error on main thread""" """Handle configuration error on main thread"""
self._safe_append_text(f"Configuration failed with error: {error_message}") self._safe_append_text(f"Configuration failed with error: {error_message}")
MessageService.critical(self, "Configuration Error", f"Configuration failed: {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 # Clean up thread
if hasattr(self, 'config_thread') and self.config_thread is not None: if hasattr(self, 'config_thread') and self.config_thread is not None:
# Disconnect all signals to prevent "Internal C++ object already deleted" errors # Disconnect all signals to prevent "Internal C++ object already deleted" errors
@@ -1937,16 +2083,25 @@ class InstallModlistScreen(QWidget):
else: else:
# User clicked Cancel or closed the dialog - cancel the workflow # User clicked Cancel or closed the dialog - cancel the workflow
self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.") self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.")
# Reset button states # Re-enable all controls when workflow is cancelled
self.start_btn.setEnabled(True) self._enable_controls_after_operation()
self.cancel_btn.setVisible(True) self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False) 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): def validate_manual_steps_completion(self):
"""Validate that manual steps were actually completed and handle retry logic""" """Validate that manual steps were actually completed and handle retry logic"""
modlist_name = self.modlist_name_edit.text().strip() modlist_name = self.modlist_name_edit.text().strip()
install_dir = self.install_dir_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 # Add delay to allow Steam filesystem updates to complete
self._safe_append_text("Waiting for Steam filesystem updates to complete...") self._safe_append_text("Waiting for Steam filesystem updates to complete...")
@@ -1957,7 +2112,10 @@ class InstallModlistScreen(QWidget):
# Steam assigns a NEW AppID during restart, different from the one we initially created # 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...") self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
from jackify.backend.handlers.shortcut_handler import ShortcutHandler 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) current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path)
if not current_appid or not current_appid.isdigit(): if not current_appid or not current_appid.isdigit():
@@ -1978,7 +2136,12 @@ class InstallModlistScreen(QWidget):
# Initialize ModlistHandler with correct parameters # Initialize ModlistHandler with correct parameters
path_handler = PathHandler() 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 # Set required properties manually after initialization
modlist_handler.modlist_dir = install_dir modlist_handler.modlist_dir = install_dir
@@ -2184,10 +2347,10 @@ class InstallModlistScreen(QWidget):
updated_context = { updated_context = {
'name': modlist_name, 'name': modlist_name,
'path': install_dir, '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_value': None,
'modlist_source': None, 'modlist_source': None,
'resolution': getattr(self, '_current_resolution', '2560x1600'), 'resolution': getattr(self, '_current_resolution', None),
'skip_confirmation': True, 'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed since automated prefix is done 'manual_steps_completed': True, # Mark as completed since automated prefix is done
'appid': new_appid, # Use the NEW AppID from automated prefix creation 'appid': new_appid, # Use the NEW AppID from automated prefix creation
@@ -2196,15 +2359,21 @@ class InstallModlistScreen(QWidget):
self.context = updated_context # Ensure context is always set self.context = updated_context # Ensure context is always set
debug_print(f"Updated context with new AppID: {new_appid}") 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 # Create new config thread with updated context
class ConfigThread(QThread): class ConfigThread(QThread):
progress_update = Signal(str) progress_update = Signal(str)
configuration_complete = Signal(bool, str, str) configuration_complete = Signal(bool, str, str)
error_occurred = Signal(str) error_occurred = Signal(str)
def __init__(self, context): def __init__(self, context, is_steamdeck):
super().__init__() super().__init__()
self.context = context self.context = context
self.is_steamdeck = is_steamdeck
def run(self): def run(self):
try: try:
@@ -2213,8 +2382,8 @@ class InstallModlistScreen(QWidget):
from jackify.backend.models.modlist import ModlistContext from jackify.backend.models.modlist import ModlistContext
from pathlib import Path from pathlib import Path
# Initialize backend service # Initialize backend service with passed Steam Deck detection
system_info = SystemInfo(is_steamdeck=False) system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
modlist_service = ModlistService(system_info) modlist_service = ModlistService(system_info)
# Convert context to ModlistContext for service # Convert context to ModlistContext for service
@@ -2226,7 +2395,7 @@ class InstallModlistScreen(QWidget):
nexus_api_key='', # Not needed for configuration nexus_api_key='', # Not needed for configuration
modlist_value=self.context.get('modlist_value'), modlist_value=self.context.get('modlist_value'),
modlist_source=self.context.get('modlist_source', 'identifier'), modlist_source=self.context.get('modlist_source', 'identifier'),
resolution=self.context.get('resolution', '2560x1600'), resolution=self.context.get('resolution'),
skip_confirmation=True, skip_confirmation=True,
engine_installed=True # Skip path manipulation for engine workflows engine_installed=True # Skip path manipulation for engine workflows
) )
@@ -2245,7 +2414,7 @@ class InstallModlistScreen(QWidget):
# This shouldn't happen since automated prefix creation is complete # This shouldn't happen since automated prefix creation is complete
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") 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( result = modlist_service.configure_modlist_post_steam(
context=modlist_context, context=modlist_context,
progress_callback=progress_callback, progress_callback=progress_callback,
@@ -2261,7 +2430,7 @@ class InstallModlistScreen(QWidget):
self.error_occurred.emit(str(e)) self.error_occurred.emit(str(e))
# Start configuration thread # 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.progress_update.connect(self.on_configuration_progress)
self.config_thread.configuration_complete.connect(self.on_configuration_complete) self.config_thread.configuration_complete.connect(self.on_configuration_complete)
self.config_thread.error_occurred.connect(self.on_configuration_error) self.config_thread.error_occurred.connect(self.on_configuration_error)
@@ -2282,10 +2451,10 @@ class InstallModlistScreen(QWidget):
updated_context = { updated_context = {
'name': modlist_name, 'name': modlist_name,
'path': install_dir, '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_value': None,
'modlist_source': None, 'modlist_source': None,
'resolution': getattr(self, '_current_resolution', '2560x1600'), 'resolution': getattr(self, '_current_resolution', None),
'skip_confirmation': True, 'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed 'manual_steps_completed': True, # Mark as completed
'appid': new_appid # Use the NEW AppID from Steam 'appid': new_appid # Use the NEW AppID from Steam
@@ -2322,15 +2491,21 @@ class InstallModlistScreen(QWidget):
def _create_config_thread(self, context): def _create_config_thread(self, context):
"""Create a new ConfigThread with proper lifecycle management""" """Create a new ConfigThread with proper lifecycle management"""
from PySide6.QtCore import QThread, Signal 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): class ConfigThread(QThread):
progress_update = Signal(str) progress_update = Signal(str)
configuration_complete = Signal(bool, str, str) configuration_complete = Signal(bool, str, str)
error_occurred = Signal(str) error_occurred = Signal(str)
def __init__(self, context, parent=None): def __init__(self, context, is_steamdeck, parent=None):
super().__init__(parent) super().__init__(parent)
self.context = context self.context = context
self.is_steamdeck = is_steamdeck
def run(self): def run(self):
try: try:
@@ -2339,8 +2514,8 @@ class InstallModlistScreen(QWidget):
from jackify.backend.models.modlist import ModlistContext from jackify.backend.models.modlist import ModlistContext
from pathlib import Path from pathlib import Path
# Initialize backend service # Initialize backend service with passed Steam Deck detection
system_info = SystemInfo(is_steamdeck=False) system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
modlist_service = ModlistService(system_info) modlist_service = ModlistService(system_info)
# Convert context to ModlistContext for service # Convert context to ModlistContext for service
@@ -2389,7 +2564,7 @@ class InstallModlistScreen(QWidget):
self.progress_update.emit(f"DEBUG: {error_details}") self.progress_update.emit(f"DEBUG: {error_details}")
self.error_occurred.emit(str(e)) 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): def handle_validation_failure(self, missing_text):
"""Handle failed validation with retry logic""" """Handle failed validation with retry logic"""
@@ -2510,18 +2685,68 @@ class InstallModlistScreen(QWidget):
# Cleanup any remaining processes # Cleanup any remaining processes
self.cleanup_processes() self.cleanup_processes()
# Reset button states # Reset button states and re-enable all controls
self.start_btn.setEnabled(True) self._enable_controls_after_operation()
self.cancel_btn.setVisible(True) self.cancel_btn.setVisible(True)
self.cancel_install_btn.setVisible(False) self.cancel_install_btn.setVisible(False)
self._safe_append_text("Installation cancelled by user.") 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): def cancel_and_cleanup(self):
"""Handle Cancel button - clean up processes and go back""" """Handle Cancel button - clean up processes and go back"""
self.cleanup_processes() self.cleanup_processes()
self.go_back() self.go_back()
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()
def closeEvent(self, event): def closeEvent(self, event):
"""Handle window close event - clean up processes""" """Handle window close event - clean up processes"""
self.cleanup_processes() self.cleanup_processes()

View File

@@ -120,7 +120,7 @@ class MainMenu(QWidget):
msg.setIcon(QMessageBox.Information) msg.setIcon(QMessageBox.Information)
msg.exec() msg.exec()
elif action_id == "modlist_tasks" and self.stacked_widget: elif action_id == "modlist_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(3) self.stacked_widget.setCurrentIndex(2)
elif action_id == "return_main_menu": elif action_id == "return_main_menu":
# This is the main menu, so do nothing # This is the main menu, so do nothing
pass pass

View File

@@ -198,11 +198,11 @@ class ModlistTasksScreen(QWidget):
if action_id == "return_main_menu": if action_id == "return_main_menu":
self.stacked_widget.setCurrentIndex(0) self.stacked_widget.setCurrentIndex(0)
elif action_id == "install_modlist": elif action_id == "install_modlist":
self.stacked_widget.setCurrentIndex(4) self.stacked_widget.setCurrentIndex(3)
elif action_id == "configure_new_modlist": elif action_id == "configure_new_modlist":
self.stacked_widget.setCurrentIndex(5) self.stacked_widget.setCurrentIndex(4)
elif action_id == "configure_existing_modlist": elif action_id == "configure_existing_modlist":
self.stacked_widget.setCurrentIndex(6) self.stacked_widget.setCurrentIndex(5)
def go_back(self): def go_back(self):
"""Return to main menu""" """Return to main menu"""

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,97 @@
"""
AppImage utilities for self-updating functionality.
This module provides utilities for detecting if Jackify is running as an AppImage
and getting the path to the current AppImage file.
"""
import os
import sys
from pathlib import Path
from typing import Optional
def is_appimage() -> bool:
"""
Check if Jackify is currently running as an AppImage.
Returns:
bool: True if running as AppImage, False otherwise
"""
return 'APPIMAGE' in os.environ
def get_appimage_path() -> Optional[Path]:
"""
Get the path to the current AppImage file.
This uses the APPIMAGE environment variable set by the AppImage runtime.
This is the standard, reliable method for AppImage path detection.
For security, this validates that the AppImage is actually Jackify to prevent
accidentally updating other AppImages when running from development environments.
Returns:
Optional[Path]: Path to the AppImage file if running as Jackify AppImage, None otherwise
"""
if not is_appimage():
return None
appimage_path = os.environ.get('APPIMAGE')
if appimage_path and os.path.exists(appimage_path):
path = Path(appimage_path)
# Validate this is actually a Jackify AppImage to prevent updating wrong apps
if 'jackify' in path.name.lower():
return path
else:
# Running from different AppImage (e.g., development in Cursor.AppImage)
return None
return None
def can_self_update() -> bool:
"""
Check if self-updating is possible.
Returns:
bool: True if self-updating is possible, False otherwise
"""
appimage_path = get_appimage_path()
if not appimage_path:
return False
# Check if we can write to the AppImage file (for replacement)
try:
return os.access(appimage_path, os.W_OK)
except (OSError, PermissionError):
return False
def get_appimage_info() -> dict:
"""
Get information about the current AppImage.
Returns:
dict: Information about the AppImage including path, writability, etc.
"""
appimage_path = get_appimage_path()
info = {
'is_appimage': is_appimage(),
'path': appimage_path,
'can_update': can_self_update(),
'size_mb': None,
'writable': False
}
if appimage_path and appimage_path.exists():
try:
stat = appimage_path.stat()
info['size_mb'] = round(stat.st_size / (1024 * 1024), 1)
info['writable'] = os.access(appimage_path, os.W_OK)
except (OSError, PermissionError):
pass
return info

View File

@@ -14,15 +14,21 @@ import shutil
class LoggingHandler: class LoggingHandler:
""" """
Central logging handler for Jackify. Central logging handler for Jackify.
- Uses ~/Jackify/logs/ as the log directory. - Uses configurable Jackify data directory for logs (default: ~/Jackify/logs/).
- Supports per-function log files (e.g., jackify-install-wabbajack.log). - Supports per-function log files (e.g., jackify-install-wabbajack.log).
- Handles log rotation and log directory creation. - Handles log rotation and log directory creation.
Usage: Usage:
logger = LoggingHandler().setup_logger('install_wabbajack', 'jackify-install-wabbajack.log') logger = LoggingHandler().setup_logger('install_wabbajack', 'jackify-install-wabbajack.log')
""" """
def __init__(self): def __init__(self):
self.log_dir = Path.home() / "Jackify" / "logs" # Don't cache log_dir - use property to get fresh path each time
self.ensure_log_directory() self.ensure_log_directory()
@property
def log_dir(self):
"""Get the current log directory (may change if config updated)."""
from jackify.shared.paths import get_jackify_logs_dir
return get_jackify_logs_dir()
def ensure_log_directory(self) -> None: def ensure_log_directory(self) -> None:
"""Ensure the log directory exists.""" """Ensure the log directory exists."""

File diff suppressed because it is too large Load Diff

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

@@ -51,9 +51,15 @@ def _clear_screen_fallback():
def print_jackify_banner(): def print_jackify_banner():
"""Print the Jackify application banner""" """Print the Jackify application banner"""
print(""" from jackify import __version__
version_text = f"Jackify CLI ({__version__})"
# Center the version text in the banner (72 chars content width)
padding = (72 - len(version_text)) // 2
centered_version = " " * padding + version_text + " " * (72 - len(version_text) - padding)
print(f"""
╔════════════════════════════════════════════════════════════════════════╗ ╔════════════════════════════════════════════════════════════════════════╗
Jackify CLI (pre-alpha) {centered_version}
║ ║ ║ ║
║ A tool for installing and configuring modlists ║ ║ A tool for installing and configuring modlists ║
║ & associated utilities on Linux ║ ║ & associated utilities on Linux ║

View File

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