mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06bd94d119 | ||
|
|
52806f4116 | ||
|
|
956ea24465 | ||
|
|
f039cf9c24 | ||
|
|
d9ea1be347 | ||
|
|
a8862475d4 | ||
|
|
430d085287 | ||
|
|
7212a58480 | ||
|
|
80914bc76f | ||
|
|
8661f8963e | ||
|
|
f46ed2c0fe | ||
|
|
c9bd6f60e6 | ||
|
|
28cde64887 | ||
|
|
64c76046ce | ||
|
|
4eb1d63de7 | ||
|
|
8131e23057 | ||
|
|
1cd4caf04b | ||
|
|
e005f56bdb | ||
|
|
1f84fc7c68 | ||
|
|
70b18004e1 | ||
|
|
0b6e32beac | ||
|
|
c20a27dd90 | ||
|
|
cac4411137 | ||
|
|
2e4cdc2854 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,7 +35,7 @@ Thumbs.db
|
||||
docs/
|
||||
testing/
|
||||
|
||||
# PyInstaller build files (development only)
|
||||
# Build files (development only)
|
||||
*.spec
|
||||
hook-*.py
|
||||
requirements-packaging.txt
|
||||
|
||||
272
CHANGELOG.md
272
CHANGELOG.md
@@ -1,5 +1,277 @@
|
||||
# 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
|
||||
**Release Date:** September 11, 2025
|
||||
|
||||
|
||||
@@ -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 "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
|
||||
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 "This can help resolve certain graphics issues with Wabbajack running under Proton."
|
||||
exit 0
|
||||
|
||||
@@ -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 "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
|
||||
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 "This can help resolve certain graphics issues with Wabbajack running under Proton."
|
||||
exit 0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -93,7 +93,7 @@ For a complete step-by-step guide with screenshots, see the [User Guide](https:/
|
||||
|
||||
### 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`
|
||||
3. **Run**: `chmod +x Jackify.AppImage && ./Jackify.AppImage`
|
||||
4. **Install**: Choose "Install a Modlist", select your game and modlist, configure directories and API key
|
||||
|
||||
@@ -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 "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
|
||||
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 "This can help resolve certain graphics issues with Wabbajack running under Proton."
|
||||
exit 0
|
||||
|
||||
@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
|
||||
Wabbajack modlists natively on Linux systems.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.6.5"
|
||||
|
||||
@@ -23,6 +23,44 @@ from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
|
||||
# UI Colors already imported above
|
||||
|
||||
def _get_user_proton_version():
|
||||
"""Get user's preferred Proton version from config, with fallback to auto-detection"""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
config_handler = ConfigHandler()
|
||||
user_proton_path = config_handler.get_proton_path()
|
||||
|
||||
if user_proton_path == 'auto':
|
||||
# Use enhanced fallback logic with GE-Proton preference
|
||||
logging.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence")
|
||||
return WineUtils.select_best_proton()
|
||||
else:
|
||||
# User has selected a specific Proton version
|
||||
# Use the exact directory name for Steam config.vdf
|
||||
try:
|
||||
proton_version = os.path.basename(user_proton_path)
|
||||
# GE-Proton uses exact directory name, Valve Proton needs lowercase conversion
|
||||
if proton_version.startswith('GE-Proton'):
|
||||
# Keep GE-Proton name exactly as-is
|
||||
steam_proton_name = proton_version
|
||||
else:
|
||||
# Convert Valve Proton names to Steam's format
|
||||
steam_proton_name = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
|
||||
if not steam_proton_name.startswith('proton'):
|
||||
steam_proton_name = f"proton_{steam_proton_name}"
|
||||
|
||||
logging.info(f"Using user-selected Proton: {steam_proton_name}")
|
||||
return steam_proton_name
|
||||
except Exception as e:
|
||||
logging.warning(f"Invalid user Proton path '{user_proton_path}', falling back to auto: {e}")
|
||||
return WineUtils.select_best_proton()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get user Proton preference, using default: {e}")
|
||||
return "proton_experimental"
|
||||
|
||||
# Attempt to import readline for tab completion
|
||||
READLINE_AVAILABLE = False
|
||||
try:
|
||||
@@ -104,8 +142,8 @@ class ModlistInstallCLI:
|
||||
|
||||
if isinstance(menu_handler_or_system_info, SystemInfo):
|
||||
# GUI frontend initialization pattern
|
||||
system_info = menu_handler_or_system_info
|
||||
self.steamdeck = system_info.is_steamdeck
|
||||
self.system_info = menu_handler_or_system_info
|
||||
self.steamdeck = self.system_info.is_steamdeck
|
||||
|
||||
# Initialize menu_handler for GUI mode
|
||||
from ..handlers.menu_handler import MenuHandler
|
||||
@@ -114,8 +152,11 @@ class ModlistInstallCLI:
|
||||
# CLI frontend initialization pattern
|
||||
self.menu_handler = menu_handler_or_system_info
|
||||
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.context = {}
|
||||
# Use standard logging (no file handler)
|
||||
@@ -689,6 +730,14 @@ class ModlistInstallCLI:
|
||||
cmd += ['-m', self.context['machineid']]
|
||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||
|
||||
# Add debug flag if debug mode is enabled
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if debug_mode:
|
||||
cmd.append('--debug')
|
||||
self.logger.info("Adding --debug flag to jackify-engine")
|
||||
|
||||
# Store original environment values to restore later
|
||||
original_env_values = {
|
||||
'NEXUS_API_KEY': os.environ.get('NEXUS_API_KEY'),
|
||||
@@ -914,6 +963,20 @@ class ModlistInstallCLI:
|
||||
|
||||
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
|
||||
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
|
||||
)
|
||||
|
||||
# Handle the result
|
||||
if isinstance(result, tuple) and len(result) == 3:
|
||||
# Handle the result (same logic as GUI)
|
||||
if isinstance(result, tuple) and len(result) == 4:
|
||||
if result[0] == "CONFLICT":
|
||||
# Handle conflict
|
||||
conflicts = result[1]
|
||||
@@ -984,8 +1047,8 @@ class ModlistInstallCLI:
|
||||
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
|
||||
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:
|
||||
@@ -1000,10 +1063,58 @@ class ModlistInstallCLI:
|
||||
print(f"{COLOR_ERROR}Invalid choice. Cancelling.{COLOR_RESET}")
|
||||
return
|
||||
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
|
||||
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:
|
||||
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}")
|
||||
if app_id:
|
||||
print(f"{COLOR_INFO}Steam AppID: {app_id}{COLOR_RESET}")
|
||||
return
|
||||
# Continue to configuration phase
|
||||
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
|
||||
print(f"\n{COLOR_INFO}Using manual Steam setup workflow...{COLOR_RESET}")
|
||||
# Step 3: Use SAME backend service as GUI
|
||||
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
|
||||
from ..handlers.shortcut_handler import ShortcutHandler
|
||||
shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=False)
|
||||
|
||||
# Create nxmhandler.ini to suppress NXM popup
|
||||
shortcut_handler.write_nxmhandler_ini(install_dir_str, mo2_exe_path)
|
||||
|
||||
# Create shortcut with working NativeSteamService
|
||||
from ..services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
success, app_id = steam_service.create_shortcut_with_proton(
|
||||
app_name=shortcut_name,
|
||||
exe_path=mo2_exe_path,
|
||||
start_dir=os.path.dirname(mo2_exe_path),
|
||||
launch_options="%command%",
|
||||
tags=["Jackify"],
|
||||
proton_version="proton_experimental"
|
||||
# Create ModlistContext with engine_installed=True (same as GUI)
|
||||
modlist_context = ModlistContext(
|
||||
name=shortcut_name,
|
||||
install_dir=Path(install_dir_str),
|
||||
download_dir=Path(install_dir_str) / "downloads", # Standard location
|
||||
game_type=self.context.get('detected_game', 'Unknown'),
|
||||
nexus_api_key='', # Not needed for configuration
|
||||
modlist_value=self.context.get('modlist_value', ''),
|
||||
modlist_source=self.context.get('modlist_source', 'identifier'),
|
||||
resolution=self.context.get('resolution'),
|
||||
mo2_exe_path=Path(mo2_exe_path),
|
||||
skip_confirmation=True, # Always skip confirmation in CLI
|
||||
engine_installed=True # Skip path manipulation for engine workflows
|
||||
)
|
||||
|
||||
if not success or not app_id:
|
||||
self.logger.error("Failed to create Steam shortcut")
|
||||
print(f"{COLOR_ERROR}Failed to create Steam shortcut. Check logs for details.{COLOR_RESET}")
|
||||
return
|
||||
# Add app_id to context
|
||||
modlist_context.app_id = app_id
|
||||
|
||||
# Step 2: Handle Steam restart and manual steps (if not in GUI mode)
|
||||
if not is_gui_mode:
|
||||
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)
|
||||
# Step 4: Configure modlist using SAME service as GUI
|
||||
modlist_service = ModlistService(self.system_info)
|
||||
|
||||
# Add section header for configuration phase if progress callback is available
|
||||
if 'progress_callback' in locals() and progress_callback:
|
||||
progress_callback("") # Blank line for spacing
|
||||
progress_callback("=== Configuring Modlist ===")
|
||||
progress_callback("=== Configuration Phase ===")
|
||||
|
||||
self.logger.info("Running post-installation configuration phase")
|
||||
configuration_success = modlist_menu.run_modlist_configuration_phase(config_context)
|
||||
print(f"\n{COLOR_INFO}=== Configuration Phase ==={COLOR_RESET}")
|
||||
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:
|
||||
print(f"{COLOR_SUCCESS}Configuration completed successfully!{COLOR_RESET}")
|
||||
self.logger.info("Post-installation configuration completed successfully")
|
||||
else:
|
||||
print(f"{COLOR_WARNING}Configuration had some issues but completed.{COLOR_RESET}")
|
||||
self.logger.warning("Post-installation configuration had issues")
|
||||
else:
|
||||
# Game not supported for automated configuration
|
||||
@@ -1162,10 +1199,9 @@ class ModlistInstallCLI:
|
||||
# Section header now provided by GUI layer to avoid duplication
|
||||
|
||||
try:
|
||||
# Set GUI mode for backend operations
|
||||
# CLI Install: keep original GUI mode (don't force GUI mode)
|
||||
import os
|
||||
original_gui_mode = os.environ.get('JACKIFY_GUI_MODE')
|
||||
os.environ['JACKIFY_GUI_MODE'] = '1'
|
||||
|
||||
try:
|
||||
# Build context for configuration
|
||||
@@ -1176,7 +1212,7 @@ class ModlistInstallCLI:
|
||||
'modlist_value': context.get('modlist_value'),
|
||||
'modlist_source': context.get('modlist_source'),
|
||||
'resolution': context.get('resolution'),
|
||||
'skip_confirmation': True, # GUI mode is non-interactive
|
||||
'skip_confirmation': True, # CLI Install is non-interactive
|
||||
'manual_steps_completed': False
|
||||
}
|
||||
|
||||
@@ -1258,13 +1294,16 @@ class ModlistInstallCLI:
|
||||
from jackify.backend.services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
# Get user's preferred Proton version
|
||||
proton_version = _get_user_proton_version()
|
||||
|
||||
success, app_id = steam_service.create_shortcut_with_proton(
|
||||
app_name=config_context['name'],
|
||||
exe_path=config_context['mo2_exe_path'],
|
||||
start_dir=os.path.dirname(config_context['mo2_exe_path']),
|
||||
launch_options="%command%",
|
||||
tags=["Jackify"],
|
||||
proton_version="proton_experimental"
|
||||
proton_version=proton_version
|
||||
)
|
||||
|
||||
if not success or not app_id:
|
||||
@@ -1400,8 +1439,9 @@ class ModlistInstallCLI:
|
||||
# Remove status indicators to get clean line
|
||||
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
|
||||
|
||||
# Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL]
|
||||
parts = clean_line.split(' - ')
|
||||
# Split from right to handle modlist names with dashes
|
||||
# Format: "NAME - GAME - SIZES - MACHINE_URL"
|
||||
parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts
|
||||
if len(parts) != 4:
|
||||
continue # Skip malformed lines
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ class ConfigHandler:
|
||||
"default_install_parent_dir": None, # Parent directory for modlist installations
|
||||
"default_download_parent_dir": None, # Parent directory for downloads
|
||||
"modlist_install_base_dir": os.path.expanduser("~/Games"), # Configurable base directory for modlist installations
|
||||
"modlist_downloads_base_dir": os.path.expanduser("~/Games/Modlist_Downloads") # Configurable base directory for downloads
|
||||
"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
|
||||
@@ -46,6 +49,14 @@ class ConfigHandler:
|
||||
# If steam_path is not set, detect it
|
||||
if not self.settings["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
|
||||
self.save_config()
|
||||
|
||||
@@ -213,11 +224,14 @@ class ConfigHandler:
|
||||
def get_api_key(self):
|
||||
"""
|
||||
Retrieve and decode the saved Nexus API key
|
||||
Always reads fresh from disk to pick up changes from other instances
|
||||
|
||||
Returns:
|
||||
str: Decoded API key or None if not saved
|
||||
"""
|
||||
try:
|
||||
# Reload config from disk to pick up changes from Settings dialog
|
||||
self._load_config()
|
||||
encoded_key = self.settings.get("nexus_api_key")
|
||||
if encoded_key:
|
||||
# Decode the base64 encoded key
|
||||
@@ -231,10 +245,13 @@ class ConfigHandler:
|
||||
def has_saved_api_key(self):
|
||||
"""
|
||||
Check if an API key is saved in configuration
|
||||
Always reads fresh from disk to pick up changes from other instances
|
||||
|
||||
Returns:
|
||||
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
|
||||
|
||||
def clear_api_key(self):
|
||||
@@ -481,4 +498,88 @@ class ConfigHandler:
|
||||
logger.error(f"Error saving modlist downloads base directory: {e}")
|
||||
return False
|
||||
|
||||
def get_proton_path(self):
|
||||
"""
|
||||
Retrieve the saved Install Proton path from configuration (for jackify-engine)
|
||||
Always reads fresh from disk to pick up changes from Settings dialog
|
||||
|
||||
Returns:
|
||||
str: Saved Install Proton path or 'auto' if not saved
|
||||
"""
|
||||
try:
|
||||
# Reload config from disk to pick up changes from Settings dialog
|
||||
self._load_config()
|
||||
proton_path = self.settings.get("proton_path", "auto")
|
||||
logger.debug(f"Retrieved fresh install proton_path from config: {proton_path}")
|
||||
return proton_path
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving install proton_path: {e}")
|
||||
return "auto"
|
||||
|
||||
def get_game_proton_path(self):
|
||||
"""
|
||||
Retrieve the saved Game Proton path from configuration (for game shortcuts)
|
||||
Falls back to install Proton path if game Proton not set
|
||||
Always reads fresh from disk to pick up changes from Settings dialog
|
||||
|
||||
Returns:
|
||||
str: Saved Game Proton path, Install Proton path, or 'auto' if not saved
|
||||
"""
|
||||
try:
|
||||
# Reload config from disk to pick up changes from Settings dialog
|
||||
self._load_config()
|
||||
game_proton_path = self.settings.get("game_proton_path")
|
||||
|
||||
# If game proton not set or set to same_as_install, use install proton
|
||||
if not game_proton_path or game_proton_path == "same_as_install":
|
||||
game_proton_path = self.settings.get("proton_path", "auto")
|
||||
|
||||
logger.debug(f"Retrieved fresh game proton_path from config: {game_proton_path}")
|
||||
return game_proton_path
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving game proton_path: {e}")
|
||||
return "auto"
|
||||
|
||||
def get_proton_version(self):
|
||||
"""
|
||||
Retrieve the saved Proton version from configuration
|
||||
Always reads fresh from disk to pick up changes from Settings dialog
|
||||
|
||||
Returns:
|
||||
str: Saved Proton version or 'auto' if not saved
|
||||
"""
|
||||
try:
|
||||
# Reload config from disk to pick up changes from Settings dialog
|
||||
self._load_config()
|
||||
proton_version = self.settings.get("proton_version", "auto")
|
||||
logger.debug(f"Retrieved fresh proton_version from config: {proton_version}")
|
||||
return proton_version
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving proton_version: {e}")
|
||||
return "auto"
|
||||
|
||||
def _auto_detect_proton(self):
|
||||
"""Auto-detect and set best Proton version (includes GE-Proton and Valve Proton)"""
|
||||
try:
|
||||
from .wine_utils import WineUtils
|
||||
best_proton = WineUtils.select_best_proton()
|
||||
|
||||
if best_proton:
|
||||
self.settings["proton_path"] = str(best_proton['path'])
|
||||
self.settings["proton_version"] = best_proton['name']
|
||||
proton_type = best_proton.get('type', 'Unknown')
|
||||
logger.info(f"Auto-detected Proton: {best_proton['name']} ({proton_type})")
|
||||
self.save_config()
|
||||
else:
|
||||
# Fallback to auto-detect mode
|
||||
self.settings["proton_path"] = "auto"
|
||||
self.settings["proton_version"] = "auto"
|
||||
logger.info("No compatible Proton versions found, using auto-detect mode")
|
||||
self.save_config()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-detect Proton: {e}")
|
||||
self.settings["proton_path"] = "auto"
|
||||
self.settings["proton_version"] = "auto"
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ def main():
|
||||
print(f"Error: {diagnosis['error']}")
|
||||
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" 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']} "
|
||||
|
||||
@@ -152,8 +152,10 @@ class ModlistMenuHandler:
|
||||
self.path_handler = PathHandler()
|
||||
self.vdf_handler = VDFHandler()
|
||||
|
||||
# Determine Steam Deck status (already done by ConfigHandler, use it)
|
||||
self.steamdeck = config_handler.settings.get('steamdeck', False)
|
||||
# Determine Steam Deck status using centralized detection
|
||||
from ..services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
self.steamdeck = platform_service.is_steamdeck
|
||||
|
||||
# Create the resolution handler
|
||||
self.resolution_handler = ResolutionHandler()
|
||||
@@ -178,7 +180,13 @@ class ModlistMenuHandler:
|
||||
self.logger.error(f"Error initializing ModlistMenuHandler: {e}")
|
||||
# Initialize with defaults/empty to prevent errors
|
||||
self.filesystem_handler = FileSystemHandler()
|
||||
self.steamdeck = False
|
||||
# Use centralized detection even in fallback
|
||||
try:
|
||||
from ..services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
self.steamdeck = platform_service.is_steamdeck
|
||||
except:
|
||||
self.steamdeck = False # Final fallback
|
||||
self.modlist_handler = None
|
||||
|
||||
def show_modlist_menu(self):
|
||||
|
||||
@@ -71,15 +71,19 @@ class ModlistHandler:
|
||||
}
|
||||
|
||||
# Canonical mapping of modlist-specific Wine components (from omni-guides.sh)
|
||||
# NOTE: dotnet4.x components disabled in v0.1.6.2 - replaced with universal registry fixes
|
||||
MODLIST_WINE_COMPONENTS = {
|
||||
"wildlander": ["dotnet472"],
|
||||
"librum": ["dotnet40", "dotnet8"],
|
||||
"apostasy": ["dotnet40", "dotnet8"],
|
||||
"nordicsouls": ["dotnet40"],
|
||||
"livingskyrim": ["dotnet40"],
|
||||
"lsiv": ["dotnet40"],
|
||||
"ls4": ["dotnet40"],
|
||||
"lostlegacy": ["dotnet48"],
|
||||
# "wildlander": ["dotnet472"], # DISABLED: Universal registry fixes replace dotnet472 installation
|
||||
# "librum": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
|
||||
"librum": ["dotnet8"], # dotnet40 replaced with universal registry fixes
|
||||
# "apostasy": ["dotnet40", "dotnet8"], # PARTIAL DISABLE: Keep dotnet8, remove dotnet40
|
||||
"apostasy": ["dotnet8"], # dotnet40 replaced with universal registry fixes
|
||||
# "nordicsouls": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "livingskyrim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lsiv": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "ls4": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lorerim": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
# "lostlegacy": ["dotnet40"], # DISABLED: Universal registry fixes replace dotnet40 installation
|
||||
}
|
||||
|
||||
def __init__(self, steam_path_or_config: Union[Dict, str, Path, None] = None,
|
||||
@@ -105,6 +109,12 @@ class ModlistHandler:
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.logger.propagate = False
|
||||
self.steamdeck = steamdeck
|
||||
|
||||
# DEBUG: Log ModlistHandler instantiation details for SD card path debugging
|
||||
import traceback
|
||||
caller_info = traceback.extract_stack()[-2] # Get caller info
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler created: id={id(self)}, steamdeck={steamdeck}")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] Created from: {caller_info.filename}:{caller_info.lineno} in {caller_info.name}()")
|
||||
self.steam_path: Optional[Path] = None
|
||||
self.verbose = verbose # Store verbose flag
|
||||
self.mo2_path: Optional[Path] = None
|
||||
@@ -158,7 +168,10 @@ class ModlistHandler:
|
||||
self.stock_game_path = None
|
||||
|
||||
# Initialize Handlers (should happen regardless of how paths were provided)
|
||||
self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck, logger=self.logger)
|
||||
self.protontricks_handler = ProtontricksHandler(self.steamdeck, logger=self.logger)
|
||||
# Initialize winetricks handler for wine component installation
|
||||
from .winetricks_handler import WinetricksHandler
|
||||
self.winetricks_handler = WinetricksHandler(logger=self.logger)
|
||||
self.shortcut_handler = ShortcutHandler(steamdeck=self.steamdeck, verbose=self.verbose)
|
||||
self.filesystem_handler = filesystem_handler if filesystem_handler else FileSystemHandler()
|
||||
self.resolution_handler = ResolutionHandler()
|
||||
@@ -224,44 +237,41 @@ class ModlistHandler:
|
||||
discovered_modlists_info = []
|
||||
|
||||
try:
|
||||
# 1. Get ALL non-Steam shortcuts from Protontricks
|
||||
# Now calls the renamed method without filtering
|
||||
protontricks_shortcuts = self.protontricks_handler.list_non_steam_shortcuts()
|
||||
if not protontricks_shortcuts:
|
||||
self.logger.warning("Protontricks did not list any non-Steam shortcuts.")
|
||||
return []
|
||||
self.logger.debug(f"Protontricks non-Steam shortcuts found: {protontricks_shortcuts}")
|
||||
|
||||
# 2. Get shortcuts pointing to the executable from shortcuts.vdf
|
||||
# Get shortcuts pointing to the executable from shortcuts.vdf
|
||||
matching_vdf_shortcuts = self.shortcut_handler.find_shortcuts_by_exe(executable_name)
|
||||
if not matching_vdf_shortcuts:
|
||||
self.logger.debug(f"No shortcuts found pointing to '{executable_name}' in shortcuts.vdf.")
|
||||
return []
|
||||
self.logger.debug(f"Shortcuts matching executable '{executable_name}' in VDF: {matching_vdf_shortcuts}")
|
||||
|
||||
# 3. Correlate the two lists and extract required info
|
||||
# Process each matching shortcut and convert signed AppID to unsigned
|
||||
for vdf_shortcut in matching_vdf_shortcuts:
|
||||
app_name = vdf_shortcut.get('AppName')
|
||||
start_dir = vdf_shortcut.get('StartDir')
|
||||
|
||||
signed_appid = vdf_shortcut.get('appid')
|
||||
|
||||
if not app_name or not start_dir:
|
||||
self.logger.warning(f"Skipping VDF shortcut due to missing AppName or StartDir: {vdf_shortcut}")
|
||||
continue
|
||||
|
||||
if app_name in protontricks_shortcuts:
|
||||
app_id = protontricks_shortcuts[app_name]
|
||||
|
||||
# Append dictionary with all necessary info
|
||||
modlist_info = {
|
||||
'name': app_name,
|
||||
'appid': app_id,
|
||||
'path': start_dir
|
||||
}
|
||||
discovered_modlists_info.append(modlist_info)
|
||||
self.logger.info(f"Validated shortcut: '{app_name}' (AppID: {app_id}, Path: {start_dir})")
|
||||
if signed_appid is None:
|
||||
self.logger.warning(f"Skipping VDF shortcut due to missing appid: {vdf_shortcut}")
|
||||
continue
|
||||
|
||||
# Convert signed AppID to unsigned AppID (the format used by Steam prefixes)
|
||||
if signed_appid < 0:
|
||||
unsigned_appid = signed_appid + (2**32)
|
||||
else:
|
||||
# Downgraded from WARNING to INFO
|
||||
self.logger.info(f"Shortcut '{app_name}' found in VDF but not listed by protontricks. Skipping.")
|
||||
unsigned_appid = signed_appid
|
||||
|
||||
# Append dictionary with all necessary info using unsigned AppID
|
||||
modlist_info = {
|
||||
'name': app_name,
|
||||
'appid': unsigned_appid,
|
||||
'path': start_dir
|
||||
}
|
||||
discovered_modlists_info.append(modlist_info)
|
||||
self.logger.info(f"Discovered shortcut: '{app_name}' (Signed: {signed_appid} → Unsigned: {unsigned_appid}, Path: {start_dir})")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error discovering executable shortcuts: {e}", exc_info=True)
|
||||
@@ -315,13 +325,26 @@ class ModlistHandler:
|
||||
self.modlist_dir = Path(modlist_dir_path_str)
|
||||
self.modlist_ini = modlist_ini_path
|
||||
|
||||
# Determine if modlist is on SD card
|
||||
# Use str() for startswith check
|
||||
if str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media"):
|
||||
# Determine if modlist is on SD card (Steam Deck only)
|
||||
# On non-Steam Deck systems, /media mounts should use Z: drive, not D: drive
|
||||
is_on_sdcard_path = str(self.modlist_dir).startswith("/run/media") or str(self.modlist_dir).startswith("/media")
|
||||
|
||||
# DEBUG: Log SD card detection logic
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] SD card detection for instance id={id(self)}:")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] modlist_dir: {self.modlist_dir}")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] is_on_sdcard_path: {is_on_sdcard_path}")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] self.steamdeck: {self.steamdeck}")
|
||||
|
||||
if is_on_sdcard_path and self.steamdeck:
|
||||
self.modlist_sdcard = True
|
||||
self.logger.info("Modlist appears to be on an SD card.")
|
||||
self.logger.info("Modlist appears to be on an SD card (Steam Deck).")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] Set modlist_sdcard=True")
|
||||
else:
|
||||
self.modlist_sdcard = False
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] Set modlist_sdcard=False because: is_on_sdcard_path={is_on_sdcard_path} AND steamdeck={self.steamdeck}")
|
||||
if is_on_sdcard_path and not self.steamdeck:
|
||||
self.logger.info("Modlist on /media mount detected on non-Steam Deck system - using Z: drive mapping.")
|
||||
self.logger.debug("[SD_CARD_DEBUG] This is the ROOT CAUSE - SD card path but steamdeck=False!")
|
||||
|
||||
# Find and set compatdata path now that we have appid
|
||||
# Ensure PathHandler is available (should be initialized in __init__)
|
||||
@@ -345,7 +368,8 @@ class ModlistHandler:
|
||||
# Store engine_installed flag for conditional path manipulation
|
||||
self.engine_installed = modlist_info.get('engine_installed', False)
|
||||
self.logger.debug(f" Engine Installed: {self.engine_installed}")
|
||||
|
||||
|
||||
|
||||
# Call internal detection methods to populate more state
|
||||
if not self._detect_game_variables():
|
||||
self.logger.warning("Failed to auto-detect game type after setting context.")
|
||||
@@ -665,6 +689,25 @@ class ModlistHandler:
|
||||
return False
|
||||
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
|
||||
if status_callback:
|
||||
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
|
||||
target_appid = self.appid
|
||||
|
||||
if not self.protontricks_handler.install_wine_components(target_appid, self.game_var_full, specific_components=components):
|
||||
self.logger.error("Failed to install Wine components. Configuration aborted.")
|
||||
# Use user's preferred component installation method (respects settings toggle)
|
||||
wineprefix = self.protontricks_handler.get_wine_prefix_path(target_appid)
|
||||
if not wineprefix:
|
||||
self.logger.error("Failed to get WINEPREFIX path for component installation.")
|
||||
print("Error: Could not determine wine prefix location.")
|
||||
return False
|
||||
|
||||
# Use the winetricks handler which respects the user's toggle setting
|
||||
try:
|
||||
self.logger.info("Installing Wine components using user's preferred method...")
|
||||
success = self.winetricks_handler.install_wine_components(wineprefix, self.game_var_full, specific_components=components)
|
||||
if success:
|
||||
self.logger.info("Wine component installation completed successfully")
|
||||
else:
|
||||
self.logger.error("Wine component installation failed")
|
||||
print("Error: Failed to install necessary Wine components.")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"Wine component installation failed with exception: {e}")
|
||||
print("Error: Failed to install necessary Wine components.")
|
||||
return False # Abort on failure
|
||||
return False
|
||||
self.logger.info("Step 4: Installing Wine components... Done")
|
||||
|
||||
# Step 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("Step 6: Backing up ModOrganizer.ini... Done")
|
||||
|
||||
# Step 6.5: Handle symlinked downloads directory
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Checking for symlinked downloads directory")
|
||||
self.logger.info("Step 6.5: Checking for symlinked downloads directory...")
|
||||
if not self._handle_symlinked_downloads():
|
||||
self.logger.warning("Warning during symlink handling (non-critical)")
|
||||
self.logger.info("Step 6.5: Checking for symlinked downloads directory... Done")
|
||||
|
||||
# Step 7a: Detect Stock Game/Game Root path
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Detecting stock game path")
|
||||
@@ -758,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("Using unified path manipulation to avoid duplicate processing.")
|
||||
|
||||
# Conditionally update binary and working directory paths
|
||||
# Conditionally update binary and working directory paths
|
||||
# Skip for jackify-engine workflows since paths are already correct
|
||||
if not getattr(self, 'engine_installed', False):
|
||||
steam_libraries = [self.steam_library] if self.steam_library else None
|
||||
# Exception: Always run for SD card installs to fix Z:/run/media/... to D:/... paths
|
||||
|
||||
# DEBUG: Add comprehensive logging to identify Steam Deck SD card path manipulation issues
|
||||
engine_installed = getattr(self, 'engine_installed', False)
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] ModlistHandler instance: id={id(self)}")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] engine_installed: {engine_installed}")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] modlist_sdcard: {self.modlist_sdcard}")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] steamdeck parameter passed to constructor: {getattr(self, 'steamdeck', 'NOT_SET')}")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] Path manipulation condition: not {engine_installed} or {self.modlist_sdcard} = {not engine_installed or self.modlist_sdcard}")
|
||||
|
||||
if not getattr(self, 'engine_installed', False) or self.modlist_sdcard:
|
||||
# Convert steamapps/common path to library root path
|
||||
steam_libraries = None
|
||||
if self.steam_library:
|
||||
# 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(
|
||||
modlist_ini_path=modlist_ini_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.")
|
||||
return False # Abort on failure
|
||||
else:
|
||||
self.logger.debug("Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini")
|
||||
self.logger.debug("[SD_CARD_DEBUG] Skipping path manipulation - jackify-engine already set correct paths in ModOrganizer.ini")
|
||||
self.logger.debug(f"[SD_CARD_DEBUG] SKIPPED because: engine_installed={engine_installed} and modlist_sdcard={self.modlist_sdcard}")
|
||||
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
|
||||
|
||||
# Step 9: Update Resolution Settings (if applicable)
|
||||
@@ -781,10 +867,16 @@ class ModlistHandler:
|
||||
status_callback(f"{self._get_progress_timestamp()} Updating resolution settings")
|
||||
# Ensure resolution_handler call uses correct args if needed
|
||||
# Assuming it uses modlist_dir (str) and game_var_full (str)
|
||||
if not self.resolution_handler.update_ini_resolution(
|
||||
modlist_dir=self.modlist_dir,
|
||||
game_var=self.game_var_full,
|
||||
set_res=self.selected_resolution
|
||||
# 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 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.")
|
||||
print("Warning: Failed to update resolution settings.")
|
||||
@@ -811,32 +903,55 @@ class ModlistHandler:
|
||||
status_callback(f"{self._get_progress_timestamp()} Creating dxvk.conf file")
|
||||
self.logger.info("Step 10: Creating dxvk.conf file...")
|
||||
# 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(
|
||||
modlist_dir=self.modlist_dir,
|
||||
modlist_sdcard=self.modlist_sdcard,
|
||||
steam_library=str(self.steam_library) if self.steam_library else None, # Pass as string or None
|
||||
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.")
|
||||
print("Warning: Failed to create dxvk.conf file.")
|
||||
self.logger.info("Step 10: Creating dxvk.conf... Done")
|
||||
|
||||
# Step 11a: Small Tasks - Delete Plugin
|
||||
# Step 11a: Small Tasks - Delete Incompatible Plugins
|
||||
if status_callback:
|
||||
status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugin")
|
||||
self.logger.info("Step 11a: Deleting incompatible MO2 plugin (FixGameRegKey.py)...")
|
||||
plugin_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
|
||||
if plugin_path.exists():
|
||||
status_callback(f"{self._get_progress_timestamp()} Deleting incompatible MO2 plugins")
|
||||
self.logger.info("Step 11a: Deleting incompatible MO2 plugins...")
|
||||
|
||||
# Delete FixGameRegKey.py plugin
|
||||
fixgamereg_path = Path(self.modlist_dir) / "plugins" / "FixGameRegKey.py"
|
||||
if fixgamereg_path.exists():
|
||||
try:
|
||||
plugin_path.unlink()
|
||||
fixgamereg_path.unlink()
|
||||
self.logger.info("FixGameRegKey.py plugin deleted successfully.")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to delete FixGameRegKey.py plugin: {e}")
|
||||
print("Warning: Failed to delete incompatible plugin file.")
|
||||
print("Warning: Failed to delete FixGameRegKey.py plugin file.")
|
||||
else:
|
||||
self.logger.debug("FixGameRegKey.py plugin not found (this is normal).")
|
||||
self.logger.info("Step 11a: Plugin deletion check complete.")
|
||||
|
||||
# Delete PageFileManager plugin directory (Linux has no PageFile)
|
||||
pagefilemgr_path = Path(self.modlist_dir) / "plugins" / "PageFileManager"
|
||||
if pagefilemgr_path.exists():
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(pagefilemgr_path)
|
||||
self.logger.info("PageFileManager plugin directory deleted successfully.")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to delete PageFileManager plugin directory: {e}")
|
||||
print("Warning: Failed to delete PageFileManager plugin directory.")
|
||||
else:
|
||||
self.logger.debug("PageFileManager plugin not found (this is normal).")
|
||||
|
||||
self.logger.info("Step 11a: Incompatible plugin deletion check complete.")
|
||||
|
||||
|
||||
# Step 11b: Download Font
|
||||
if status_callback:
|
||||
@@ -844,7 +959,7 @@ class ModlistHandler:
|
||||
prefix_path_str = self.path_handler.find_compat_data(str(self.appid))
|
||||
if prefix_path_str:
|
||||
prefix_path = Path(prefix_path_str)
|
||||
fonts_dir = prefix_path / "drive_c" / "windows" / "Fonts"
|
||||
fonts_dir = prefix_path / "pfx" / "drive_c" / "windows" / "Fonts"
|
||||
font_url = "https://github.com/mrbvrz/segoe-ui-linux/raw/refs/heads/master/font/seguisym.ttf"
|
||||
font_dest_path = fonts_dir / "seguisym.ttf"
|
||||
|
||||
@@ -879,6 +994,10 @@ class ModlistHandler:
|
||||
# status_callback("Configuration completed successfully!")
|
||||
|
||||
self.logger.info("Configuration steps completed successfully.")
|
||||
|
||||
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
|
||||
self._re_enforce_windows_10_mode()
|
||||
|
||||
return True # Return True on success
|
||||
|
||||
def _detect_steam_library_info(self) -> bool:
|
||||
@@ -1117,6 +1236,7 @@ class ModlistHandler:
|
||||
("grid-hero.png", f"{appid}_hero.png"),
|
||||
("grid-logo.png", f"{appid}_logo.png"),
|
||||
("grid-tall.png", f"{appid}.png"),
|
||||
("grid-tall.png", f"{appid}p.png"),
|
||||
]
|
||||
|
||||
for src_name, dest_name in images:
|
||||
@@ -1143,7 +1263,7 @@ class ModlistHandler:
|
||||
# Determine game type
|
||||
game = (game_var_full or modlist_name or "").lower().replace(" ", "")
|
||||
# Add game-specific extras
|
||||
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game:
|
||||
if "skyrim" in game or "fallout4" in game or "starfield" in game or "oblivion_remastered" in game or "enderal" in game:
|
||||
extras += ["d3dcompiler_47", "d3dx11_43", "d3dcompiler_43", "dotnet6", "dotnet7"]
|
||||
elif "falloutnewvegas" in game or "fnv" in game or "oblivion" in game:
|
||||
extras += ["d3dx9_43", "d3dx9"]
|
||||
@@ -1218,6 +1338,12 @@ class ModlistHandler:
|
||||
# Check ModOrganizer.ini for indicators (nvse/enderal) as an early, robust signal
|
||||
try:
|
||||
mo2_ini = modlist_path / "ModOrganizer.ini"
|
||||
# Also check Somnium's non-standard location
|
||||
if not mo2_ini.exists():
|
||||
somnium_mo2_ini = modlist_path / "files" / "ModOrganizer.ini"
|
||||
if somnium_mo2_ini.exists():
|
||||
mo2_ini = somnium_mo2_ini
|
||||
|
||||
if mo2_ini.exists():
|
||||
try:
|
||||
content = mo2_ini.read_text(errors='ignore').lower()
|
||||
@@ -1274,4 +1400,236 @@ class ModlistHandler:
|
||||
self.logger.debug("No special game type detected - standard workflow will be used")
|
||||
return None
|
||||
|
||||
# (Ensure EOF is clean and no extra incorrect methods exist below)
|
||||
def _re_enforce_windows_10_mode(self):
|
||||
"""
|
||||
Re-enforce Windows 10 mode after modlist-specific configurations.
|
||||
This matches the legacy script behavior (line 1333) where Windows 10 mode
|
||||
is re-applied after modlist-specific steps to ensure consistency.
|
||||
"""
|
||||
try:
|
||||
if not hasattr(self, 'appid') or not self.appid:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - no AppID available")
|
||||
return
|
||||
|
||||
from ..handlers.winetricks_handler import WinetricksHandler
|
||||
from ..handlers.path_handler import PathHandler
|
||||
|
||||
# Get prefix path for the AppID
|
||||
prefix_path = PathHandler.find_compat_data(str(self.appid))
|
||||
if not prefix_path:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - prefix path not found")
|
||||
return
|
||||
|
||||
# Use winetricks handler to set Windows 10 mode
|
||||
winetricks_handler = WinetricksHandler()
|
||||
wine_binary = winetricks_handler._get_wine_binary_for_prefix(str(prefix_path))
|
||||
if not wine_binary:
|
||||
self.logger.warning("Cannot re-enforce Windows 10 mode - wine binary not found")
|
||||
return
|
||||
|
||||
winetricks_handler._set_windows_10_mode(str(prefix_path), wine_binary)
|
||||
|
||||
self.logger.info("Windows 10 mode re-enforced after modlist-specific configurations")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error re-enforcing Windows 10 mode: {e}")
|
||||
|
||||
def _handle_symlinked_downloads(self) -> bool:
|
||||
"""
|
||||
Check if downloads_directory in ModOrganizer.ini points to a symlink.
|
||||
If it does, comment out the line to force MO2 to use default behavior.
|
||||
|
||||
Returns:
|
||||
bool: True on success or no action needed, False on error
|
||||
"""
|
||||
try:
|
||||
import configparser
|
||||
import os
|
||||
|
||||
if not self.modlist_ini or not os.path.exists(self.modlist_ini):
|
||||
self.logger.warning("ModOrganizer.ini not found for symlink check")
|
||||
return True # Non-critical
|
||||
|
||||
# Read the INI file
|
||||
config = configparser.ConfigParser(allow_no_value=True, delimiters=['='])
|
||||
config.optionxform = str # Preserve case sensitivity
|
||||
|
||||
try:
|
||||
# Read file manually to handle BOM
|
||||
with open(self.modlist_ini, 'r', encoding='utf-8-sig') as f:
|
||||
config.read_file(f)
|
||||
except UnicodeDecodeError:
|
||||
with open(self.modlist_ini, 'r', encoding='latin-1') as f:
|
||||
config.read_file(f)
|
||||
|
||||
# Check if downloads_directory or download_directory exists and is a symlink
|
||||
downloads_key = None
|
||||
downloads_path = None
|
||||
|
||||
if 'General' in config:
|
||||
# Check for both possible key names
|
||||
if 'downloads_directory' in config['General']:
|
||||
downloads_key = 'downloads_directory'
|
||||
downloads_path = config['General']['downloads_directory']
|
||||
elif 'download_directory' in config['General']:
|
||||
downloads_key = 'download_directory'
|
||||
downloads_path = config['General']['download_directory']
|
||||
|
||||
if downloads_path:
|
||||
|
||||
if downloads_path and os.path.exists(downloads_path):
|
||||
# Check if the path or any parent directory contains symlinks
|
||||
def has_symlink_in_path(path):
|
||||
"""Check if path or any parent directory is a symlink"""
|
||||
current_path = Path(path).resolve()
|
||||
check_path = Path(path)
|
||||
|
||||
# Walk up the path checking each component
|
||||
for parent in [check_path] + list(check_path.parents):
|
||||
if parent.is_symlink():
|
||||
return True, str(parent)
|
||||
return False, None
|
||||
|
||||
has_symlink, symlink_path = has_symlink_in_path(downloads_path)
|
||||
if has_symlink:
|
||||
self.logger.info(f"Detected symlink in downloads directory path: {symlink_path} -> {downloads_path}")
|
||||
self.logger.info("Commenting out downloads_directory to avoid Wine symlink issues")
|
||||
|
||||
# Read the file manually to preserve comments and formatting
|
||||
with open(self.modlist_ini, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find and comment out the downloads directory line
|
||||
modified = False
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith(f'{downloads_key}='):
|
||||
lines[i] = '#' + line # Comment out the line
|
||||
modified = True
|
||||
break
|
||||
|
||||
if modified:
|
||||
# Write the modified file back
|
||||
with open(self.modlist_ini, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
self.logger.info(f"{downloads_key} line commented out successfully")
|
||||
else:
|
||||
self.logger.warning("downloads_directory line not found in file")
|
||||
else:
|
||||
self.logger.debug(f"downloads_directory is not a symlink: {downloads_path}")
|
||||
else:
|
||||
self.logger.debug("downloads_directory path does not exist or is empty")
|
||||
else:
|
||||
self.logger.debug("No downloads_directory found in ModOrganizer.ini")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling symlinked downloads: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _apply_universal_dotnet_fixes(self):
|
||||
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists"""
|
||||
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
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ class ModlistInstallCLI:
|
||||
def __init__(self, menu_handler: MenuHandler, steamdeck: bool = False):
|
||||
self.menu_handler = menu_handler
|
||||
self.steamdeck = steamdeck
|
||||
self.protontricks_handler = ProtontricksHandler(steamdeck=steamdeck)
|
||||
self.protontricks_handler = ProtontricksHandler(steamdeck)
|
||||
self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
|
||||
self.context = {}
|
||||
# Use standard logging (no file handler)
|
||||
@@ -616,7 +616,8 @@ class ModlistInstallCLI:
|
||||
if machineid:
|
||||
# Convert machineid to filename (e.g., "Tuxborn/Tuxborn" -> "Tuxborn.wabbajack")
|
||||
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}")
|
||||
|
||||
if modlist_value and modlist_value.endswith('.wabbajack') and os.path.isfile(modlist_value):
|
||||
@@ -723,13 +724,17 @@ class ModlistInstallCLI:
|
||||
if chunk == b'\n':
|
||||
# Complete line - decode and print
|
||||
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''
|
||||
last_progress_time = time.time()
|
||||
elif chunk == b'\r':
|
||||
# Carriage return - decode and print without newline
|
||||
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()
|
||||
buffer = b''
|
||||
last_progress_time = time.time()
|
||||
@@ -1020,8 +1025,9 @@ class ModlistInstallCLI:
|
||||
# Remove status indicators to get clean line
|
||||
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
|
||||
|
||||
# Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL]
|
||||
parts = clean_line.split(' - ')
|
||||
# Split from right to handle modlist names with dashes
|
||||
# Format: "NAME - GAME - SIZES - MACHINE_URL"
|
||||
parts = clean_line.rsplit(' - ', 3) # Split from right, max 3 splits = 4 parts
|
||||
if len(parts) != 4:
|
||||
continue # Skip malformed lines
|
||||
|
||||
@@ -1097,4 +1103,36 @@ class ModlistInstallCLI:
|
||||
print(f"Nexus API Key: [SET]")
|
||||
else:
|
||||
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
|
||||
@@ -32,14 +32,21 @@ class PathHandler:
|
||||
@staticmethod
|
||||
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
|
||||
"""
|
||||
Removes the '/run/media/mmcblk0p1/' prefix if present.
|
||||
Removes any detected SD card mount prefix dynamically.
|
||||
Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns.
|
||||
Returns the path as a POSIX-style string (using /).
|
||||
"""
|
||||
path_str = path_obj.as_posix() # Work with consistent forward slashes
|
||||
if path_str.lower().startswith(SDCARD_PREFIX.lower()):
|
||||
# Return the part *after* the prefix, ensuring no leading slash remains unless root
|
||||
relative_part = path_str[len(SDCARD_PREFIX):]
|
||||
return relative_part if relative_part else "." # Return '.' if it was exactly the prefix
|
||||
from .wine_utils import WineUtils
|
||||
|
||||
path_str = path_obj.as_posix() # Work with consistent forward slashes
|
||||
|
||||
# Use dynamic SD card detection from WineUtils
|
||||
stripped_path = WineUtils._strip_sdcard_path(path_str)
|
||||
|
||||
if stripped_path != path_str:
|
||||
# Path was stripped, remove leading slash for relative path
|
||||
return stripped_path.lstrip('/') if stripped_path != '/' else '.'
|
||||
|
||||
return path_str
|
||||
|
||||
@staticmethod
|
||||
@@ -251,7 +258,7 @@ class PathHandler:
|
||||
return False
|
||||
|
||||
@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
|
||||
|
||||
@@ -261,6 +268,7 @@ class PathHandler:
|
||||
steam_library (str): Path to the Steam library
|
||||
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")
|
||||
vanilla_game_dir (str): Optional path to vanilla game directory for fallback
|
||||
|
||||
Returns:
|
||||
bool: True on success, False on failure
|
||||
@@ -271,25 +279,35 @@ class PathHandler:
|
||||
# Determine the location for dxvk.conf
|
||||
dxvk_conf_path = None
|
||||
|
||||
# Check for common stock game directories
|
||||
# Check for common stock game directories first, then vanilla as fallback
|
||||
stock_game_paths = [
|
||||
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, "STOCK GAME"),
|
||||
os.path.join(modlist_dir, "Stock Game Folder"),
|
||||
os.path.join(modlist_dir, "Stock Folder"),
|
||||
os.path.join(modlist_dir, "Skyrim Stock"),
|
||||
os.path.join(modlist_dir, "root", "Skyrim Special Edition"),
|
||||
os.path.join(steam_library, game_var_full)
|
||||
os.path.join(modlist_dir, "root", "Skyrim Special Edition")
|
||||
]
|
||||
|
||||
# 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:
|
||||
if os.path.exists(path):
|
||||
dxvk_conf_path = os.path.join(path, "dxvk.conf")
|
||||
break
|
||||
|
||||
if not dxvk_conf_path:
|
||||
logger.error("Could not determine location for dxvk.conf")
|
||||
return False
|
||||
# Fallback: Try vanilla game directory if provided
|
||||
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
|
||||
required_line = "dxvk.enableGraphicsPipelineLibrary = False"
|
||||
@@ -612,47 +630,30 @@ class PathHandler:
|
||||
|
||||
# Moved _find_shortcuts_vdf here from ShortcutHandler
|
||||
def _find_shortcuts_vdf(self) -> Optional[str]:
|
||||
"""Helper to find the active shortcuts.vdf file for a user.
|
||||
"""Helper to find the active shortcuts.vdf file for the current Steam user.
|
||||
|
||||
Iterates through userdata directories and returns the path to the
|
||||
first found shortcuts.vdf file.
|
||||
Uses proper multi-user detection to find the correct Steam user instead
|
||||
of just taking the first found user directory.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The full path to the shortcuts.vdf file, or None if not found.
|
||||
"""
|
||||
# This implementation was moved from ShortcutHandler
|
||||
userdata_base_paths = [
|
||||
os.path.expanduser("~/.steam/steam/userdata"),
|
||||
os.path.expanduser("~/.local/share/Steam/userdata"),
|
||||
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata")
|
||||
]
|
||||
found_vdf_path = None
|
||||
for base_path in userdata_base_paths:
|
||||
if not os.path.isdir(base_path):
|
||||
logger.debug(f"Userdata base path not found or not a directory: {base_path}")
|
||||
continue
|
||||
logger.debug(f"Searching for user IDs in: {base_path}")
|
||||
try:
|
||||
for item in os.listdir(base_path):
|
||||
user_path = os.path.join(base_path, item)
|
||||
if os.path.isdir(user_path) and item.isdigit():
|
||||
logger.debug(f"Checking user directory: {user_path}")
|
||||
config_path = os.path.join(user_path, "config")
|
||||
shortcuts_file = os.path.join(config_path, "shortcuts.vdf")
|
||||
if os.path.isfile(shortcuts_file):
|
||||
logger.info(f"Found shortcuts.vdf at: {shortcuts_file}")
|
||||
found_vdf_path = shortcuts_file
|
||||
break # Found it for this base path
|
||||
else:
|
||||
logger.debug(f"shortcuts.vdf not found in {config_path}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not access directory {base_path}: {e}")
|
||||
continue # Try next base path
|
||||
if found_vdf_path:
|
||||
break # Found it in this base path
|
||||
if not found_vdf_path:
|
||||
logger.error("Could not find any shortcuts.vdf file in common Steam locations.")
|
||||
return found_vdf_path
|
||||
try:
|
||||
# Use native Steam service for proper multi-user detection
|
||||
from jackify.backend.services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
shortcuts_path = steam_service.get_shortcuts_vdf_path()
|
||||
|
||||
if shortcuts_path:
|
||||
logger.info(f"Found shortcuts.vdf using multi-user detection: {shortcuts_path}")
|
||||
return str(shortcuts_path)
|
||||
else:
|
||||
logger.error("Could not determine shortcuts.vdf path using multi-user detection")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error using multi-user detection for shortcuts.vdf: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]:
|
||||
@@ -726,7 +727,7 @@ class PathHandler:
|
||||
try:
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
lines = f.readlines()
|
||||
drive_letter = "D:" if modlist_sdcard else "Z:"
|
||||
drive_letter = "D:\\\\" if modlist_sdcard else "Z:\\\\"
|
||||
processed_path = self._strip_sdcard_path_prefix(new_game_path)
|
||||
windows_style = processed_path.replace('/', '\\')
|
||||
windows_style_double = windows_style.replace('\\', '\\\\')
|
||||
@@ -773,6 +774,39 @@ class PathHandler:
|
||||
return False
|
||||
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
|
||||
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
|
||||
binary_paths_updated = 0
|
||||
working_dirs_updated = 0
|
||||
@@ -791,9 +825,16 @@ class PathHandler:
|
||||
backslash_style = wd_match.group(2)
|
||||
working_dir_lines.append((i, stripped, index, backslash_style))
|
||||
binary_paths_by_index = {}
|
||||
# Use provided steam_libraries if available, else detect
|
||||
if steam_libraries is None or not steam_libraries:
|
||||
# Use existing gamePath to determine correct Steam library, fallback to detection
|
||||
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()
|
||||
logger.debug(f"Fallback to detected Steam libraries: {steam_libraries}")
|
||||
for i, line, index, backslash_style in binary_lines:
|
||||
parts = line.split('=', 1)
|
||||
if len(parts) != 2:
|
||||
@@ -815,11 +856,12 @@ class PathHandler:
|
||||
subpath = value_part[idx:].lstrip('/')
|
||||
correct_steam_lib = None
|
||||
for lib in steam_libraries:
|
||||
if (lib / subpath.split('/')[2]).exists():
|
||||
correct_steam_lib = lib.parent
|
||||
# Check if the actual game folder exists in this library
|
||||
if len(subpath.split('/')) > 3 and (lib / subpath.split('/')[2] / subpath.split('/')[3]).exists():
|
||||
correct_steam_lib = lib
|
||||
break
|
||||
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:
|
||||
new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/')
|
||||
else:
|
||||
@@ -842,9 +884,10 @@ class PathHandler:
|
||||
rel_path = value_part[idx:].lstrip('/')
|
||||
else:
|
||||
rel_path = exe_name
|
||||
new_binary_path = f"{drive_prefix}/{modlist_dir_path}/{rel_path}".replace('\\', '/').replace('//', '/')
|
||||
processed_modlist_path = PathHandler._strip_sdcard_path_prefix(modlist_dir_path) if modlist_sdcard else str(modlist_dir_path)
|
||||
new_binary_path = f"{drive_prefix}/{processed_modlist_path}/{rel_path}".replace('\\', '/').replace('//', '/')
|
||||
formatted_binary_path = PathHandler._format_binary_for_mo2(new_binary_path)
|
||||
new_binary_line = f"{index}{backslash_style}binary={formatted_binary_path}"
|
||||
new_binary_line = f"{index}{backslash_style}binary = {formatted_binary_path}"
|
||||
logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}")
|
||||
lines[i] = new_binary_line + "\n"
|
||||
binary_paths_updated += 1
|
||||
@@ -859,7 +902,7 @@ class PathHandler:
|
||||
wd_path = drive_prefix + wd_path
|
||||
formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path)
|
||||
key_part = f"{index}{backslash_style}workingDirectory"
|
||||
new_wd_line = f"{key_part}={formatted_wd_path}"
|
||||
new_wd_line = f"{key_part} = {formatted_wd_path}"
|
||||
logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}")
|
||||
lines[j] = new_wd_line + "\n"
|
||||
working_dirs_updated += 1
|
||||
|
||||
@@ -21,14 +21,19 @@ logger = logging.getLogger(__name__)
|
||||
class ProtontricksHandler:
|
||||
"""
|
||||
Handles operations related to Protontricks detection and usage
|
||||
|
||||
This handler now supports native Steam operations as a fallback/replacement
|
||||
for protontricks functionality.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, steamdeck: bool, logger=None):
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.which_protontricks = None # 'flatpak' or 'native'
|
||||
self.protontricks_version = None
|
||||
self.protontricks_path = None
|
||||
self.steamdeck = steamdeck # Store steamdeck status
|
||||
self._native_steam_service = None
|
||||
self.use_native_operations = True # Enable native Steam operations by default
|
||||
|
||||
def _get_clean_subprocess_env(self):
|
||||
"""
|
||||
@@ -69,7 +74,14 @@ class ProtontricksHandler:
|
||||
env.pop('DYLD_LIBRARY_PATH', None)
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def _get_native_steam_service(self):
|
||||
"""Get native Steam operations service instance"""
|
||||
if self._native_steam_service is None:
|
||||
from ..services.native_steam_operations_service import NativeSteamOperationsService
|
||||
self._native_steam_service = NativeSteamOperationsService(steamdeck=self.steamdeck)
|
||||
return self._native_steam_service
|
||||
|
||||
def detect_protontricks(self):
|
||||
"""
|
||||
Detect if protontricks is installed and whether it's flatpak or native.
|
||||
@@ -137,9 +149,29 @@ class ProtontricksHandler:
|
||||
should_install = True
|
||||
else:
|
||||
try:
|
||||
response = input("Protontricks not found. Install the Flatpak version? (Y/n): ").lower()
|
||||
if response == 'y' or response == '':
|
||||
print("\nProtontricks not found. Choose installation method:")
|
||||
print("1. Install via Flatpak (automatic)")
|
||||
print("2. Install via native package manager (manual)")
|
||||
print("3. Skip (Use bundled winetricks instead)")
|
||||
choice = input("Enter choice (1/2/3): ").strip()
|
||||
|
||||
if choice == '1' or choice == '':
|
||||
should_install = True
|
||||
elif choice == '2':
|
||||
print("\nTo install protontricks via your system package manager:")
|
||||
print("• Ubuntu/Debian: sudo apt install protontricks")
|
||||
print("• Fedora: sudo dnf install protontricks")
|
||||
print("• Arch Linux: sudo pacman -S protontricks")
|
||||
print("• openSUSE: sudo zypper install protontricks")
|
||||
print("\nAfter installation, please rerun Jackify.")
|
||||
return False
|
||||
elif choice == '3':
|
||||
print("Skipping protontricks installation. Will use bundled winetricks for component installation.")
|
||||
logger.info("User chose to skip protontricks and use winetricks fallback")
|
||||
return False
|
||||
else:
|
||||
print("Invalid choice. Installation cancelled.")
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
print("\nInstallation cancelled.")
|
||||
return False
|
||||
@@ -255,9 +287,19 @@ class ProtontricksHandler:
|
||||
|
||||
def set_protontricks_permissions(self, modlist_dir, steamdeck=False):
|
||||
"""
|
||||
Set permissions for Protontricks to access the modlist directory
|
||||
Set permissions for Steam operations to access the modlist directory.
|
||||
|
||||
Uses native operations when enabled, falls back to protontricks permissions.
|
||||
Returns True on success, False on failure
|
||||
"""
|
||||
# Use native operations if enabled
|
||||
if self.use_native_operations:
|
||||
logger.debug("Using native Steam operations, permissions handled natively")
|
||||
try:
|
||||
return self._get_native_steam_service().set_steam_permissions(modlist_dir, steamdeck)
|
||||
except Exception as e:
|
||||
logger.warning(f"Native permissions failed, falling back to protontricks: {e}")
|
||||
|
||||
if self.which_protontricks != 'flatpak':
|
||||
logger.debug("Using Native protontricks, skip setting permissions")
|
||||
return True
|
||||
@@ -338,15 +380,22 @@ class ProtontricksHandler:
|
||||
|
||||
# Renamed from list_non_steam_games for clarity and purpose
|
||||
def list_non_steam_shortcuts(self) -> Dict[str, str]:
|
||||
"""List ALL non-Steam shortcuts recognized by Protontricks.
|
||||
"""List ALL non-Steam shortcuts.
|
||||
|
||||
Runs 'protontricks -l' and parses the output for lines matching
|
||||
"Non-Steam shortcut: [Name] ([AppID])".
|
||||
Uses native VDF parsing when enabled, falls back to protontricks -l parsing.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping the shortcut name (AppName) to its AppID.
|
||||
Returns an empty dictionary if none are found or an error occurs.
|
||||
"""
|
||||
# Use native operations if enabled
|
||||
if self.use_native_operations:
|
||||
logger.info("Listing non-Steam shortcuts via native VDF parsing...")
|
||||
try:
|
||||
return self._get_native_steam_service().list_non_steam_shortcuts()
|
||||
except Exception as e:
|
||||
logger.warning(f"Native shortcut listing failed, falling back to protontricks: {e}")
|
||||
|
||||
logger.info("Listing ALL non-Steam shortcuts via protontricks...")
|
||||
non_steam_shortcuts = {}
|
||||
# --- Ensure protontricks is detected before proceeding ---
|
||||
@@ -459,7 +508,7 @@ class ProtontricksHandler:
|
||||
if "ShowDotFiles" not in content:
|
||||
logger.debug(f"Adding ShowDotFiles entry to {user_reg_path}")
|
||||
with open(user_reg_path, 'a', encoding='utf-8') as f:
|
||||
f.write('\n[Software\\Wine] 1603891765\n')
|
||||
f.write('\n[Software\\Wine] 1603891765\n')
|
||||
f.write('"ShowDotFiles"="Y"\n')
|
||||
dotfiles_set_success = True # Count file write as success too
|
||||
else:
|
||||
@@ -468,7 +517,7 @@ class ProtontricksHandler:
|
||||
else:
|
||||
logger.warning(f"user.reg not found at {user_reg_path}, creating it.")
|
||||
with open(user_reg_path, 'w', encoding='utf-8') as f:
|
||||
f.write('[Software\\Wine] 1603891765\n')
|
||||
f.write('[Software\\Wine] 1603891765\n')
|
||||
f.write('"ShowDotFiles"="Y"\n')
|
||||
dotfiles_set_success = True # Creating file counts as success
|
||||
except Exception as e:
|
||||
@@ -577,12 +626,22 @@ class ProtontricksHandler:
|
||||
def get_wine_prefix_path(self, appid) -> Optional[str]:
|
||||
"""Gets the WINEPREFIX path for a given AppID.
|
||||
|
||||
Uses native path discovery when enabled, falls back to protontricks detection.
|
||||
|
||||
Args:
|
||||
appid (str): The Steam AppID.
|
||||
|
||||
Returns:
|
||||
The WINEPREFIX path as a string, or None if detection fails.
|
||||
"""
|
||||
# Use native operations if enabled
|
||||
if self.use_native_operations:
|
||||
logger.debug(f"Getting WINEPREFIX for AppID {appid} via native path discovery")
|
||||
try:
|
||||
return self._get_native_steam_service().get_wine_prefix_path(appid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Native WINEPREFIX detection failed, falling back to protontricks: {e}")
|
||||
|
||||
logger.debug(f"Getting WINEPREFIX for AppID {appid}")
|
||||
result = self.run_protontricks("-c", "echo $WINEPREFIX", appid)
|
||||
if result and result.returncode == 0 and result.stdout.strip():
|
||||
|
||||
@@ -149,7 +149,7 @@ class ResolutionHandler:
|
||||
return ["1280x720", "1280x800", "1920x1080", "1920x1200", "2560x1440"]
|
||||
|
||||
@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.
|
||||
|
||||
@@ -157,6 +157,7 @@ class ResolutionHandler:
|
||||
modlist_dir (str): Path to the modlist directory.
|
||||
game_var (str): The game identifier (e.g., "Skyrim Special Edition", "Fallout 4").
|
||||
set_res (str): The desired resolution (e.g., "1920x1080").
|
||||
vanilla_game_dir (str): Optional path to vanilla game directory for fallback.
|
||||
|
||||
Returns:
|
||||
bool: True if successful or not applicable, False on error.
|
||||
@@ -211,22 +212,30 @@ class ResolutionHandler:
|
||||
|
||||
logger.debug(f"Processing {prefs_filenames}...")
|
||||
prefs_files_found = []
|
||||
# Search common locations: profiles/, stock game dirs
|
||||
search_dirs = [modlist_path / "profiles"]
|
||||
# Add potential stock game directories dynamically (case-insensitive)
|
||||
potential_stock_dirs = [d for d in modlist_path.iterdir() if d.is_dir() and
|
||||
d.name.lower() in ["stock game", "game root", "stock folder", "skyrim stock"]] # Add more if needed
|
||||
search_dirs.extend(potential_stock_dirs)
|
||||
|
||||
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)))
|
||||
# Search entire modlist directory recursively for all target files
|
||||
logger.debug(f"Searching entire modlist directory for: {prefs_filenames}")
|
||||
for fname in prefs_filenames:
|
||||
found_files = list(modlist_path.rglob(fname))
|
||||
prefs_files_found.extend(found_files)
|
||||
if found_files:
|
||||
logger.debug(f"Found {len(found_files)} {fname} files: {[str(f) for f in found_files]}")
|
||||
|
||||
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.")
|
||||
# Consider this success as the main operation didn't fail?
|
||||
return True
|
||||
logger.warning(f"No preference files ({prefs_filenames}) found in modlist directory.")
|
||||
|
||||
# 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:
|
||||
files_processed += 1
|
||||
@@ -314,19 +323,23 @@ class ResolutionHandler:
|
||||
|
||||
new_lines = []
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
modified = True
|
||||
else:
|
||||
|
||||
@@ -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()
|
||||
@@ -41,7 +41,7 @@ class ShortcutHandler:
|
||||
self._last_shortcuts_backup = None # Track the last backup path
|
||||
self._safe_shortcuts_backup = None # Track backup made just before restart
|
||||
# Initialize ProtontricksHandler here, passing steamdeck status
|
||||
self.protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck)
|
||||
self.protontricks_handler = ProtontricksHandler(self.steamdeck)
|
||||
|
||||
def _enable_tab_completion(self):
|
||||
"""Enable tab completion for file paths using the shared completer"""
|
||||
@@ -964,7 +964,7 @@ class ShortcutHandler:
|
||||
self.logger.info(f"Attempting to find current AppID for shortcut: '{shortcut_name}' (exe_path: '{exe_path}')")
|
||||
try:
|
||||
from .protontricks_handler import ProtontricksHandler # Local import
|
||||
pt_handler = ProtontricksHandler(steamdeck=self.steamdeck)
|
||||
pt_handler = ProtontricksHandler(self.steamdeck)
|
||||
if not pt_handler.detect_protontricks():
|
||||
self.logger.error("Protontricks not detected")
|
||||
return None
|
||||
@@ -988,8 +988,8 @@ class ShortcutHandler:
|
||||
shortcuts_data = VDFHandler.load(shortcuts_vdf_path, binary=True)
|
||||
if shortcuts_data and 'shortcuts' in shortcuts_data:
|
||||
for idx, shortcut in shortcuts_data['shortcuts'].items():
|
||||
app_name = shortcut.get('AppName', '').strip()
|
||||
exe = shortcut.get('Exe', '').strip('"').strip()
|
||||
app_name = shortcut.get('AppName', shortcut.get('appname', '')).strip()
|
||||
exe = shortcut.get('Exe', shortcut.get('exe', '')).strip('"').strip()
|
||||
vdf_shortcuts.append((app_name, exe, idx))
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error parsing shortcuts.vdf for exe path matching: {e}")
|
||||
@@ -1036,7 +1036,7 @@ class ShortcutHandler:
|
||||
matched_shortcuts = []
|
||||
|
||||
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
|
||||
self.logger.error(f"shortcuts.vdf path not found or invalid: {self.shortcuts_path}")
|
||||
self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations")
|
||||
return []
|
||||
|
||||
# Directly process the single shortcuts.vdf file found during init
|
||||
@@ -1054,9 +1054,9 @@ class ShortcutHandler:
|
||||
self.logger.warning(f"Skipping invalid shortcut entry (not a dict) at index {shortcut_id} in {shortcuts_file}")
|
||||
continue
|
||||
|
||||
app_name = shortcut.get('AppName')
|
||||
exe_path = shortcut.get('Exe', '').strip('"')
|
||||
start_dir = shortcut.get('StartDir', '').strip('"')
|
||||
app_name = shortcut.get('AppName', shortcut.get('appname'))
|
||||
exe_path = shortcut.get('Exe', shortcut.get('exe', '')).strip('"')
|
||||
start_dir = shortcut.get('StartDir', shortcut.get('startdir', '')).strip('"')
|
||||
|
||||
# Check if the base name of the exe_path matches the target
|
||||
if app_name and start_dir and os.path.basename(exe_path) == executable_name:
|
||||
@@ -1159,7 +1159,7 @@ class ShortcutHandler:
|
||||
|
||||
# --- Use the single shortcuts.vdf path found during init ---
|
||||
if not self.shortcuts_path or not os.path.isfile(self.shortcuts_path):
|
||||
self.logger.error(f"shortcuts.vdf path not found or invalid: {self.shortcuts_path}")
|
||||
self.logger.info(f"No shortcuts.vdf file found at {self.shortcuts_path} - this is normal for new Steam installations")
|
||||
return []
|
||||
|
||||
vdf_path = self.shortcuts_path
|
||||
@@ -1180,18 +1180,21 @@ class ShortcutHandler:
|
||||
self.logger.warning(f"Skipping invalid shortcut entry at index {index} in {vdf_path}")
|
||||
continue
|
||||
|
||||
exe_path = shortcut_details.get('Exe', '').strip('"') # Get Exe path, remove quotes
|
||||
app_name = shortcut_details.get('AppName', 'Unknown Shortcut')
|
||||
exe_path = shortcut_details.get('Exe', shortcut_details.get('exe', '')).strip('"') # Get Exe path, remove quotes
|
||||
app_name = shortcut_details.get('AppName', shortcut_details.get('appname', 'Unknown Shortcut'))
|
||||
|
||||
# Check if the executable_name is present in the Exe path
|
||||
if executable_name in os.path.basename(exe_path):
|
||||
self.logger.info(f"Found matching shortcut '{app_name}' in {vdf_path}")
|
||||
# Extract relevant details
|
||||
# Extract relevant details with case-insensitive fallbacks
|
||||
app_id = shortcut_details.get('appid', shortcut_details.get('AppID', shortcut_details.get('appId', None)))
|
||||
start_dir = shortcut_details.get('StartDir', shortcut_details.get('startdir', '')).strip('"')
|
||||
|
||||
match = {
|
||||
'AppName': app_name,
|
||||
'Exe': exe_path, # Store unquoted path
|
||||
'StartDir': shortcut_details.get('StartDir', '').strip('"') # Unquoted
|
||||
# Add other useful fields if needed, e.g., 'ShortcutPath'
|
||||
'StartDir': start_dir,
|
||||
'appid': app_id # Include the AppID for conversion to unsigned
|
||||
}
|
||||
matching_shortcuts.append(match)
|
||||
else:
|
||||
|
||||
@@ -222,15 +222,21 @@ class ValidationHandler:
|
||||
def validate_steam_shortcut(self, app_id: str) -> Tuple[bool, str]:
|
||||
"""Validate a Steam shortcut."""
|
||||
try:
|
||||
# Check if shortcuts.vdf exists
|
||||
shortcuts_path = Path.home() / '.steam' / 'steam' / 'userdata' / '75424832' / 'config' / 'shortcuts.vdf'
|
||||
# Use native Steam service to get proper shortcuts.vdf path with multi-user support
|
||||
from jackify.backend.services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
shortcuts_path = steam_service.get_shortcuts_vdf_path()
|
||||
|
||||
if not shortcuts_path:
|
||||
return False, "Could not determine shortcuts.vdf path (no active Steam user found)"
|
||||
|
||||
if not shortcuts_path.exists():
|
||||
return False, "shortcuts.vdf not found"
|
||||
|
||||
|
||||
# Check if shortcuts.vdf is accessible
|
||||
if not os.access(shortcuts_path, os.R_OK | os.W_OK):
|
||||
return False, "shortcuts.vdf is not accessible"
|
||||
|
||||
|
||||
# Parse shortcuts.vdf using VDFHandler
|
||||
shortcuts_data = VDFHandler.load(str(shortcuts_path), binary=True)
|
||||
|
||||
|
||||
@@ -132,7 +132,8 @@ class WabbajackParser:
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered'
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
return [display_names.get(game, game) for game in self.supported_games]
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
import glob
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional, Tuple, List, Dict
|
||||
from .subprocess_utils import get_clean_subprocess_env
|
||||
|
||||
# Initialize logger
|
||||
@@ -197,16 +197,43 @@ class WineUtils:
|
||||
logger.error(f"Error editing binary working paths: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_sd_card_mounts():
|
||||
"""
|
||||
Dynamically detect all current SD card mount points
|
||||
Returns list of mount point paths
|
||||
"""
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(['df', '-h'], capture_output=True, text=True, timeout=5)
|
||||
sd_mounts = []
|
||||
for line in result.stdout.split('\n'):
|
||||
# Look for common SD card mount patterns
|
||||
if '/run/media' in line or ('/mnt' in line and 'sdcard' in line.lower()):
|
||||
parts = line.split()
|
||||
if len(parts) >= 6: # df output has 6+ columns
|
||||
mount_point = parts[-1] # Last column is mount point
|
||||
if mount_point.startswith(('/run/media', '/mnt')):
|
||||
sd_mounts.append(mount_point)
|
||||
return sd_mounts
|
||||
except Exception:
|
||||
# Fallback to common patterns if df fails
|
||||
return ['/run/media/mmcblk0p1', '/run/media/deck']
|
||||
|
||||
@staticmethod
|
||||
def _strip_sdcard_path(path):
|
||||
"""
|
||||
Strip /run/media/deck/UUID from SD card paths
|
||||
Internal helper method
|
||||
Strip any detected SD card mount prefix from paths
|
||||
Handles both /run/media/mmcblk0p1 and /run/media/deck/UUID patterns
|
||||
"""
|
||||
if path.startswith("/run/media/deck/"):
|
||||
parts = path.split("/", 5)
|
||||
if len(parts) >= 6:
|
||||
return "/" + parts[5]
|
||||
sd_mounts = WineUtils._get_sd_card_mounts()
|
||||
|
||||
for mount in sd_mounts:
|
||||
if path.startswith(mount):
|
||||
# Strip the mount prefix and ensure proper leading slash
|
||||
relative_path = path[len(mount):].lstrip('/')
|
||||
return "/" + relative_path if relative_path else "/"
|
||||
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
@@ -510,10 +537,7 @@ class WineUtils:
|
||||
if "mods" in binary_path:
|
||||
# mods path type found
|
||||
if modlist_sdcard:
|
||||
path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir
|
||||
# Strip /run/media/deck/UUID if present
|
||||
if '/run/media/' in path_middle:
|
||||
path_middle = '/' + path_middle.split('/run/media/', 1)[1].split('/', 2)[2]
|
||||
path_middle = WineUtils._strip_sdcard_path(modlist_dir)
|
||||
else:
|
||||
path_middle = modlist_dir
|
||||
|
||||
@@ -523,10 +547,7 @@ class WineUtils:
|
||||
elif any(x in binary_path for x in ["Stock Game", "Game Root", "STOCK GAME", "Stock Game Folder", "Stock Folder", "Skyrim Stock", "root/Skyrim Special Edition"]):
|
||||
# Stock/Game Root found
|
||||
if modlist_sdcard:
|
||||
path_middle = modlist_dir.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in modlist_dir else modlist_dir
|
||||
# Strip /run/media/deck/UUID if present
|
||||
if '/run/media/' in path_middle:
|
||||
path_middle = '/' + path_middle.split('/run/media/', 1)[1].split('/', 2)[2]
|
||||
path_middle = WineUtils._strip_sdcard_path(modlist_dir)
|
||||
else:
|
||||
path_middle = modlist_dir
|
||||
|
||||
@@ -562,7 +583,7 @@ class WineUtils:
|
||||
elif "steamapps" in binary_path:
|
||||
# Steamapps found
|
||||
if basegame_sdcard:
|
||||
path_middle = steam_library.split('mmcblk0p1', 1)[1] if 'mmcblk0p1' in steam_library else steam_library
|
||||
path_middle = WineUtils._strip_sdcard_path(steam_library)
|
||||
drive_letter = "D:"
|
||||
else:
|
||||
path_middle = steam_library.split('steamapps', 1)[0] if 'steamapps' in steam_library else steam_library
|
||||
@@ -609,12 +630,49 @@ class WineUtils:
|
||||
"""
|
||||
# Clean up the version string for directory matching
|
||||
version_patterns = [proton_version, proton_version.replace(' ', '_'), proton_version.replace(' ', '')]
|
||||
# Standard Steam library locations
|
||||
steam_common_paths = [
|
||||
Path.home() / ".steam/steam/steamapps/common",
|
||||
Path.home() / ".local/share/Steam/steamapps/common",
|
||||
Path.home() / ".steam/root/steamapps/common"
|
||||
]
|
||||
|
||||
# Get actual Steam library paths from libraryfolders.vdf (smart detection)
|
||||
steam_common_paths = []
|
||||
compatibility_paths = []
|
||||
|
||||
try:
|
||||
from .path_handler import PathHandler
|
||||
# Get root Steam library paths (without /steamapps/common suffix)
|
||||
root_steam_libs = PathHandler.get_all_steam_library_paths()
|
||||
for lib_path in root_steam_libs:
|
||||
lib = Path(lib_path)
|
||||
if lib.exists():
|
||||
# Valve Proton: {library}/steamapps/common
|
||||
common_path = lib / "steamapps/common"
|
||||
if common_path.exists():
|
||||
steam_common_paths.append(common_path)
|
||||
# GE-Proton: same Steam installation root + compatibilitytools.d
|
||||
compatibility_paths.append(lib / "compatibilitytools.d")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not detect Steam libraries from libraryfolders.vdf: {e}")
|
||||
|
||||
# Fallback locations if dynamic detection fails
|
||||
if not steam_common_paths:
|
||||
steam_common_paths = [
|
||||
Path.home() / ".steam/steam/steamapps/common",
|
||||
Path.home() / ".local/share/Steam/steamapps/common",
|
||||
Path.home() / ".steam/root/steamapps/common"
|
||||
]
|
||||
|
||||
if not compatibility_paths:
|
||||
compatibility_paths = [
|
||||
Path.home() / ".steam/steam/compatibilitytools.d",
|
||||
Path.home() / ".local/share/Steam/compatibilitytools.d"
|
||||
]
|
||||
|
||||
# Add standard compatibility tool locations (covers edge cases like Flatpak)
|
||||
compatibility_paths.extend([
|
||||
Path.home() / ".steam/root/compatibilitytools.d",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
|
||||
# Flatpak GE-Proton extension paths
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
|
||||
])
|
||||
# Special handling for Proton 9: try all possible directory names
|
||||
if proton_version.strip().startswith("Proton 9"):
|
||||
proton9_candidates = ["Proton 9.0", "Proton 9.0 (Beta)"]
|
||||
@@ -628,8 +686,9 @@ class WineUtils:
|
||||
wine_bin = subdir / "files/bin/wine"
|
||||
if wine_bin.is_file():
|
||||
return str(wine_bin)
|
||||
# General case: try version patterns
|
||||
for base_path in steam_common_paths:
|
||||
# General case: try version patterns in both steamapps and compatibilitytools.d
|
||||
all_paths = steam_common_paths + compatibility_paths
|
||||
for base_path in all_paths:
|
||||
if not base_path.is_dir():
|
||||
continue
|
||||
for pattern in version_patterns:
|
||||
@@ -643,7 +702,20 @@ class WineUtils:
|
||||
wine_bin = subdir / "files/bin/wine"
|
||||
if wine_bin.is_file():
|
||||
return str(wine_bin)
|
||||
# Fallback: Try 'Proton - Experimental' if present
|
||||
# Fallback: Try user's configured Proton version
|
||||
try:
|
||||
from .config_handler import ConfigHandler
|
||||
config = ConfigHandler()
|
||||
fallback_path = config.get_proton_path()
|
||||
if fallback_path != 'auto':
|
||||
fallback_wine_bin = Path(fallback_path) / "files/bin/wine"
|
||||
if fallback_wine_bin.is_file():
|
||||
logger.warning(f"Requested Proton version '{proton_version}' not found. Falling back to user's configured version.")
|
||||
return str(fallback_wine_bin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final fallback: Try 'Proton - Experimental' if present
|
||||
for base_path in steam_common_paths:
|
||||
wine_bin = base_path / "Proton - Experimental" / "files/bin/wine"
|
||||
if wine_bin.is_file():
|
||||
@@ -698,4 +770,307 @@ class WineUtils:
|
||||
proton_path = str(Path(wine_bin).parent.parent)
|
||||
logger.debug(f"Found Proton path: {proton_path}")
|
||||
|
||||
return compatdata_path, proton_path, wine_bin
|
||||
return compatdata_path, proton_path, wine_bin
|
||||
|
||||
@staticmethod
|
||||
def get_steam_library_paths() -> List[Path]:
|
||||
"""
|
||||
Get all Steam library paths from libraryfolders.vdf (handles Flatpak, custom locations, etc.).
|
||||
|
||||
Returns:
|
||||
List of Path objects for Steam library directories
|
||||
"""
|
||||
steam_common_paths = []
|
||||
|
||||
try:
|
||||
from .path_handler import PathHandler
|
||||
# Use existing PathHandler that reads libraryfolders.vdf
|
||||
library_paths = PathHandler.get_all_steam_library_paths()
|
||||
logger.info(f"PathHandler found Steam libraries: {library_paths}")
|
||||
|
||||
# Convert to steamapps/common paths for Proton scanning
|
||||
for lib_path in library_paths:
|
||||
common_path = lib_path / "steamapps" / "common"
|
||||
if common_path.exists():
|
||||
steam_common_paths.append(common_path)
|
||||
logger.debug(f"Added Steam library: {common_path}")
|
||||
else:
|
||||
logger.debug(f"Steam library path doesn't exist: {common_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PathHandler failed to read libraryfolders.vdf: {e}")
|
||||
|
||||
# Always add fallback paths in case PathHandler missed something
|
||||
fallback_paths = [
|
||||
Path.home() / ".steam/steam/steamapps/common",
|
||||
Path.home() / ".local/share/Steam/steamapps/common",
|
||||
Path.home() / ".steam/root/steamapps/common"
|
||||
]
|
||||
|
||||
for fallback_path in fallback_paths:
|
||||
if fallback_path.exists() and fallback_path not in steam_common_paths:
|
||||
steam_common_paths.append(fallback_path)
|
||||
logger.debug(f"Added fallback Steam library: {fallback_path}")
|
||||
|
||||
logger.info(f"Final Steam library paths for Proton scanning: {steam_common_paths}")
|
||||
return steam_common_paths
|
||||
|
||||
@staticmethod
|
||||
def get_compatibility_tool_paths() -> List[Path]:
|
||||
"""
|
||||
Get all compatibility tool paths for GE-Proton and other custom Proton versions.
|
||||
|
||||
Returns:
|
||||
List of Path objects for compatibility tool directories
|
||||
"""
|
||||
compat_paths = [
|
||||
Path.home() / ".steam/steam/compatibilitytools.d",
|
||||
Path.home() / ".local/share/Steam/compatibilitytools.d",
|
||||
Path.home() / ".steam/root/compatibilitytools.d",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d",
|
||||
# Flatpak GE-Proton extension paths
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam.CompatibilityTool.Proton-GE/.local/share/Steam/compatibilitytools.d",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/compatibilitytools.d/GE-Proton"
|
||||
]
|
||||
|
||||
# Return only existing paths
|
||||
return [path for path in compat_paths if path.exists()]
|
||||
|
||||
@staticmethod
|
||||
def scan_ge_proton_versions() -> List[Dict[str, any]]:
|
||||
"""
|
||||
Scan for available GE-Proton versions in compatibilitytools.d directories.
|
||||
|
||||
Returns:
|
||||
List of dicts with version info, sorted by priority (newest first)
|
||||
"""
|
||||
logger.info("Scanning for available GE-Proton versions...")
|
||||
|
||||
found_versions = []
|
||||
compat_paths = WineUtils.get_compatibility_tool_paths()
|
||||
|
||||
if not compat_paths:
|
||||
logger.warning("No compatibility tool paths found")
|
||||
return []
|
||||
|
||||
for compat_path in compat_paths:
|
||||
logger.debug(f"Scanning compatibility tools: {compat_path}")
|
||||
|
||||
try:
|
||||
# Look for GE-Proton directories
|
||||
for proton_dir in compat_path.iterdir():
|
||||
if not proton_dir.is_dir():
|
||||
continue
|
||||
|
||||
dir_name = proton_dir.name
|
||||
if not dir_name.startswith("GE-Proton"):
|
||||
continue
|
||||
|
||||
# Check for wine binary
|
||||
wine_bin = proton_dir / "files" / "bin" / "wine"
|
||||
if not wine_bin.exists() or not wine_bin.is_file():
|
||||
logger.debug(f"Skipping {dir_name} - no wine binary found")
|
||||
continue
|
||||
|
||||
# Parse version from directory name (e.g., "GE-Proton10-16")
|
||||
version_match = re.match(r'GE-Proton(\d+)-(\d+)', dir_name)
|
||||
if version_match:
|
||||
major_ver = int(version_match.group(1))
|
||||
minor_ver = int(version_match.group(2))
|
||||
|
||||
# Calculate priority: GE-Proton gets highest priority
|
||||
# Priority format: 200 (base) + major*10 + minor (e.g., 200 + 100 + 16 = 316)
|
||||
priority = 200 + (major_ver * 10) + minor_ver
|
||||
|
||||
found_versions.append({
|
||||
'name': dir_name,
|
||||
'path': proton_dir,
|
||||
'wine_bin': wine_bin,
|
||||
'priority': priority,
|
||||
'major_version': major_ver,
|
||||
'minor_version': minor_ver,
|
||||
'type': 'GE-Proton'
|
||||
})
|
||||
logger.debug(f"Found {dir_name} at {proton_dir} (priority: {priority})")
|
||||
else:
|
||||
logger.debug(f"Skipping {dir_name} - unknown GE-Proton version format")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error scanning {compat_path}: {e}")
|
||||
|
||||
# Sort by priority (highest first, so newest GE-Proton versions come first)
|
||||
found_versions.sort(key=lambda x: x['priority'], reverse=True)
|
||||
|
||||
logger.info(f"Found {len(found_versions)} GE-Proton version(s)")
|
||||
return found_versions
|
||||
|
||||
@staticmethod
|
||||
def scan_valve_proton_versions() -> List[Dict[str, any]]:
|
||||
"""
|
||||
Scan for available Valve Proton versions with fallback priority.
|
||||
|
||||
Returns:
|
||||
List of dicts with version info, sorted by priority (best first)
|
||||
"""
|
||||
logger.info("Scanning for available Valve Proton versions...")
|
||||
|
||||
found_versions = []
|
||||
steam_libs = WineUtils.get_steam_library_paths()
|
||||
|
||||
if not steam_libs:
|
||||
logger.warning("No Steam library paths found")
|
||||
return []
|
||||
|
||||
# Priority order for Valve Proton versions
|
||||
# Note: GE-Proton uses 200+ range, so Valve Proton gets 100+ range
|
||||
preferred_versions = [
|
||||
("Proton - Experimental", 150), # Higher priority than regular Valve Proton
|
||||
("Proton 10.0", 140),
|
||||
("Proton 9.0", 130),
|
||||
("Proton 9.0 (Beta)", 125)
|
||||
]
|
||||
|
||||
for steam_path in steam_libs:
|
||||
logger.debug(f"Scanning Steam library: {steam_path}")
|
||||
|
||||
for version_name, priority in preferred_versions:
|
||||
proton_path = steam_path / version_name
|
||||
wine_bin = proton_path / "files" / "bin" / "wine"
|
||||
|
||||
if wine_bin.exists() and wine_bin.is_file():
|
||||
found_versions.append({
|
||||
'name': version_name,
|
||||
'path': proton_path,
|
||||
'wine_bin': wine_bin,
|
||||
'priority': priority,
|
||||
'type': 'Valve-Proton'
|
||||
})
|
||||
logger.debug(f"Found {version_name} at {proton_path}")
|
||||
|
||||
# Sort by priority (highest first)
|
||||
found_versions.sort(key=lambda x: x['priority'], reverse=True)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
unique_versions = []
|
||||
seen_names = set()
|
||||
for version in found_versions:
|
||||
if version['name'] not in seen_names:
|
||||
unique_versions.append(version)
|
||||
seen_names.add(version['name'])
|
||||
|
||||
logger.info(f"Found {len(unique_versions)} unique Valve Proton version(s)")
|
||||
return unique_versions
|
||||
|
||||
@staticmethod
|
||||
def scan_all_proton_versions() -> List[Dict[str, any]]:
|
||||
"""
|
||||
Scan for all available Proton versions (GE-Proton + Valve Proton) with unified priority.
|
||||
|
||||
Priority Chain (highest to lowest):
|
||||
1. GE-Proton10-16+ (priority 316+)
|
||||
2. GE-Proton10-* (priority 200+)
|
||||
3. Proton - Experimental (priority 150)
|
||||
4. Proton 10.0 (priority 140)
|
||||
5. Proton 9.0 (priority 130)
|
||||
6. Proton 9.0 (Beta) (priority 125)
|
||||
|
||||
Returns:
|
||||
List of dicts with version info, sorted by priority (best first)
|
||||
"""
|
||||
logger.info("Scanning for all available Proton versions...")
|
||||
|
||||
all_versions = []
|
||||
|
||||
# Scan GE-Proton versions (highest priority)
|
||||
ge_versions = WineUtils.scan_ge_proton_versions()
|
||||
all_versions.extend(ge_versions)
|
||||
|
||||
# Scan Valve Proton versions
|
||||
valve_versions = WineUtils.scan_valve_proton_versions()
|
||||
all_versions.extend(valve_versions)
|
||||
|
||||
# Sort by priority (highest first)
|
||||
all_versions.sort(key=lambda x: x['priority'], reverse=True)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
unique_versions = []
|
||||
seen_names = set()
|
||||
for version in all_versions:
|
||||
if version['name'] not in seen_names:
|
||||
unique_versions.append(version)
|
||||
seen_names.add(version['name'])
|
||||
|
||||
if unique_versions:
|
||||
logger.info(f"Found {len(unique_versions)} total Proton version(s)")
|
||||
logger.info(f"Best available: {unique_versions[0]['name']} ({unique_versions[0]['type']})")
|
||||
else:
|
||||
logger.warning("No Proton versions found")
|
||||
|
||||
return unique_versions
|
||||
|
||||
@staticmethod
|
||||
def select_best_proton() -> Optional[Dict[str, any]]:
|
||||
"""
|
||||
Select the best available Proton version (GE-Proton or Valve Proton) using unified precedence.
|
||||
|
||||
Returns:
|
||||
Dict with version info for the best Proton, or None if none found
|
||||
"""
|
||||
available_versions = WineUtils.scan_all_proton_versions()
|
||||
|
||||
if not available_versions:
|
||||
logger.warning("No compatible Proton versions found")
|
||||
return None
|
||||
|
||||
# Return the highest priority version (first in sorted list)
|
||||
best_version = available_versions[0]
|
||||
logger.info(f"Selected best Proton version: {best_version['name']} ({best_version['type']})")
|
||||
return best_version
|
||||
|
||||
@staticmethod
|
||||
def select_best_valve_proton() -> Optional[Dict[str, any]]:
|
||||
"""
|
||||
Select the best available Valve Proton version using fallback precedence.
|
||||
Note: This method is kept for backward compatibility. Consider using select_best_proton() instead.
|
||||
|
||||
Returns:
|
||||
Dict with version info for the best Proton, or None if none found
|
||||
"""
|
||||
available_versions = WineUtils.scan_valve_proton_versions()
|
||||
|
||||
if not available_versions:
|
||||
logger.warning("No compatible Valve Proton versions found")
|
||||
return None
|
||||
|
||||
# Return the highest priority version (first in sorted list)
|
||||
best_version = available_versions[0]
|
||||
logger.info(f"Selected Valve Proton version: {best_version['name']}")
|
||||
return best_version
|
||||
|
||||
@staticmethod
|
||||
def check_proton_requirements() -> Tuple[bool, str, Optional[Dict[str, any]]]:
|
||||
"""
|
||||
Check if compatible Proton version is available for workflows.
|
||||
|
||||
Returns:
|
||||
tuple: (requirements_met, status_message, proton_info)
|
||||
- requirements_met: True if compatible Proton found
|
||||
- status_message: Human-readable status for display to user
|
||||
- proton_info: Dict with Proton details if found, None otherwise
|
||||
"""
|
||||
logger.info("Checking Proton requirements for workflow...")
|
||||
|
||||
# Scan for available Proton versions (includes GE-Proton + Valve Proton)
|
||||
best_proton = WineUtils.select_best_proton()
|
||||
|
||||
if best_proton:
|
||||
# Compatible Proton found
|
||||
proton_type = best_proton.get('type', 'Unknown')
|
||||
status_msg = f"✓ Using {best_proton['name']} ({proton_type}) for this workflow"
|
||||
logger.info(f"Proton requirements satisfied: {best_proton['name']} ({proton_type})")
|
||||
return True, status_msg, best_proton
|
||||
else:
|
||||
# No compatible Proton found
|
||||
status_msg = "✗ No compatible Proton version found (GE-Proton 10+, Proton 9+, 10, or Experimental required)"
|
||||
logger.warning("Proton requirements not met - no compatible version found")
|
||||
return False, status_msg, None
|
||||
893
jackify/backend/handlers/winetricks_handler.py
Normal file
893
jackify/backend/handlers/winetricks_handler.py
Normal 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}")
|
||||
@@ -38,6 +38,65 @@ class AutomatedPrefixService:
|
||||
"""Get consistent progress timestamp"""
|
||||
from jackify.shared.timing import get_timestamp
|
||||
return get_timestamp()
|
||||
|
||||
def _get_user_proton_version(self, modlist_name: str = None):
|
||||
"""Get user's preferred Proton version from config, with fallback to auto-detection
|
||||
|
||||
Args:
|
||||
modlist_name: Optional modlist name for special handling (e.g., Lorerim)
|
||||
"""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Check for Lorerim-specific Proton override first
|
||||
modlist_normalized = modlist_name.lower().replace(" ", "") if modlist_name else ""
|
||||
if modlist_normalized == 'lorerim':
|
||||
lorerim_proton = self._get_lorerim_preferred_proton()
|
||||
if lorerim_proton:
|
||||
logger.info(f"Lorerim detected: Using {lorerim_proton} instead of user settings")
|
||||
self._store_proton_override_notification("Lorerim", lorerim_proton)
|
||||
return lorerim_proton
|
||||
|
||||
# Check for Lost Legacy-specific Proton override (needs Proton 9 for ENB compatibility)
|
||||
if modlist_normalized == 'lostlegacy':
|
||||
lostlegacy_proton = self._get_lorerim_preferred_proton() # Use same logic as Lorerim
|
||||
if lostlegacy_proton:
|
||||
logger.info(f"Lost Legacy detected: Using {lostlegacy_proton} instead of user settings (ENB compatibility)")
|
||||
self._store_proton_override_notification("Lost Legacy", lostlegacy_proton)
|
||||
return lostlegacy_proton
|
||||
|
||||
config_handler = ConfigHandler()
|
||||
user_proton_path = config_handler.get_game_proton_path()
|
||||
|
||||
if user_proton_path == 'auto':
|
||||
# Use enhanced fallback logic with GE-Proton preference
|
||||
logger.info("User selected auto-detect, using GE-Proton → Experimental → Proton precedence")
|
||||
return WineUtils.select_best_proton()
|
||||
else:
|
||||
# User has selected a specific Proton version
|
||||
# Use the exact directory name for Steam config.vdf
|
||||
try:
|
||||
proton_version = os.path.basename(user_proton_path)
|
||||
# GE-Proton uses exact directory name, Valve Proton needs lowercase conversion
|
||||
if proton_version.startswith('GE-Proton'):
|
||||
# Keep GE-Proton name exactly as-is
|
||||
steam_proton_name = proton_version
|
||||
else:
|
||||
# Convert Valve Proton names to Steam's format
|
||||
steam_proton_name = proton_version.lower().replace(' - ', '_').replace(' ', '_').replace('-', '_')
|
||||
if not steam_proton_name.startswith('proton'):
|
||||
steam_proton_name = f"proton_{steam_proton_name}"
|
||||
|
||||
logger.info(f"Using user-selected Proton: {steam_proton_name}")
|
||||
return steam_proton_name
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid user Proton path '{user_proton_path}', falling back to auto: {e}")
|
||||
return WineUtils.select_best_proton()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user Proton preference, using default: {e}")
|
||||
return "proton_experimental"
|
||||
|
||||
|
||||
def create_shortcut_with_native_service(self, shortcut_name: str, exe_path: str,
|
||||
@@ -87,6 +146,9 @@ class AutomatedPrefixService:
|
||||
logger.warning(f"Could not generate STEAM_COMPAT_MOUNTS, using default: {e}")
|
||||
launch_options = "%command%"
|
||||
|
||||
# Get user's preferred Proton version (with Lorerim-specific override)
|
||||
proton_version = self._get_user_proton_version(shortcut_name)
|
||||
|
||||
# Create shortcut with Proton using native service
|
||||
success, app_id = steam_service.create_shortcut_with_proton(
|
||||
app_name=shortcut_name,
|
||||
@@ -94,14 +156,14 @@ class AutomatedPrefixService:
|
||||
start_dir=modlist_install_dir,
|
||||
launch_options=launch_options,
|
||||
tags=["Jackify"],
|
||||
proton_version="proton_experimental"
|
||||
proton_version=proton_version
|
||||
)
|
||||
|
||||
if success and app_id:
|
||||
logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}")
|
||||
return True, app_id
|
||||
else:
|
||||
logger.error("❌ Native Steam service failed to create shortcut")
|
||||
logger.error("Native Steam service failed to create shortcut")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
@@ -292,15 +354,18 @@ class AutomatedPrefixService:
|
||||
logger.error(f"Steam userdata directory not found: {userdata_dir}")
|
||||
return None
|
||||
|
||||
# Find the first user directory (most systems have only one user)
|
||||
user_dirs = [d for d in userdata_dir.iterdir() if d.is_dir() and d.name.isdigit()]
|
||||
if not user_dirs:
|
||||
logger.error("No Steam user directories found in userdata")
|
||||
# Use NativeSteamService for proper user detection
|
||||
from ..services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
if not steam_service.find_steam_user():
|
||||
logger.error("Could not detect Steam user for shortcuts")
|
||||
return None
|
||||
|
||||
shortcuts_path = steam_service.get_shortcuts_vdf_path()
|
||||
if not shortcuts_path:
|
||||
logger.error("Could not get shortcuts.vdf path from Steam service")
|
||||
return None
|
||||
|
||||
# Use the first user directory found
|
||||
user_dir = user_dirs[0]
|
||||
shortcuts_path = user_dir / "config" / "shortcuts.vdf"
|
||||
|
||||
logger.debug(f"Looking for shortcuts.vdf at: {shortcuts_path}")
|
||||
if not shortcuts_path.exists():
|
||||
@@ -407,7 +472,7 @@ exit"""
|
||||
|
||||
if shortcut_name in name:
|
||||
appid = shortcut.get('appid')
|
||||
exe_path = shortcut.get('Exe', '')
|
||||
exe_path = shortcut.get('Exe', '').strip('"')
|
||||
|
||||
logger.info(f"Found shortcut: {name}")
|
||||
logger.info(f" AppID: {appid}")
|
||||
@@ -443,7 +508,7 @@ exit"""
|
||||
try:
|
||||
# Use the existing protontricks handler
|
||||
from jackify.backend.handlers.protontricks_handler import ProtontricksHandler
|
||||
protontricks_handler = ProtontricksHandler(steamdeck=steamdeck or False)
|
||||
protontricks_handler = ProtontricksHandler(steamdeck or False)
|
||||
result = protontricks_handler.run_protontricks('-l')
|
||||
|
||||
if result.returncode == 0:
|
||||
@@ -471,7 +536,7 @@ exit"""
|
||||
logger.warning(f"Error running protontricks -l on attempt {i+1}: {e}")
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
@@ -939,7 +1004,7 @@ echo Prefix creation complete.
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.TimeoutExpired):
|
||||
continue
|
||||
|
||||
logger.info("ℹ️ No more processes to kill")
|
||||
logger.info("No more processes to kill")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -1296,7 +1361,7 @@ echo Prefix creation complete.
|
||||
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
@@ -1356,7 +1421,7 @@ echo Prefix creation complete.
|
||||
if killed_count > 0:
|
||||
logger.info(f" Killed {killed_count} ModOrganizer processes")
|
||||
else:
|
||||
logger.warning("❌ No ModOrganizer processes found to kill")
|
||||
logger.warning("No ModOrganizer processes found to kill")
|
||||
|
||||
return killed_count
|
||||
|
||||
@@ -1512,6 +1577,9 @@ echo Prefix creation complete.
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Steam Configuration complete!")
|
||||
# Show Proton override notification if applicable
|
||||
self._show_proton_override_notification(progress_callback)
|
||||
|
||||
logger.info(" Simple automated prefix creation workflow completed successfully")
|
||||
return True, prefix_path, actual_appid
|
||||
|
||||
@@ -1624,11 +1692,11 @@ echo Prefix creation complete.
|
||||
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error setting CompatTool on shortcut: {e}")
|
||||
logger.error(f"Error setting CompatTool on shortcut: {e}")
|
||||
return False
|
||||
|
||||
def _set_proton_on_shortcut(self, shortcut_name: str) -> bool:
|
||||
@@ -1718,21 +1786,15 @@ echo Prefix creation complete.
|
||||
progress_callback("=== Steam Integration ===")
|
||||
progress_callback(f"{self._get_progress_timestamp()} Creating Steam shortcut with native service")
|
||||
|
||||
# Dual approach: Registry injection for FNV, launch options for Enderal
|
||||
# Registry injection approach for both FNV and Enderal
|
||||
from ..handlers.modlist_handler import ModlistHandler
|
||||
modlist_handler = ModlistHandler()
|
||||
special_game_type = modlist_handler.detect_special_game_type(modlist_install_dir)
|
||||
|
||||
# Generate launch options only for Enderal (FNV uses registry injection)
|
||||
|
||||
# No launch options needed - both FNV and Enderal use registry injection
|
||||
custom_launch_options = None
|
||||
if special_game_type == "enderal":
|
||||
custom_launch_options = self._generate_special_game_launch_options(special_game_type, modlist_install_dir)
|
||||
if not custom_launch_options:
|
||||
logger.error(f"Failed to generate launch options for Enderal modlist")
|
||||
return False, None, None, None
|
||||
logger.info("Using launch options approach for Enderal modlist")
|
||||
elif special_game_type == "fnv":
|
||||
logger.info("Using registry injection approach for FNV modlist")
|
||||
if special_game_type in ["fnv", "enderal"]:
|
||||
logger.info(f"Using registry injection approach for {special_game_type.upper()} modlist")
|
||||
else:
|
||||
logger.debug("Standard modlist - no special game handling needed")
|
||||
|
||||
@@ -1808,23 +1870,19 @@ echo Prefix creation complete.
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Setup verification completed")
|
||||
|
||||
# Step 5: Inject game registry entries for FNV modlists (Enderal uses launch options)
|
||||
# Step 5: Inject game registry entries for FNV and Enderal modlists
|
||||
# Get prefix path (needed for logging regardless of game type)
|
||||
prefix_path = self.get_prefix_path(appid)
|
||||
|
||||
if special_game_type == "fnv":
|
||||
logger.info("Step 5: Injecting FNV game registry entries")
|
||||
|
||||
if special_game_type in ["fnv", "enderal"]:
|
||||
logger.info(f"Step 5: Injecting {special_game_type.upper()} game registry entries")
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Injecting FNV game registry entries...")
|
||||
|
||||
progress_callback(f"{self._get_progress_timestamp()} Injecting {special_game_type.upper()} game registry entries...")
|
||||
|
||||
if prefix_path:
|
||||
self._inject_game_registry_entries(str(prefix_path))
|
||||
else:
|
||||
logger.warning("Could not find prefix path for registry injection")
|
||||
elif special_game_type == "enderal":
|
||||
logger.info("Step 5: Skipping registry injection for Enderal (using launch options)")
|
||||
if progress_callback:
|
||||
progress_callback(f"{self._get_progress_timestamp()} Skipping registry injection for Enderal")
|
||||
else:
|
||||
logger.info("Step 5: Skipping registry injection for standard modlist")
|
||||
if progress_callback:
|
||||
@@ -1835,6 +1893,11 @@ echo Prefix creation complete.
|
||||
if progress_callback:
|
||||
progress_callback(f"{last_timestamp} Steam integration complete")
|
||||
progress_callback("") # Blank line after Steam integration complete
|
||||
|
||||
# Show Proton override notification if applicable
|
||||
self._show_proton_override_notification(progress_callback)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("") # Extra blank line to span across Configuration Summary
|
||||
progress_callback("") # And one more to create space before Prefix Configuration
|
||||
|
||||
@@ -2496,15 +2559,31 @@ echo Prefix creation complete.
|
||||
Returns:
|
||||
Path to localconfig.vdf or None if not found
|
||||
"""
|
||||
# Try the standard Steam userdata path
|
||||
steam_userdata_path = Path.home() / ".steam" / "steam" / "userdata"
|
||||
if steam_userdata_path.exists():
|
||||
# Find the first user directory (usually only one on Steam Deck)
|
||||
user_dirs = [d for d in steam_userdata_path.iterdir() if d.is_dir() and d.name.isdigit()]
|
||||
if user_dirs:
|
||||
localconfig_path = user_dirs[0] / "config" / "localconfig.vdf"
|
||||
# Use NativeSteamService for proper user detection
|
||||
try:
|
||||
from ..services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
|
||||
if steam_service.find_steam_user():
|
||||
localconfig_path = steam_service.user_config_path / "localconfig.vdf"
|
||||
if localconfig_path.exists():
|
||||
return str(localconfig_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error using Steam service for localconfig.vdf detection: {e}")
|
||||
|
||||
# Fallback to manual detection
|
||||
steam_userdata_path = Path.home() / ".steam" / "steam" / "userdata"
|
||||
if steam_userdata_path.exists():
|
||||
user_dirs = [d for d in steam_userdata_path.iterdir() if d.is_dir() and d.name.isdigit() and d.name != "0"]
|
||||
if user_dirs:
|
||||
# Use most recently modified directory as fallback
|
||||
try:
|
||||
most_recent = max(user_dirs, key=lambda d: d.stat().st_mtime)
|
||||
localconfig_path = most_recent / "config" / "localconfig.vdf"
|
||||
if localconfig_path.exists():
|
||||
return str(localconfig_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.error("Could not find localconfig.vdf")
|
||||
return None
|
||||
@@ -2601,8 +2680,11 @@ echo Prefix creation complete.
|
||||
env = os.environ.copy()
|
||||
env['STEAM_COMPAT_CLIENT_INSTALL_PATH'] = str(steam_root)
|
||||
env['STEAM_COMPAT_DATA_PATH'] = str(compatdata_dir / str(abs(appid)))
|
||||
# Suppress GUI windows by unsetting DISPLAY
|
||||
# Suppress GUI windows using jackify-engine's proven approach
|
||||
env['DISPLAY'] = ''
|
||||
env['WAYLAND_DISPLAY'] = ''
|
||||
env['WINEDEBUG'] = '-all'
|
||||
env['WINEDLLOVERRIDES'] = 'msdia80.dll=n;conhost.exe=d;cmd.exe=d'
|
||||
|
||||
# Create the compatdata directory
|
||||
compat_dir = compatdata_dir / str(abs(appid))
|
||||
@@ -2615,8 +2697,19 @@ echo Prefix creation complete.
|
||||
# Run proton run wineboot -u to initialize the prefix
|
||||
cmd = [str(proton_path), 'run', 'wineboot', '-u']
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=60)
|
||||
|
||||
# Adjust timeout for SD card installations on Steam Deck (slower I/O)
|
||||
from ..services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
is_steamdeck_sdcard = (platform_service.is_steamdeck and
|
||||
str(proton_path).startswith('/run/media/'))
|
||||
timeout = 180 if is_steamdeck_sdcard else 60
|
||||
if is_steamdeck_sdcard:
|
||||
logger.info(f"Using extended timeout ({timeout}s) for Steam Deck SD card Proton installation")
|
||||
|
||||
# Use jackify-engine's approach: UseShellExecute=false, CreateNoWindow=true equivalent
|
||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=timeout,
|
||||
shell=False, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
|
||||
logger.info(f"Proton exit code: {result.returncode}")
|
||||
|
||||
if result.stdout:
|
||||
@@ -2633,7 +2726,7 @@ echo Prefix creation complete.
|
||||
logger.info(f" Proton prefix created at: {pfx}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"⚠️ Proton prefix not found at: {pfx}")
|
||||
logger.warning(f"Proton prefix not found at: {pfx}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -2644,31 +2737,64 @@ echo Prefix creation complete.
|
||||
return False
|
||||
|
||||
def _find_proton_binary(self, proton_common_dir: Path) -> Optional[Path]:
|
||||
"""Locate a Proton wrapper script to use (prefer Experimental)."""
|
||||
candidates = []
|
||||
preferred = [
|
||||
"Proton - Experimental",
|
||||
"Proton 9.0",
|
||||
"Proton 8.0",
|
||||
"Proton Hotfix",
|
||||
]
|
||||
|
||||
for name in preferred:
|
||||
p = proton_common_dir / name / "proton"
|
||||
if p.exists():
|
||||
candidates.append(p)
|
||||
|
||||
# As a fallback, scan all Proton* dirs
|
||||
if not candidates and proton_common_dir.exists():
|
||||
for p in proton_common_dir.glob("Proton*/proton"):
|
||||
candidates.append(p)
|
||||
|
||||
if not candidates:
|
||||
logger.error("No Proton wrapper found under steamapps/common")
|
||||
"""Locate a Proton wrapper script to use, respecting user's configuration."""
|
||||
try:
|
||||
from jackify.backend.handlers.config_handler import ConfigHandler
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
config = ConfigHandler()
|
||||
user_proton_path = config.get_game_proton_path()
|
||||
|
||||
# If user selected a specific Proton, try that first
|
||||
if user_proton_path != 'auto':
|
||||
# Resolve symlinks to handle ~/.steam/steam -> ~/.local/share/Steam
|
||||
resolved_proton_path = os.path.realpath(user_proton_path)
|
||||
|
||||
# Check for wine binary in different Proton structures
|
||||
valve_proton_wine = Path(resolved_proton_path) / "dist" / "bin" / "wine"
|
||||
ge_proton_wine = Path(resolved_proton_path) / "files" / "bin" / "wine"
|
||||
|
||||
if valve_proton_wine.exists() or ge_proton_wine.exists():
|
||||
# Found user's Proton, now find the proton wrapper script
|
||||
proton_wrapper = Path(resolved_proton_path) / "proton"
|
||||
if proton_wrapper.exists():
|
||||
logger.info(f"Using user-selected Proton wrapper: {proton_wrapper}")
|
||||
return proton_wrapper
|
||||
else:
|
||||
logger.warning(f"User-selected Proton missing wrapper script: {proton_wrapper}")
|
||||
else:
|
||||
logger.warning(f"User-selected Proton path invalid: {user_proton_path}")
|
||||
|
||||
# Fall back to auto-detection
|
||||
logger.info("Falling back to automatic Proton detection")
|
||||
candidates = []
|
||||
preferred = [
|
||||
"Proton - Experimental",
|
||||
"Proton 9.0",
|
||||
"Proton 8.0",
|
||||
"Proton Hotfix",
|
||||
]
|
||||
|
||||
for name in preferred:
|
||||
p = proton_common_dir / name / "proton"
|
||||
if p.exists():
|
||||
candidates.append(p)
|
||||
|
||||
# As a fallback, scan all Proton* dirs
|
||||
if not candidates and proton_common_dir.exists():
|
||||
for p in proton_common_dir.glob("Proton*/proton"):
|
||||
candidates.append(p)
|
||||
|
||||
if not candidates:
|
||||
logger.error("No Proton wrapper found under steamapps/common")
|
||||
return None
|
||||
|
||||
logger.info(f"Using auto-detected Proton wrapper: {candidates[0]}")
|
||||
return candidates[0]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding Proton binary: {e}")
|
||||
return None
|
||||
|
||||
logger.info(f"Using Proton wrapper: {candidates[0]}")
|
||||
return candidates[0]
|
||||
|
||||
def replace_existing_shortcut(self, shortcut_name: str, exe_path: str, modlist_install_dir: str) -> Tuple[bool, Optional[int]]:
|
||||
"""
|
||||
@@ -2718,26 +2844,39 @@ echo Prefix creation complete.
|
||||
|
||||
def verify_compatibility_tool_persists(self, appid: int) -> bool:
|
||||
"""
|
||||
Verify that the compatibility tool setting persists.
|
||||
|
||||
Verify that the compatibility tool setting persists with correct Proton version.
|
||||
|
||||
Args:
|
||||
appid: The AppID to check
|
||||
|
||||
|
||||
Returns:
|
||||
True if compatibility tool is set, False otherwise
|
||||
True if compatibility tool is correctly set, False otherwise
|
||||
"""
|
||||
try:
|
||||
config_path = Path.home() / ".steam/steam/config/config.vdf"
|
||||
with open(config_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
if f'"{appid}"' in content:
|
||||
logger.info(" Compatibility tool persists")
|
||||
return True
|
||||
else:
|
||||
logger.warning("⚠️ Compatibility tool not found")
|
||||
if not config_path.exists():
|
||||
logger.warning("Steam config.vdf not found")
|
||||
return False
|
||||
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check if AppID exists and has a Proton version set
|
||||
if f'"{appid}"' in content:
|
||||
# Get the expected Proton version
|
||||
expected_proton = self._get_user_proton_version()
|
||||
|
||||
# Look for the Proton version in the compatibility tool mapping
|
||||
if expected_proton in content:
|
||||
logger.info(f" Compatibility tool persists: {expected_proton}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"AppID {appid} found but Proton version '{expected_proton}' not set")
|
||||
return False
|
||||
else:
|
||||
logger.warning("Compatibility tool not found")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying compatibility tool: {e}")
|
||||
return False
|
||||
@@ -2851,14 +2990,115 @@ echo Prefix creation complete.
|
||||
logger.error(f"Failed to update registry path: {e}")
|
||||
return False
|
||||
|
||||
def _apply_universal_dotnet_fixes(self, modlist_compatdata_path: str):
|
||||
"""Apply universal dotnet4.x compatibility registry fixes to ALL modlists"""
|
||||
try:
|
||||
prefix_path = os.path.join(modlist_compatdata_path, "pfx")
|
||||
if not os.path.exists(prefix_path):
|
||||
logger.warning(f"Prefix path not found: {prefix_path}")
|
||||
return False
|
||||
|
||||
logger.info("Applying universal dotnet4.x compatibility registry fixes...")
|
||||
|
||||
# Find the appropriate Wine binary to use for registry operations
|
||||
wine_binary = self._find_wine_binary_for_registry(modlist_compatdata_path)
|
||||
if not wine_binary:
|
||||
logger.error("Could not find Wine binary for registry operations")
|
||||
return False
|
||||
|
||||
# Set environment for Wine registry operations
|
||||
env = os.environ.copy()
|
||||
env['WINEPREFIX'] = prefix_path
|
||||
env['WINEDEBUG'] = '-all' # Suppress Wine debug output
|
||||
|
||||
# Registry fix 1: Set mscoree=native DLL override
|
||||
# This tells Wine to use native .NET runtime instead of Wine's implementation
|
||||
logger.debug("Setting mscoree=native DLL override...")
|
||||
cmd1 = [
|
||||
wine_binary, 'reg', 'add',
|
||||
'HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides',
|
||||
'/v', 'mscoree', '/t', 'REG_SZ', '/d', 'native', '/f'
|
||||
]
|
||||
|
||||
result1 = subprocess.run(cmd1, env=env, capture_output=True, text=True)
|
||||
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):
|
||||
"""Detect and inject FNV/Enderal game paths into modlist's system.reg"""
|
||||
"""Detect and inject FNV/Enderal game paths and apply universal dotnet4.x compatibility fixes"""
|
||||
system_reg_path = os.path.join(modlist_compatdata_path, "pfx", "system.reg")
|
||||
if not os.path.exists(system_reg_path):
|
||||
logger.warning("system.reg not found, skipping game path injection")
|
||||
return
|
||||
|
||||
logger.info("Detecting and injecting game registry entries...")
|
||||
|
||||
logger.info("Detecting game registry entries...")
|
||||
|
||||
# NOTE: Universal dotnet4.x registry fixes now applied in modlist_handler.py after .reg downloads
|
||||
|
||||
# Game configurations
|
||||
games_config = {
|
||||
@@ -2889,10 +3129,107 @@ echo Prefix creation complete.
|
||||
)
|
||||
if success:
|
||||
logger.info(f"Updated registry entry for {config['name']}")
|
||||
|
||||
# Special handling for Enderal: Create required user directory
|
||||
if app_id == "976620": # Enderal Special Edition
|
||||
try:
|
||||
enderal_docs_path = os.path.join(modlist_compatdata_path, "pfx", "drive_c", "users", "steamuser", "Documents", "My Games", "Enderal Special Edition")
|
||||
os.makedirs(enderal_docs_path, exist_ok=True)
|
||||
logger.info(f"Created Enderal user directory: {enderal_docs_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create Enderal user directory: {e}")
|
||||
else:
|
||||
logger.warning(f"Failed to update registry entry for {config['name']}")
|
||||
else:
|
||||
logger.debug(f"{config['name']} not found in Steam libraries")
|
||||
|
||||
logger.info("Game registry injection completed")
|
||||
|
||||
|
||||
def _get_lorerim_preferred_proton(self):
|
||||
"""Get Lorerim's preferred Proton 9 version with specific priority order"""
|
||||
try:
|
||||
from jackify.backend.handlers.wine_utils import WineUtils
|
||||
|
||||
# Get all available Proton versions
|
||||
available_versions = WineUtils.scan_all_proton_versions()
|
||||
|
||||
if not available_versions:
|
||||
logger.warning("No Proton versions found for Lorerim override")
|
||||
return None
|
||||
|
||||
# Priority order for Lorerim:
|
||||
# 1. GEProton9-27 (specific version)
|
||||
# 2. Other GEProton-9 versions (latest first)
|
||||
# 3. Valve Proton 9 (any version)
|
||||
|
||||
preferred_candidates = []
|
||||
|
||||
for version in available_versions:
|
||||
version_name = version['name']
|
||||
|
||||
# Priority 1: GEProton9-27 specifically
|
||||
if version_name == 'GE-Proton9-27':
|
||||
logger.info(f"Lorerim: Found preferred GE-Proton9-27")
|
||||
return version_name
|
||||
|
||||
# Priority 2: Other GE-Proton 9 versions
|
||||
elif version_name.startswith('GE-Proton9-'):
|
||||
preferred_candidates.append(('ge_proton_9', version_name, version))
|
||||
|
||||
# Priority 3: Valve Proton 9
|
||||
elif 'Proton 9' in version_name:
|
||||
preferred_candidates.append(('valve_proton_9', version_name, version))
|
||||
|
||||
# Return best candidate if any found
|
||||
if preferred_candidates:
|
||||
# Sort by priority (GE-Proton first, then by name for latest)
|
||||
preferred_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
|
||||
best_candidate = preferred_candidates[0]
|
||||
logger.info(f"Lorerim: Selected {best_candidate[1]} as best Proton 9 option")
|
||||
return best_candidate[1]
|
||||
|
||||
logger.warning("Lorerim: No suitable Proton 9 versions found, will use user settings")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error detecting Lorerim Proton preference: {e}")
|
||||
return None
|
||||
|
||||
def _store_proton_override_notification(self, modlist_name: str, proton_version: str):
|
||||
"""Store Proton override information for end-of-install notification"""
|
||||
try:
|
||||
# Store override info for later display
|
||||
if not hasattr(self, '_proton_overrides'):
|
||||
self._proton_overrides = []
|
||||
|
||||
self._proton_overrides.append({
|
||||
'modlist': modlist_name,
|
||||
'proton_version': proton_version,
|
||||
'reason': f'{modlist_name} requires Proton 9 for optimal compatibility'
|
||||
})
|
||||
|
||||
logger.debug(f"Stored Proton override notification: {modlist_name} → {proton_version}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store Proton override notification: {e}")
|
||||
|
||||
def _show_proton_override_notification(self, progress_callback=None):
|
||||
"""Display any Proton override notifications to the user"""
|
||||
try:
|
||||
if hasattr(self, '_proton_overrides') and self._proton_overrides:
|
||||
for override in self._proton_overrides:
|
||||
notification_msg = f"PROTON OVERRIDE: {override['modlist']} configured to use {override['proton_version']} for optimal compatibility"
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("")
|
||||
progress_callback(f"{self._get_progress_timestamp()} {notification_msg}")
|
||||
|
||||
logger.info(notification_msg)
|
||||
|
||||
# Clear notifications after display
|
||||
self._proton_overrides = []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to show Proton override notification: {e}")
|
||||
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ class ModlistService:
|
||||
"""Lazy initialization of modlist handler."""
|
||||
if self._modlist_handler is None:
|
||||
from ..handlers.modlist_handler import ModlistHandler
|
||||
# Initialize with proper dependencies
|
||||
self._modlist_handler = ModlistHandler()
|
||||
from ..services.platform_detection_service import PlatformDetectionService
|
||||
# Initialize with proper dependencies and centralized Steam Deck detection
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
self._modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck)
|
||||
return self._modlist_handler
|
||||
|
||||
def _get_wabbajack_handler(self):
|
||||
@@ -293,15 +295,7 @@ class ModlistService:
|
||||
elif context.get('machineid'):
|
||||
cmd += ['-m', context['machineid']]
|
||||
cmd += ['-o', install_dir_str, '-d', download_dir_str]
|
||||
|
||||
# Check for debug mode and add --debug flag
|
||||
from ..handlers.config_handler import ConfigHandler
|
||||
config_handler = ConfigHandler()
|
||||
debug_mode = config_handler.get('debug_mode', False)
|
||||
if debug_mode:
|
||||
cmd.append('--debug')
|
||||
logger.debug("DEBUG: Added --debug flag to jackify-engine command")
|
||||
|
||||
|
||||
# NOTE: API key is passed via environment variable only, not as command line argument
|
||||
|
||||
# Store original environment values (copied from working code)
|
||||
@@ -637,8 +631,13 @@ class ModlistService:
|
||||
'mo2_exe_path': str(context.install_dir / 'ModOrganizer.exe'),
|
||||
'resolution': getattr(context, 'resolution', None),
|
||||
'skip_confirmation': True, # Service layer should be non-interactive
|
||||
'manual_steps_completed': False
|
||||
'manual_steps_completed': False,
|
||||
'appid': getattr(context, 'app_id', None) # Fix: Include appid like other configuration paths
|
||||
}
|
||||
|
||||
# DEBUG: Log what resolution we're passing
|
||||
logger.info(f"DEBUG: config_context resolution = {config_context['resolution']}")
|
||||
logger.info(f"DEBUG: context.resolution = {getattr(context, 'resolution', 'NOT_SET')}")
|
||||
|
||||
# Run the complete configuration phase
|
||||
success = modlist_menu.run_modlist_configuration_phase(config_context)
|
||||
|
||||
185
jackify/backend/services/native_steam_operations_service.py
Normal file
185
jackify/backend/services/native_steam_operations_service.py
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Native Steam Operations Service
|
||||
|
||||
This service provides direct Steam operations using VDF parsing and path discovery.
|
||||
Replaces protontricks dependencies with native Steam functionality.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import vdf
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, List
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NativeSteamOperationsService:
|
||||
"""
|
||||
Service providing native Steam operations for shortcut discovery and prefix management.
|
||||
|
||||
Replaces protontricks functionality with:
|
||||
- Direct VDF parsing for shortcut discovery
|
||||
- Native compatdata path construction
|
||||
- Direct Steam library detection
|
||||
"""
|
||||
|
||||
def __init__(self, steamdeck: bool = False):
|
||||
self.steamdeck = steamdeck
|
||||
self.logger = logger
|
||||
|
||||
def list_non_steam_shortcuts(self) -> Dict[str, str]:
|
||||
"""
|
||||
List non-Steam shortcuts via direct VDF parsing.
|
||||
|
||||
Returns:
|
||||
Dict mapping shortcut name to AppID string
|
||||
"""
|
||||
logger.info("Listing non-Steam shortcuts via native VDF parsing...")
|
||||
shortcuts = {}
|
||||
|
||||
try:
|
||||
# Find all possible shortcuts.vdf locations
|
||||
shortcuts_paths = self._find_shortcuts_vdf_paths()
|
||||
|
||||
for shortcuts_path in shortcuts_paths:
|
||||
logger.debug(f"Checking shortcuts.vdf at: {shortcuts_path}")
|
||||
|
||||
if not shortcuts_path.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(shortcuts_path, 'rb') as f:
|
||||
data = vdf.binary_load(f)
|
||||
|
||||
shortcuts_data = data.get('shortcuts', {})
|
||||
for shortcut_key, shortcut_data in shortcuts_data.items():
|
||||
if isinstance(shortcut_data, dict):
|
||||
app_name = shortcut_data.get('AppName', '').strip()
|
||||
app_id = shortcut_data.get('appid', '')
|
||||
|
||||
if app_name and app_id:
|
||||
# Convert to positive AppID string (compatible format)
|
||||
positive_appid = str(abs(int(app_id)))
|
||||
shortcuts[app_name] = positive_appid
|
||||
logger.debug(f"Found non-Steam shortcut: '{app_name}' with AppID {positive_appid}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading shortcuts.vdf at {shortcuts_path}: {e}")
|
||||
continue
|
||||
|
||||
if not shortcuts:
|
||||
logger.warning("No non-Steam shortcuts found in any shortcuts.vdf")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing non-Steam shortcuts: {e}")
|
||||
|
||||
return shortcuts
|
||||
|
||||
def set_steam_permissions(self, modlist_dir: str, steamdeck: bool = False) -> bool:
|
||||
"""
|
||||
Handle Steam access permissions for native operations.
|
||||
|
||||
Since we're using direct file access, no special permissions needed.
|
||||
|
||||
Args:
|
||||
modlist_dir: Modlist directory path (for future use)
|
||||
steamdeck: Steam Deck flag (for future use)
|
||||
|
||||
Returns:
|
||||
Always True (no permissions needed for native operations)
|
||||
"""
|
||||
logger.debug("Using native Steam operations, no permission setting needed")
|
||||
return True
|
||||
|
||||
def get_wine_prefix_path(self, appid: str) -> Optional[str]:
|
||||
"""
|
||||
Get WINEPREFIX path via direct compatdata discovery.
|
||||
|
||||
Args:
|
||||
appid: Steam AppID string
|
||||
|
||||
Returns:
|
||||
WINEPREFIX path string or None if not found
|
||||
"""
|
||||
logger.debug(f"Getting WINEPREFIX for AppID {appid} using native path discovery")
|
||||
|
||||
try:
|
||||
# Find all possible compatdata locations
|
||||
compatdata_paths = self._find_compatdata_paths()
|
||||
|
||||
for compatdata_base in compatdata_paths:
|
||||
prefix_path = compatdata_base / appid / "pfx"
|
||||
logger.debug(f"Checking prefix path: {prefix_path}")
|
||||
|
||||
if prefix_path.exists():
|
||||
logger.debug(f"Found WINEPREFIX: {prefix_path}")
|
||||
return str(prefix_path)
|
||||
|
||||
logger.error(f"WINEPREFIX not found for AppID {appid} in any compatdata location")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting WINEPREFIX for AppID {appid}: {e}")
|
||||
return None
|
||||
|
||||
def _find_shortcuts_vdf_paths(self) -> List[Path]:
|
||||
"""Find all possible shortcuts.vdf file locations"""
|
||||
paths = []
|
||||
|
||||
# Standard Steam locations
|
||||
steam_locations = [
|
||||
Path.home() / ".steam/steam",
|
||||
Path.home() / ".local/share/Steam",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/home/.steam/steam",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/home/.local/share/Steam"
|
||||
]
|
||||
|
||||
for steam_root in steam_locations:
|
||||
if not steam_root.exists():
|
||||
continue
|
||||
|
||||
# Find userdata directories
|
||||
userdata_path = steam_root / "userdata"
|
||||
if userdata_path.exists():
|
||||
for user_dir in userdata_path.iterdir():
|
||||
if user_dir.is_dir() and user_dir.name.isdigit():
|
||||
shortcuts_path = user_dir / "config" / "shortcuts.vdf"
|
||||
paths.append(shortcuts_path)
|
||||
|
||||
return paths
|
||||
|
||||
def _find_compatdata_paths(self) -> List[Path]:
|
||||
"""Find all possible compatdata directory locations"""
|
||||
paths = []
|
||||
|
||||
# Standard compatdata locations
|
||||
standard_locations = [
|
||||
Path.home() / ".steam/steam/steamapps/compatdata",
|
||||
Path.home() / ".local/share/Steam/steamapps/compatdata",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/home/.steam/steam/steamapps/compatdata",
|
||||
Path.home() / ".var/app/com.valvesoftware.Steam/home/.local/share/Steam/steamapps/compatdata"
|
||||
]
|
||||
|
||||
for path in standard_locations:
|
||||
if path.exists():
|
||||
paths.append(path)
|
||||
|
||||
# Also check additional Steam libraries via libraryfolders.vdf
|
||||
try:
|
||||
from jackify.shared.paths import PathHandler
|
||||
all_steam_libs = PathHandler.get_all_steam_library_paths()
|
||||
|
||||
for lib_path in all_steam_libs:
|
||||
compatdata_path = lib_path / "steamapps" / "compatdata"
|
||||
if compatdata_path.exists():
|
||||
paths.append(compatdata_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get additional Steam library paths: {e}")
|
||||
|
||||
return paths
|
||||
@@ -15,6 +15,8 @@ import vdf
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Dict, Any, List
|
||||
|
||||
from ..handlers.vdf_handler import VDFHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class NativeSteamService:
|
||||
@@ -28,37 +30,153 @@ class NativeSteamService:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.steam_path = Path.home() / ".steam" / "steam"
|
||||
self.userdata_path = self.steam_path / "userdata"
|
||||
self.steam_paths = [
|
||||
Path.home() / ".steam" / "steam",
|
||||
Path.home() / ".local" / "share" / "Steam",
|
||||
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam",
|
||||
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam",
|
||||
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam"
|
||||
]
|
||||
self.steam_path = None
|
||||
self.userdata_path = None
|
||||
self.user_id = None
|
||||
self.user_config_path = None
|
||||
|
||||
def find_steam_user(self) -> bool:
|
||||
"""Find the active Steam user directory"""
|
||||
"""
|
||||
Find the active Steam user directory using Steam's own configuration files.
|
||||
No more guessing - uses loginusers.vdf to get the most recent user and converts SteamID64 to SteamID3.
|
||||
"""
|
||||
try:
|
||||
if not self.userdata_path.exists():
|
||||
logger.error("Steam userdata directory not found")
|
||||
# Step 1: Find Steam installation using Steam's own file structure
|
||||
if not self._find_steam_installation():
|
||||
logger.error("No Steam installation found")
|
||||
return False
|
||||
|
||||
# Find the first user directory (usually there's only one)
|
||||
user_dirs = [d for d in self.userdata_path.iterdir() if d.is_dir() and d.name.isdigit()]
|
||||
if not user_dirs:
|
||||
logger.error("No Steam user directories found")
|
||||
|
||||
# Step 2: Parse loginusers.vdf to get the most recent user (SteamID64)
|
||||
steamid64 = self._get_most_recent_user_from_loginusers()
|
||||
if not steamid64:
|
||||
logger.warning("Could not determine most recent Steam user from loginusers.vdf, trying fallback method")
|
||||
# Fallback: Look for existing user directories in userdata
|
||||
steamid3 = self._find_user_from_userdata_directory()
|
||||
if steamid3:
|
||||
logger.info(f"Found Steam user using userdata directory fallback: SteamID3={steamid3}")
|
||||
# Skip the conversion step since we already have SteamID3
|
||||
self.user_id = str(steamid3)
|
||||
self.user_config_path = self.userdata_path / str(steamid3) / "config"
|
||||
logger.info(f"Steam user set up via fallback: {self.user_id}")
|
||||
logger.info(f"User config path: {self.user_config_path}")
|
||||
return True
|
||||
else:
|
||||
logger.error("Could not determine Steam user using any method")
|
||||
return False
|
||||
|
||||
# Step 3: Convert SteamID64 to SteamID3 (userdata directory format)
|
||||
steamid3 = self._convert_steamid64_to_steamid3(steamid64)
|
||||
logger.info(f"Most recent Steam user: SteamID64={steamid64}, SteamID3={steamid3}")
|
||||
|
||||
# Step 4: Verify the userdata directory exists
|
||||
user_dir = self.userdata_path / str(steamid3)
|
||||
if not user_dir.exists():
|
||||
logger.error(f"Userdata directory does not exist: {user_dir}")
|
||||
return False
|
||||
|
||||
# Use the first user directory
|
||||
user_dir = user_dirs[0]
|
||||
self.user_id = user_dir.name
|
||||
self.user_config_path = user_dir / "config"
|
||||
|
||||
logger.info(f"Found Steam user: {self.user_id}")
|
||||
|
||||
config_dir = user_dir / "config"
|
||||
if not config_dir.exists():
|
||||
logger.error(f"User config directory does not exist: {config_dir}")
|
||||
return False
|
||||
|
||||
# Step 5: Set up the service state
|
||||
self.user_id = str(steamid3)
|
||||
self.user_config_path = config_dir
|
||||
|
||||
logger.info(f"VERIFIED Steam user: {self.user_id}")
|
||||
logger.info(f"User config path: {self.user_config_path}")
|
||||
logger.info(f"Shortcuts.vdf will be at: {self.user_config_path / 'shortcuts.vdf'}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding Steam user: {e}")
|
||||
logger.error(f"Error finding Steam user: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _find_steam_installation(self) -> bool:
|
||||
"""Find Steam installation by checking for config/loginusers.vdf"""
|
||||
for steam_path in self.steam_paths:
|
||||
loginusers_path = steam_path / "config" / "loginusers.vdf"
|
||||
userdata_path = steam_path / "userdata"
|
||||
|
||||
if loginusers_path.exists() and userdata_path.exists():
|
||||
self.steam_path = steam_path
|
||||
self.userdata_path = userdata_path
|
||||
logger.info(f"Found Steam installation at: {steam_path}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_most_recent_user_from_loginusers(self) -> Optional[str]:
|
||||
"""
|
||||
Parse loginusers.vdf to get the SteamID64 of the most recent user.
|
||||
Uses Steam's own MostRecent flag and Timestamp.
|
||||
"""
|
||||
try:
|
||||
loginusers_path = self.steam_path / "config" / "loginusers.vdf"
|
||||
|
||||
# Load VDF data
|
||||
vdf_data = VDFHandler.load(str(loginusers_path), binary=False)
|
||||
if not vdf_data:
|
||||
logger.error("Failed to parse loginusers.vdf")
|
||||
return None
|
||||
|
||||
users_section = vdf_data.get("users", {})
|
||||
if not users_section:
|
||||
logger.error("No users section found in loginusers.vdf")
|
||||
return None
|
||||
|
||||
most_recent_user = None
|
||||
most_recent_timestamp = 0
|
||||
|
||||
# Find user with MostRecent=1 or highest timestamp
|
||||
for steamid64, user_data in users_section.items():
|
||||
if isinstance(user_data, dict):
|
||||
# Check for MostRecent flag first
|
||||
if user_data.get("MostRecent") == "1":
|
||||
logger.info(f"Found user marked as MostRecent: {steamid64}")
|
||||
return steamid64
|
||||
|
||||
# Also track highest timestamp as fallback
|
||||
timestamp = int(user_data.get("Timestamp", "0"))
|
||||
if timestamp > most_recent_timestamp:
|
||||
most_recent_timestamp = timestamp
|
||||
most_recent_user = steamid64
|
||||
|
||||
# Return user with highest timestamp if no MostRecent flag found
|
||||
if most_recent_user:
|
||||
logger.info(f"Found most recent user by timestamp: {most_recent_user}")
|
||||
return most_recent_user
|
||||
|
||||
logger.error("No valid users found in loginusers.vdf")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing loginusers.vdf: {e}")
|
||||
return None
|
||||
|
||||
def _convert_steamid64_to_steamid3(self, steamid64: str) -> int:
|
||||
"""
|
||||
Convert SteamID64 to SteamID3 (used in userdata directory names).
|
||||
Formula: SteamID3 = SteamID64 - 76561197960265728
|
||||
"""
|
||||
try:
|
||||
steamid64_int = int(steamid64)
|
||||
steamid3 = steamid64_int - 76561197960265728
|
||||
logger.debug(f"Converted SteamID64 {steamid64} to SteamID3 {steamid3}")
|
||||
return steamid3
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid SteamID64 format: {steamid64}")
|
||||
raise
|
||||
|
||||
|
||||
def get_shortcuts_vdf_path(self) -> Optional[Path]:
|
||||
"""Get the path to shortcuts.vdf"""
|
||||
if not self.user_config_path:
|
||||
@@ -228,29 +346,35 @@ class NativeSteamService:
|
||||
|
||||
# Write back to file
|
||||
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
|
||||
else:
|
||||
logger.error("❌ Failed to write shortcut to VDF")
|
||||
logger.error("Failed to write shortcut to VDF")
|
||||
return False, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error creating shortcut: {e}")
|
||||
logger.error(f"Error creating shortcut: {e}")
|
||||
return False, None
|
||||
|
||||
def set_proton_version(self, app_id: int, proton_version: str = "proton_experimental") -> bool:
|
||||
"""
|
||||
Set the Proton version for a specific app using ONLY config.vdf like steam-conductor does.
|
||||
|
||||
|
||||
Args:
|
||||
app_id: The unsigned AppID
|
||||
app_id: The unsigned AppID
|
||||
proton_version: The Proton version to set
|
||||
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
# Ensure Steam user detection is completed first
|
||||
if not self.steam_path:
|
||||
if not self.find_steam_user():
|
||||
logger.error("Cannot set Proton version: Steam user detection failed")
|
||||
return False
|
||||
|
||||
logger.info(f"Setting Proton version '{proton_version}' for AppID {app_id} using STL-compatible format")
|
||||
|
||||
|
||||
try:
|
||||
# Step 1: Write to the main config.vdf for CompatToolMapping
|
||||
config_path = self.steam_path / "config" / "config.vdf"
|
||||
@@ -272,8 +396,27 @@ class NativeSteamService:
|
||||
# Find the CompatToolMapping section
|
||||
compat_start = config_text.find('"CompatToolMapping"')
|
||||
if compat_start == -1:
|
||||
logger.error("CompatToolMapping section not found in config.vdf")
|
||||
return False
|
||||
logger.warning("CompatToolMapping section not found in config.vdf, creating it")
|
||||
# Find the Steam section to add CompatToolMapping to
|
||||
steam_section = config_text.find('"Steam"')
|
||||
if steam_section == -1:
|
||||
logger.error("Steam section not found in config.vdf")
|
||||
return False
|
||||
|
||||
# Find the opening brace for Steam section
|
||||
steam_brace = config_text.find('{', steam_section)
|
||||
if steam_brace == -1:
|
||||
logger.error("Steam section opening brace not found")
|
||||
return False
|
||||
|
||||
# Insert CompatToolMapping section right after Steam opening brace
|
||||
insert_pos = steam_brace + 1
|
||||
compat_section = '\n\t\t"CompatToolMapping"\n\t\t{\n\t\t}\n'
|
||||
config_text = config_text[:insert_pos] + compat_section + config_text[insert_pos:]
|
||||
|
||||
# Update compat_start position after insertion
|
||||
compat_start = config_text.find('"CompatToolMapping"')
|
||||
logger.info("Created CompatToolMapping section in config.vdf")
|
||||
|
||||
# Find the closing brace for CompatToolMapping
|
||||
# Look for the opening brace after CompatToolMapping
|
||||
@@ -320,24 +463,34 @@ class NativeSteamService:
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error setting Proton version: {e}")
|
||||
logger.error(f"Error setting Proton version: {e}")
|
||||
return False
|
||||
|
||||
def create_shortcut_with_proton(self, app_name: str, exe_path: str, start_dir: str = None,
|
||||
def create_shortcut_with_proton(self, app_name: str, exe_path: str, start_dir: str = None,
|
||||
launch_options: str = "%command%", tags: List[str] = None,
|
||||
proton_version: str = "proton_experimental") -> Tuple[bool, Optional[int]]:
|
||||
proton_version: str = None) -> Tuple[bool, Optional[int]]:
|
||||
"""
|
||||
Complete workflow: Create shortcut and set Proton version.
|
||||
|
||||
|
||||
This is the main method that replaces STL entirely.
|
||||
|
||||
|
||||
Returns:
|
||||
(success, app_id) - Success status and the AppID
|
||||
"""
|
||||
# Auto-detect best Proton version if none provided
|
||||
if proton_version is None:
|
||||
try:
|
||||
from jackify.backend.core.modlist_operations import _get_user_proton_version
|
||||
proton_version = _get_user_proton_version()
|
||||
logger.info(f"Auto-detected Proton version: {proton_version}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to auto-detect Proton, falling back to experimental: {e}")
|
||||
proton_version = "proton_experimental"
|
||||
|
||||
logger.info(f"Creating shortcut with Proton: '{app_name}' -> '{proton_version}'")
|
||||
|
||||
# Step 1: Create the shortcut
|
||||
@@ -351,7 +504,7 @@ class NativeSteamService:
|
||||
logger.error("Failed to set Proton version (shortcut still created)")
|
||||
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
|
||||
|
||||
def list_shortcuts(self) -> Dict[str, str]:
|
||||
@@ -388,12 +541,12 @@ class NativeSteamService:
|
||||
|
||||
# Write back
|
||||
if self.write_shortcuts_vdf(data):
|
||||
logger.info(f"✅ Removed shortcut '{app_name}'")
|
||||
logger.info(f"Removed shortcut '{app_name}'")
|
||||
return True
|
||||
else:
|
||||
logger.error("❌ Failed to write updated shortcuts")
|
||||
logger.error("Failed to write updated shortcuts")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error removing shortcut: {e}")
|
||||
logger.error(f"Error removing shortcut: {e}")
|
||||
return False
|
||||
67
jackify/backend/services/platform_detection_service.py
Normal file
67
jackify/backend/services/platform_detection_service.py
Normal 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()
|
||||
@@ -39,7 +39,7 @@ class ProtontricksDetectionService:
|
||||
def _get_protontricks_handler(self) -> ProtontricksHandler:
|
||||
"""Get or create ProtontricksHandler instance"""
|
||||
if self._protontricks_handler is None:
|
||||
self._protontricks_handler = ProtontricksHandler(steamdeck=self.steamdeck)
|
||||
self._protontricks_handler = ProtontricksHandler(self.steamdeck)
|
||||
return self._protontricks_handler
|
||||
|
||||
def detect_protontricks(self, use_cache: bool = True) -> Tuple[bool, str, str]:
|
||||
|
||||
437
jackify/backend/services/update_service.py
Normal file
437
jackify/backend/services/update_service.py
Normal 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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v8.0": {},
|
||||
".NETCoreApp,Version=v8.0/linux-x64": {
|
||||
"jackify-engine/0.3.12": {
|
||||
"jackify-engine/0.3.17": {
|
||||
"dependencies": {
|
||||
"Markdig": "0.40.0",
|
||||
"Microsoft.Extensions.Configuration.Json": "9.0.1",
|
||||
@@ -22,16 +22,16 @@
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"System.CommandLine": "2.0.0-beta4.22272.1",
|
||||
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
|
||||
"Wabbajack.CLI.Builder": "0.3.12",
|
||||
"Wabbajack.Downloaders.Bethesda": "0.3.12",
|
||||
"Wabbajack.Downloaders.Dispatcher": "0.3.12",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.12",
|
||||
"Wabbajack.Networking.Discord": "0.3.12",
|
||||
"Wabbajack.Networking.GitHub": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12",
|
||||
"Wabbajack.Server.Lib": "0.3.12",
|
||||
"Wabbajack.Services.OSIntegrated": "0.3.12",
|
||||
"Wabbajack.VFS": "0.3.12",
|
||||
"Wabbajack.CLI.Builder": "0.3.17",
|
||||
"Wabbajack.Downloaders.Bethesda": "0.3.17",
|
||||
"Wabbajack.Downloaders.Dispatcher": "0.3.17",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.17",
|
||||
"Wabbajack.Networking.Discord": "0.3.17",
|
||||
"Wabbajack.Networking.GitHub": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17",
|
||||
"Wabbajack.Server.Lib": "0.3.17",
|
||||
"Wabbajack.Services.OSIntegrated": "0.3.17",
|
||||
"Wabbajack.VFS": "0.3.17",
|
||||
"MegaApiClient": "1.0.0.0",
|
||||
"runtimepack.Microsoft.NETCore.App.Runtime.linux-x64": "8.0.19"
|
||||
},
|
||||
@@ -1781,7 +1781,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Wabbajack.CLI.Builder/0.3.12": {
|
||||
"Wabbajack.CLI.Builder/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Configuration.Json": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
@@ -1791,109 +1791,109 @@
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"System.CommandLine": "2.0.0-beta4.22272.1",
|
||||
"System.CommandLine.NamingConventionBinder": "2.0.0-beta4.22272.1",
|
||||
"Wabbajack.Paths": "0.3.12"
|
||||
"Wabbajack.Paths": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.CLI.Builder.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Common/0.3.12": {
|
||||
"Wabbajack.Common/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"System.Reactive": "6.0.1",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Networking.Http": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12"
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Networking.Http": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Common.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Compiler/0.3.12": {
|
||||
"Wabbajack.Compiler/0.3.17": {
|
||||
"dependencies": {
|
||||
"F23.StringSimilarity": "6.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"Wabbajack.Downloaders.Dispatcher": "0.3.12",
|
||||
"Wabbajack.Installer": "0.3.12",
|
||||
"Wabbajack.VFS": "0.3.12",
|
||||
"Wabbajack.Downloaders.Dispatcher": "0.3.17",
|
||||
"Wabbajack.Installer": "0.3.17",
|
||||
"Wabbajack.VFS": "0.3.17",
|
||||
"ini-parser-netstandard": "2.5.2"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Compiler.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Compression.BSA/0.3.12": {
|
||||
"Wabbajack.Compression.BSA/0.3.17": {
|
||||
"dependencies": {
|
||||
"K4os.Compression.LZ4.Streams": "1.3.8",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"SharpZipLib": "1.4.2",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.DTOs": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.DTOs": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Compression.BSA.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Compression.Zip/0.3.12": {
|
||||
"Wabbajack.Compression.Zip/0.3.17": {
|
||||
"dependencies": {
|
||||
"Wabbajack.IO.Async": "0.3.12"
|
||||
"Wabbajack.IO.Async": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Compression.Zip.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Configuration/0.3.12": {
|
||||
"Wabbajack.Configuration/0.3.17": {
|
||||
"runtime": {
|
||||
"Wabbajack.Configuration.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.Bethesda/0.3.12": {
|
||||
"Wabbajack.Downloaders.Bethesda/0.3.17": {
|
||||
"dependencies": {
|
||||
"LibAES-CTR": "1.1.0",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"SharpZipLib": "1.4.2",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.BethesdaNet": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.BethesdaNet": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.Bethesda.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.Dispatcher/0.3.12": {
|
||||
"Wabbajack.Downloaders.Dispatcher/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"Wabbajack.Downloaders.Bethesda": "0.3.12",
|
||||
"Wabbajack.Downloaders.GameFile": "0.3.12",
|
||||
"Wabbajack.Downloaders.GoogleDrive": "0.3.12",
|
||||
"Wabbajack.Downloaders.Http": "0.3.12",
|
||||
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Downloaders.Manual": "0.3.12",
|
||||
"Wabbajack.Downloaders.MediaFire": "0.3.12",
|
||||
"Wabbajack.Downloaders.Mega": "0.3.12",
|
||||
"Wabbajack.Downloaders.ModDB": "0.3.12",
|
||||
"Wabbajack.Downloaders.Nexus": "0.3.12",
|
||||
"Wabbajack.Downloaders.VerificationCache": "0.3.12",
|
||||
"Wabbajack.Downloaders.WabbajackCDN": "0.3.12",
|
||||
"Wabbajack.Networking.WabbajackClientApi": "0.3.12"
|
||||
"Wabbajack.Downloaders.Bethesda": "0.3.17",
|
||||
"Wabbajack.Downloaders.GameFile": "0.3.17",
|
||||
"Wabbajack.Downloaders.GoogleDrive": "0.3.17",
|
||||
"Wabbajack.Downloaders.Http": "0.3.17",
|
||||
"Wabbajack.Downloaders.IPS4OAuth2Downloader": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Downloaders.Manual": "0.3.17",
|
||||
"Wabbajack.Downloaders.MediaFire": "0.3.17",
|
||||
"Wabbajack.Downloaders.Mega": "0.3.17",
|
||||
"Wabbajack.Downloaders.ModDB": "0.3.17",
|
||||
"Wabbajack.Downloaders.Nexus": "0.3.17",
|
||||
"Wabbajack.Downloaders.VerificationCache": "0.3.17",
|
||||
"Wabbajack.Downloaders.WabbajackCDN": "0.3.17",
|
||||
"Wabbajack.Networking.WabbajackClientApi": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.Dispatcher.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.GameFile/0.3.12": {
|
||||
"Wabbajack.Downloaders.GameFile/0.3.17": {
|
||||
"dependencies": {
|
||||
"GameFinder.StoreHandlers.EADesktop": "4.5.0",
|
||||
"GameFinder.StoreHandlers.EGS": "4.5.0",
|
||||
@@ -1903,360 +1903,360 @@
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.VFS": "0.3.12"
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.VFS": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.GameFile.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.GoogleDrive/0.3.12": {
|
||||
"Wabbajack.Downloaders.GoogleDrive/0.3.17": {
|
||||
"dependencies": {
|
||||
"HtmlAgilityPack": "1.11.72",
|
||||
"Microsoft.AspNetCore.Http.Extensions": "2.3.0",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.Http": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.Http": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.GoogleDrive.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.Http/0.3.12": {
|
||||
"Wabbajack.Downloaders.Http/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.BethesdaNet": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.BethesdaNet": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.Http.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.Interfaces/0.3.12": {
|
||||
"Wabbajack.Downloaders.Interfaces/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Wabbajack.Compression.Zip": "0.3.12",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12"
|
||||
"Wabbajack.Compression.Zip": "0.3.17",
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.Interfaces.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.12": {
|
||||
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.17": {
|
||||
"dependencies": {
|
||||
"F23.StringSimilarity": "6.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.Http": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.Http": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.IPS4OAuth2Downloader.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.Manual/0.3.12": {
|
||||
"Wabbajack.Downloaders.Manual/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.Manual.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.MediaFire/0.3.12": {
|
||||
"Wabbajack.Downloaders.MediaFire/0.3.17": {
|
||||
"dependencies": {
|
||||
"HtmlAgilityPack": "1.11.72",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.MediaFire.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.Mega/0.3.12": {
|
||||
"Wabbajack.Downloaders.Mega/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.Mega.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.ModDB/0.3.12": {
|
||||
"Wabbajack.Downloaders.ModDB/0.3.17": {
|
||||
"dependencies": {
|
||||
"HtmlAgilityPack": "1.11.72",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.Http": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.Http": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.ModDB.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.Nexus/0.3.12": {
|
||||
"Wabbajack.Downloaders.Nexus/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.12",
|
||||
"Wabbajack.Networking.Http": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.NexusApi": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.12"
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.17",
|
||||
"Wabbajack.Networking.Http": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.NexusApi": "0.3.17",
|
||||
"Wabbajack.Paths": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.Nexus.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.VerificationCache/0.3.12": {
|
||||
"Wabbajack.Downloaders.VerificationCache/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Stub.System.Data.SQLite.Core.NetStandard": "1.0.119",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12"
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.VerificationCache.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Downloaders.WabbajackCDN/0.3.12": {
|
||||
"Wabbajack.Downloaders.WabbajackCDN/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Microsoft.Toolkit.HighPerformance": "7.1.2",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.Http": "0.3.12",
|
||||
"Wabbajack.RateLimiter": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.Http": "0.3.17",
|
||||
"Wabbajack.RateLimiter": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Downloaders.WabbajackCDN.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.DTOs/0.3.12": {
|
||||
"Wabbajack.DTOs/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.12"
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.17",
|
||||
"Wabbajack.Paths": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.DTOs.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.FileExtractor/0.3.12": {
|
||||
"Wabbajack.FileExtractor/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"OMODFramework": "3.0.1",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Compression.BSA": "0.3.12",
|
||||
"Wabbajack.Hashing.PHash": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Compression.BSA": "0.3.17",
|
||||
"Wabbajack.Hashing.PHash": "0.3.17",
|
||||
"Wabbajack.Paths": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.FileExtractor.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Hashing.PHash/0.3.12": {
|
||||
"Wabbajack.Hashing.PHash/0.3.17": {
|
||||
"dependencies": {
|
||||
"BCnEncoder.Net.ImageSharp": "1.1.1",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Shipwreck.Phash": "0.5.0",
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Paths": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Hashing.PHash.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Hashing.xxHash64/0.3.12": {
|
||||
"Wabbajack.Hashing.xxHash64/0.3.17": {
|
||||
"dependencies": {
|
||||
"Wabbajack.Paths": "0.3.12",
|
||||
"Wabbajack.RateLimiter": "0.3.12"
|
||||
"Wabbajack.Paths": "0.3.17",
|
||||
"Wabbajack.RateLimiter": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Hashing.xxHash64.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Installer/0.3.12": {
|
||||
"Wabbajack.Installer/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"Octopus.Octodiff": "2.0.548",
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Downloaders.Dispatcher": "0.3.12",
|
||||
"Wabbajack.Downloaders.GameFile": "0.3.12",
|
||||
"Wabbajack.FileExtractor": "0.3.12",
|
||||
"Wabbajack.Networking.WabbajackClientApi": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12",
|
||||
"Wabbajack.VFS": "0.3.12",
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Downloaders.Dispatcher": "0.3.17",
|
||||
"Wabbajack.Downloaders.GameFile": "0.3.17",
|
||||
"Wabbajack.FileExtractor": "0.3.17",
|
||||
"Wabbajack.Networking.WabbajackClientApi": "0.3.17",
|
||||
"Wabbajack.Paths": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17",
|
||||
"Wabbajack.VFS": "0.3.17",
|
||||
"ini-parser-netstandard": "2.5.2"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Installer.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.IO.Async/0.3.12": {
|
||||
"Wabbajack.IO.Async/0.3.17": {
|
||||
"runtime": {
|
||||
"Wabbajack.IO.Async.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Networking.BethesdaNet/0.3.12": {
|
||||
"Wabbajack.Networking.BethesdaNet/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Networking.Http": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12"
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Networking.Http": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Networking.BethesdaNet.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Networking.Discord/0.3.12": {
|
||||
"Wabbajack.Networking.Discord/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12"
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Networking.Discord.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Networking.GitHub/0.3.12": {
|
||||
"Wabbajack.Networking.GitHub/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Octokit": "14.0.0",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12"
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Networking.GitHub.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Networking.Http/0.3.12": {
|
||||
"Wabbajack.Networking.Http/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Http": "9.0.1",
|
||||
"Microsoft.Extensions.Logging": "9.0.1",
|
||||
"Wabbajack.Configuration": "0.3.12",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.12",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12"
|
||||
"Wabbajack.Configuration": "0.3.17",
|
||||
"Wabbajack.Downloaders.Interfaces": "0.3.17",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
|
||||
"Wabbajack.Paths": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Networking.Http.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Networking.Http.Interfaces/0.3.12": {
|
||||
"Wabbajack.Networking.Http.Interfaces/0.3.17": {
|
||||
"dependencies": {
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.12"
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Networking.Http.Interfaces.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Networking.NexusApi/0.3.12": {
|
||||
"Wabbajack.Networking.NexusApi/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Networking.Http": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12",
|
||||
"Wabbajack.Networking.WabbajackClientApi": "0.3.12"
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Networking.Http": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
|
||||
"Wabbajack.Networking.WabbajackClientApi": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Networking.NexusApi.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Networking.WabbajackClientApi/0.3.12": {
|
||||
"Wabbajack.Networking.WabbajackClientApi/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"Octokit": "14.0.0",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12",
|
||||
"Wabbajack.VFS.Interfaces": "0.3.12",
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17",
|
||||
"Wabbajack.VFS.Interfaces": "0.3.17",
|
||||
"YamlDotNet": "16.3.0"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Networking.WabbajackClientApi.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Paths/0.3.12": {
|
||||
"Wabbajack.Paths/0.3.17": {
|
||||
"runtime": {
|
||||
"Wabbajack.Paths.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Paths.IO/0.3.12": {
|
||||
"Wabbajack.Paths.IO/0.3.17": {
|
||||
"dependencies": {
|
||||
"Wabbajack.Paths": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.17",
|
||||
"shortid": "4.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Paths.IO.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.RateLimiter/0.3.12": {
|
||||
"Wabbajack.RateLimiter/0.3.17": {
|
||||
"runtime": {
|
||||
"Wabbajack.RateLimiter.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Server.Lib/0.3.12": {
|
||||
"Wabbajack.Server.Lib/0.3.17": {
|
||||
"dependencies": {
|
||||
"FluentFTP": "52.0.0",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
@@ -2264,58 +2264,58 @@
|
||||
"Nettle": "3.0.0",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.12",
|
||||
"Wabbajack.Services.OSIntegrated": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.Networking.Http.Interfaces": "0.3.17",
|
||||
"Wabbajack.Services.OSIntegrated": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Server.Lib.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.Services.OSIntegrated/0.3.12": {
|
||||
"Wabbajack.Services.OSIntegrated/0.3.17": {
|
||||
"dependencies": {
|
||||
"DeviceId": "6.8.0",
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Newtonsoft.Json": "13.0.3",
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"Wabbajack.Compiler": "0.3.12",
|
||||
"Wabbajack.Downloaders.Dispatcher": "0.3.12",
|
||||
"Wabbajack.Installer": "0.3.12",
|
||||
"Wabbajack.Networking.BethesdaNet": "0.3.12",
|
||||
"Wabbajack.Networking.Discord": "0.3.12",
|
||||
"Wabbajack.VFS": "0.3.12"
|
||||
"Wabbajack.Compiler": "0.3.17",
|
||||
"Wabbajack.Downloaders.Dispatcher": "0.3.17",
|
||||
"Wabbajack.Installer": "0.3.17",
|
||||
"Wabbajack.Networking.BethesdaNet": "0.3.17",
|
||||
"Wabbajack.Networking.Discord": "0.3.17",
|
||||
"Wabbajack.VFS": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.Services.OSIntegrated.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.VFS/0.3.12": {
|
||||
"Wabbajack.VFS/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "9.0.1",
|
||||
"SixLabors.ImageSharp": "3.1.6",
|
||||
"System.Data.SQLite.Core": "1.0.119",
|
||||
"Wabbajack.Common": "0.3.12",
|
||||
"Wabbajack.FileExtractor": "0.3.12",
|
||||
"Wabbajack.Hashing.PHash": "0.3.12",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.12",
|
||||
"Wabbajack.Paths.IO": "0.3.12",
|
||||
"Wabbajack.VFS.Interfaces": "0.3.12"
|
||||
"Wabbajack.Common": "0.3.17",
|
||||
"Wabbajack.FileExtractor": "0.3.17",
|
||||
"Wabbajack.Hashing.PHash": "0.3.17",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.17",
|
||||
"Wabbajack.Paths": "0.3.17",
|
||||
"Wabbajack.Paths.IO": "0.3.17",
|
||||
"Wabbajack.VFS.Interfaces": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.VFS.dll": {}
|
||||
}
|
||||
},
|
||||
"Wabbajack.VFS.Interfaces/0.3.12": {
|
||||
"Wabbajack.VFS.Interfaces/0.3.17": {
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection": "9.0.1",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.1",
|
||||
"Wabbajack.DTOs": "0.3.12",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.12",
|
||||
"Wabbajack.Paths": "0.3.12"
|
||||
"Wabbajack.DTOs": "0.3.17",
|
||||
"Wabbajack.Hashing.xxHash64": "0.3.17",
|
||||
"Wabbajack.Paths": "0.3.17"
|
||||
},
|
||||
"runtime": {
|
||||
"Wabbajack.VFS.Interfaces.dll": {}
|
||||
@@ -2332,7 +2332,7 @@
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"jackify-engine/0.3.12": {
|
||||
"jackify-engine/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
@@ -3021,202 +3021,202 @@
|
||||
"path": "yamldotnet/16.3.0",
|
||||
"hashPath": "yamldotnet.16.3.0.nupkg.sha512"
|
||||
},
|
||||
"Wabbajack.CLI.Builder/0.3.12": {
|
||||
"Wabbajack.CLI.Builder/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Common/0.3.12": {
|
||||
"Wabbajack.Common/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Compiler/0.3.12": {
|
||||
"Wabbajack.Compiler/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Compression.BSA/0.3.12": {
|
||||
"Wabbajack.Compression.BSA/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Compression.Zip/0.3.12": {
|
||||
"Wabbajack.Compression.Zip/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Configuration/0.3.12": {
|
||||
"Wabbajack.Configuration/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.Bethesda/0.3.12": {
|
||||
"Wabbajack.Downloaders.Bethesda/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.Dispatcher/0.3.12": {
|
||||
"Wabbajack.Downloaders.Dispatcher/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.GameFile/0.3.12": {
|
||||
"Wabbajack.Downloaders.GameFile/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.GoogleDrive/0.3.12": {
|
||||
"Wabbajack.Downloaders.GoogleDrive/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.Http/0.3.12": {
|
||||
"Wabbajack.Downloaders.Http/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.Interfaces/0.3.12": {
|
||||
"Wabbajack.Downloaders.Interfaces/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.12": {
|
||||
"Wabbajack.Downloaders.IPS4OAuth2Downloader/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.Manual/0.3.12": {
|
||||
"Wabbajack.Downloaders.Manual/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.MediaFire/0.3.12": {
|
||||
"Wabbajack.Downloaders.MediaFire/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.Mega/0.3.12": {
|
||||
"Wabbajack.Downloaders.Mega/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.ModDB/0.3.12": {
|
||||
"Wabbajack.Downloaders.ModDB/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.Nexus/0.3.12": {
|
||||
"Wabbajack.Downloaders.Nexus/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.VerificationCache/0.3.12": {
|
||||
"Wabbajack.Downloaders.VerificationCache/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Downloaders.WabbajackCDN/0.3.12": {
|
||||
"Wabbajack.Downloaders.WabbajackCDN/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.DTOs/0.3.12": {
|
||||
"Wabbajack.DTOs/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.FileExtractor/0.3.12": {
|
||||
"Wabbajack.FileExtractor/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Hashing.PHash/0.3.12": {
|
||||
"Wabbajack.Hashing.PHash/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Hashing.xxHash64/0.3.12": {
|
||||
"Wabbajack.Hashing.xxHash64/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Installer/0.3.12": {
|
||||
"Wabbajack.Installer/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.IO.Async/0.3.12": {
|
||||
"Wabbajack.IO.Async/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Networking.BethesdaNet/0.3.12": {
|
||||
"Wabbajack.Networking.BethesdaNet/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Networking.Discord/0.3.12": {
|
||||
"Wabbajack.Networking.Discord/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Networking.GitHub/0.3.12": {
|
||||
"Wabbajack.Networking.GitHub/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Networking.Http/0.3.12": {
|
||||
"Wabbajack.Networking.Http/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Networking.Http.Interfaces/0.3.12": {
|
||||
"Wabbajack.Networking.Http.Interfaces/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Networking.NexusApi/0.3.12": {
|
||||
"Wabbajack.Networking.NexusApi/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Networking.WabbajackClientApi/0.3.12": {
|
||||
"Wabbajack.Networking.WabbajackClientApi/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Paths/0.3.12": {
|
||||
"Wabbajack.Paths/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Paths.IO/0.3.12": {
|
||||
"Wabbajack.Paths.IO/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.RateLimiter/0.3.12": {
|
||||
"Wabbajack.RateLimiter/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Server.Lib/0.3.12": {
|
||||
"Wabbajack.Server.Lib/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.Services.OSIntegrated/0.3.12": {
|
||||
"Wabbajack.Services.OSIntegrated/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.VFS/0.3.12": {
|
||||
"Wabbajack.VFS/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Wabbajack.VFS.Interfaces/0.3.12": {
|
||||
"Wabbajack.VFS.Interfaces/0.3.17": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
|
||||
Binary file not shown.
@@ -1,247 +0,0 @@
|
||||
"""
|
||||
Tuxborn Command
|
||||
|
||||
CLI command for the Tuxborn Automatic Installer.
|
||||
Extracted from the original jackify-cli.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Import the backend services we'll need
|
||||
from jackify.backend.models.modlist import ModlistContext
|
||||
from jackify.shared.colors import COLOR_INFO, COLOR_ERROR, COLOR_RESET
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TuxbornCommand:
|
||||
"""Handler for the tuxborn-auto CLI command."""
|
||||
|
||||
def __init__(self, backend_services, system_info):
|
||||
"""Initialize with backend services.
|
||||
|
||||
Args:
|
||||
backend_services: Dictionary of backend service instances
|
||||
system_info: System information (steamdeck flag, etc.)
|
||||
"""
|
||||
self.backend_services = backend_services
|
||||
self.system_info = system_info
|
||||
|
||||
def add_args(self, parser):
|
||||
"""Add tuxborn-auto arguments to the main parser.
|
||||
|
||||
Args:
|
||||
parser: The main ArgumentParser
|
||||
"""
|
||||
parser.add_argument(
|
||||
"--tuxborn-auto",
|
||||
action="store_true",
|
||||
help="Run the Tuxborn Automatic Installer non-interactively (for GUI integration)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--install-dir",
|
||||
type=str,
|
||||
help="Install directory for Tuxborn (required with --tuxborn-auto)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--download-dir",
|
||||
type=str,
|
||||
help="Downloads directory for Tuxborn (required with --tuxborn-auto)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--modlist-name",
|
||||
type=str,
|
||||
default="Tuxborn",
|
||||
help="Modlist name (optional, defaults to 'Tuxborn')"
|
||||
)
|
||||
|
||||
def execute(self, args) -> int:
|
||||
"""Execute the tuxborn-auto command.
|
||||
|
||||
Args:
|
||||
args: Parsed command-line arguments
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success, 1 for failure)
|
||||
"""
|
||||
logger.info("Starting Tuxborn Automatic Installer (GUI integration mode)")
|
||||
|
||||
try:
|
||||
# Set up logging redirection (copied from original)
|
||||
self._setup_tee_logging()
|
||||
|
||||
# Build context from args
|
||||
context = self._build_context_from_args(args)
|
||||
|
||||
# Validate required fields
|
||||
if not self._validate_context(context):
|
||||
return 1
|
||||
|
||||
# Use legacy implementation for now - will migrate to backend services later
|
||||
result = self._execute_legacy_tuxborn(context)
|
||||
|
||||
logger.info("Finished Tuxborn Automatic Installer")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run Tuxborn installer: {e}")
|
||||
print(f"{COLOR_ERROR}Tuxborn installation failed: {e}{COLOR_RESET}")
|
||||
return 1
|
||||
finally:
|
||||
# Restore stdout/stderr
|
||||
self._restore_stdout_stderr()
|
||||
|
||||
def _build_context_from_args(self, args) -> dict:
|
||||
"""Build context dictionary from command arguments.
|
||||
|
||||
Args:
|
||||
args: Parsed command-line arguments
|
||||
|
||||
Returns:
|
||||
Context dictionary
|
||||
"""
|
||||
install_dir = getattr(args, 'install_dir', None)
|
||||
download_dir = getattr(args, 'download_dir', None)
|
||||
modlist_name = getattr(args, 'modlist_name', 'Tuxborn')
|
||||
machineid = 'Tuxborn/Tuxborn'
|
||||
|
||||
# Try to get API key from saved config first, then environment variable
|
||||
from jackify.backend.services.api_key_service import APIKeyService
|
||||
api_key_service = APIKeyService()
|
||||
api_key = api_key_service.get_saved_api_key()
|
||||
if not api_key:
|
||||
api_key = os.environ.get('NEXUS_API_KEY')
|
||||
|
||||
resolution = getattr(args, 'resolution', None)
|
||||
mo2_exe_path = getattr(args, 'mo2_exe_path', None)
|
||||
skip_confirmation = True # Always true in GUI mode
|
||||
|
||||
context = {
|
||||
'machineid': machineid,
|
||||
'modlist_name': modlist_name,
|
||||
'install_dir': install_dir,
|
||||
'download_dir': download_dir,
|
||||
'nexus_api_key': api_key,
|
||||
'skip_confirmation': skip_confirmation,
|
||||
'resolution': resolution,
|
||||
'mo2_exe_path': mo2_exe_path,
|
||||
}
|
||||
|
||||
# PATCH: Always set modlist_value and modlist_source for Tuxborn workflow
|
||||
context['modlist_value'] = 'Tuxborn/Tuxborn'
|
||||
context['modlist_source'] = 'identifier'
|
||||
|
||||
return context
|
||||
|
||||
def _validate_context(self, context: dict) -> bool:
|
||||
"""Validate Tuxborn context.
|
||||
|
||||
Args:
|
||||
context: Tuxborn context dictionary
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key']
|
||||
missing = [k for k in required_keys if not context.get(k)]
|
||||
|
||||
if missing:
|
||||
print(f"{COLOR_ERROR}Missing required arguments for --tuxborn-auto.\\n"
|
||||
f"--install-dir, --download-dir, and NEXUS_API_KEY (env, 32+ chars) are required.{COLOR_RESET}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _setup_tee_logging(self):
|
||||
"""Set up TEE logging (copied from original implementation)."""
|
||||
import shutil
|
||||
|
||||
# TEE logging setup & log rotation (copied from original)
|
||||
class TeeStdout:
|
||||
def __init__(self, *files):
|
||||
self.files = files
|
||||
def write(self, data):
|
||||
for f in self.files:
|
||||
f.write(data)
|
||||
f.flush()
|
||||
def flush(self):
|
||||
for f in self.files:
|
||||
f.flush()
|
||||
|
||||
log_dir = Path.home() / "Jackify" / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
workflow_log_path = log_dir / "tuxborn_workflow.log"
|
||||
|
||||
# Log rotation: keep last 3 logs, 1KB each (for testing)
|
||||
max_logs = 3
|
||||
max_size = 1024 # 1KB for testing
|
||||
if workflow_log_path.exists() and workflow_log_path.stat().st_size > max_size:
|
||||
for i in range(max_logs, 0, -1):
|
||||
prev = log_dir / f"tuxborn_workflow.log.{i-1}" if i > 1 else workflow_log_path
|
||||
dest = log_dir / f"tuxborn_workflow.log.{i}"
|
||||
if prev.exists():
|
||||
if dest.exists():
|
||||
dest.unlink()
|
||||
prev.rename(dest)
|
||||
|
||||
self.workflow_log = open(workflow_log_path, 'a')
|
||||
self.orig_stdout, self.orig_stderr = sys.stdout, sys.stderr
|
||||
sys.stdout = TeeStdout(sys.stdout, self.workflow_log)
|
||||
sys.stderr = TeeStdout(sys.stderr, self.workflow_log)
|
||||
|
||||
def _restore_stdout_stderr(self):
|
||||
"""Restore original stdout/stderr."""
|
||||
if hasattr(self, 'orig_stdout'):
|
||||
sys.stdout = self.orig_stdout
|
||||
sys.stderr = self.orig_stderr
|
||||
if hasattr(self, 'workflow_log'):
|
||||
self.workflow_log.close()
|
||||
|
||||
def _execute_legacy_tuxborn(self, context: dict) -> int:
|
||||
"""Execute Tuxborn using legacy implementation.
|
||||
|
||||
Args:
|
||||
context: Tuxborn context dictionary
|
||||
|
||||
Returns:
|
||||
Exit code
|
||||
"""
|
||||
# Import backend services
|
||||
from jackify.backend.core.modlist_operations import ModlistInstallCLI
|
||||
from jackify.backend.handlers.menu_handler import MenuHandler
|
||||
|
||||
# Create legacy handler instances
|
||||
menu_handler = MenuHandler()
|
||||
modlist_cli = ModlistInstallCLI(
|
||||
menu_handler=menu_handler,
|
||||
steamdeck=self.system_info.get('is_steamdeck', False)
|
||||
)
|
||||
|
||||
confirmed_context = modlist_cli.run_discovery_phase(context_override=context)
|
||||
if confirmed_context:
|
||||
menu_handler.logger.info("Tuxborn discovery confirmed by GUI. Proceeding to configuration/installation.")
|
||||
modlist_cli.configuration_phase()
|
||||
|
||||
# Handle GUI integration prompts (copied from original)
|
||||
print('[PROMPT:RESTART_STEAM]')
|
||||
if os.environ.get('JACKIFY_GUI_MODE'):
|
||||
input() # Wait for GUI to send confirmation, no CLI prompt
|
||||
else:
|
||||
answer = input('Restart Steam automatically now? (Y/n): ')
|
||||
# ... handle answer as before ...
|
||||
|
||||
print('[PROMPT:MANUAL_STEPS]')
|
||||
if os.environ.get('JACKIFY_GUI_MODE'):
|
||||
input() # Wait for GUI to send confirmation, no CLI prompt
|
||||
else:
|
||||
input('Once you have completed ALL the steps above, press Enter to continue...')
|
||||
|
||||
return 0
|
||||
else:
|
||||
menu_handler.logger.info("Tuxborn discovery/confirmation cancelled or failed (GUI mode).")
|
||||
print(f"{COLOR_INFO}Tuxborn installation cancelled or not confirmed.{COLOR_RESET}")
|
||||
return 1
|
||||
@@ -21,11 +21,9 @@ from jackify import __version__ as jackify_version
|
||||
# Import our command handlers
|
||||
from .commands.configure_modlist import ConfigureModlistCommand
|
||||
from .commands.install_modlist import InstallModlistCommand
|
||||
from .commands.tuxborn import TuxbornCommand
|
||||
|
||||
# Import our menu handlers
|
||||
from .menus.main_menu import MainMenuHandler
|
||||
from .menus.tuxborn_menu import TuxbornMenuHandler
|
||||
from .menus.wabbajack_menu import WabbajackMenuHandler
|
||||
from .menus.hoolamike_menu import HoolamikeMenuHandler
|
||||
from .menus.additional_menu import AdditionalMenuHandler
|
||||
@@ -174,13 +172,103 @@ class JackifyCLI:
|
||||
Returns:
|
||||
Dictionary of backend service instances
|
||||
"""
|
||||
# For now, create a basic modlist service
|
||||
# TODO: Add other services as needed
|
||||
# Initialize update service
|
||||
from jackify.backend.services.update_service import UpdateService
|
||||
update_service = UpdateService(jackify_version)
|
||||
|
||||
services = {
|
||||
'modlist_service': ModlistService(self.system_info)
|
||||
'modlist_service': ModlistService(self.system_info),
|
||||
'update_service': update_service
|
||||
}
|
||||
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):
|
||||
"""Initialize command handler instances.
|
||||
|
||||
@@ -190,7 +278,6 @@ class JackifyCLI:
|
||||
commands = {
|
||||
'configure_modlist': ConfigureModlistCommand(self.backend_services),
|
||||
'install_modlist': InstallModlistCommand(self.backend_services, self.system_info),
|
||||
'tuxborn': TuxbornCommand(self.backend_services, self.system_info)
|
||||
}
|
||||
return commands
|
||||
|
||||
@@ -202,7 +289,6 @@ class JackifyCLI:
|
||||
"""
|
||||
menus = {
|
||||
'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)),
|
||||
'tuxborn': TuxbornMenuHandler(),
|
||||
'wabbajack': WabbajackMenuHandler(),
|
||||
'hoolamike': HoolamikeMenuHandler(),
|
||||
'additional': AdditionalMenuHandler()
|
||||
@@ -271,15 +357,16 @@ class JackifyCLI:
|
||||
self._debug_print('JackifyCLI.run() called')
|
||||
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)
|
||||
if getattr(self.args, 'restart_steam', False):
|
||||
self._debug_print('Entering restart_steam workflow')
|
||||
return self._handle_restart_steam()
|
||||
|
||||
# Handle Tuxborn auto mode
|
||||
if getattr(self.args, 'tuxborn_auto', False):
|
||||
self._debug_print('Entering Tuxborn workflow')
|
||||
return self.commands['tuxborn'].execute(self.args)
|
||||
|
||||
# Handle install-modlist top-level functionality
|
||||
if getattr(self.args, 'install_modlist', False):
|
||||
@@ -290,6 +377,9 @@ class JackifyCLI:
|
||||
if getattr(self.args, 'command', None):
|
||||
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)
|
||||
self._run_interactive()
|
||||
|
||||
@@ -303,9 +393,9 @@ class JackifyCLI:
|
||||
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('--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
|
||||
self.commands['tuxborn'].add_args(parser)
|
||||
self.commands['install_modlist'].add_top_level_args(parser)
|
||||
|
||||
# Add subcommands
|
||||
@@ -360,8 +450,6 @@ class JackifyCLI:
|
||||
return 0
|
||||
elif choice == "wabbajack":
|
||||
self.menus['wabbajack'].show_wabbajack_tasks_menu(self)
|
||||
elif choice == "tuxborn":
|
||||
self.menus['tuxborn'].show_tuxborn_installer_menu(self)
|
||||
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
|
||||
# elif choice == "hoolamike":
|
||||
# self.menus['hoolamike'].show_hoolamike_menu(self)
|
||||
|
||||
@@ -4,7 +4,6 @@ Extracted from the legacy monolithic CLI system
|
||||
"""
|
||||
|
||||
from .main_menu import MainMenuHandler
|
||||
from .tuxborn_menu import TuxbornMenuHandler
|
||||
from .wabbajack_menu import WabbajackMenuHandler
|
||||
from .hoolamike_menu import HoolamikeMenuHandler
|
||||
from .additional_menu import AdditionalMenuHandler
|
||||
@@ -12,7 +11,6 @@ from .recovery_menu import RecoveryMenuHandler
|
||||
|
||||
__all__ = [
|
||||
'MainMenuHandler',
|
||||
'TuxbornMenuHandler',
|
||||
'WabbajackMenuHandler',
|
||||
'HoolamikeMenuHandler',
|
||||
'AdditionalMenuHandler',
|
||||
|
||||
@@ -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)
|
||||
@@ -32,9 +32,9 @@ class WabbajackMenuHandler:
|
||||
print_section_header("Modlist and Wabbajack Tasks")
|
||||
|
||||
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_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_ACTION}→ Modlist already in Steam? Re-configure it here{COLOR_RESET}")
|
||||
# HIDDEN FOR FIRST RELEASE - UNCOMMENT WHEN READY
|
||||
|
||||
400
jackify/frontends/gui/dialogs/about_dialog.py
Normal file
400
jackify/frontends/gui/dialogs/about_dialog.py
Normal 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()
|
||||
@@ -100,7 +100,7 @@ class UlimitGuidanceDialog(QDialog):
|
||||
status_text = "✓ Optimal"
|
||||
status_color = "#4caf50" # Green
|
||||
elif self.status['can_increase']:
|
||||
status_text = "⚠ Can Improve"
|
||||
status_text = "Can Improve"
|
||||
status_color = "#ff9800" # Orange
|
||||
else:
|
||||
status_text = "✗ Needs Manual Fix"
|
||||
@@ -222,7 +222,7 @@ class UlimitGuidanceDialog(QDialog):
|
||||
|
||||
# Warning
|
||||
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."
|
||||
)
|
||||
warning_label.setWordWrap(True)
|
||||
@@ -478,7 +478,7 @@ class UlimitGuidanceDialog(QDialog):
|
||||
status_text = "✓ Optimal"
|
||||
status_color = "#4caf50" # Green
|
||||
elif self.status['can_increase']:
|
||||
status_text = "⚠ Can Improve"
|
||||
status_text = "Can Improve"
|
||||
status_color = "#ff9800" # Orange
|
||||
else:
|
||||
status_text = "✗ Needs Manual Fix"
|
||||
|
||||
343
jackify/frontends/gui/dialogs/update_dialog.py
Normal file
343
jackify/frontends/gui/dialogs/update_dialog.py
Normal 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
10
jackify/frontends/gui/mixins/__init__.py
Normal file
10
jackify/frontends/gui/mixins/__init__.py
Normal 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']
|
||||
66
jackify/frontends/gui/mixins/operation_lock_mixin.py
Normal file
66
jackify/frontends/gui/mixins/operation_lock_mixin.py
Normal 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
|
||||
@@ -5,7 +5,6 @@ Contains all the GUI screen components for Jackify.
|
||||
"""
|
||||
|
||||
from .main_menu import MainMenu
|
||||
from .tuxborn_installer import TuxbornInstallerScreen
|
||||
from .modlist_tasks import ModlistTasksScreen
|
||||
from .install_modlist import InstallModlistScreen
|
||||
from .configure_new_modlist import ConfigureNewModlistScreen
|
||||
@@ -13,7 +12,6 @@ from .configure_existing_modlist import ConfigureExistingModlistScreen
|
||||
|
||||
__all__ = [
|
||||
'MainMenu',
|
||||
'TuxbornInstallerScreen',
|
||||
'ModlistTasksScreen',
|
||||
'InstallModlistScreen',
|
||||
'ConfigureNewModlistScreen',
|
||||
|
||||
@@ -34,11 +34,12 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
self.stacked_widget = stacked_widget
|
||||
self.main_menu_index = main_menu_index
|
||||
self.debug = DEBUG_BORDERS
|
||||
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Configure_Existing_Modlist_workflow.log')
|
||||
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
|
||||
self.refresh_paths()
|
||||
|
||||
# --- Detect Steam Deck ---
|
||||
steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower()
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
steamdeck = platform_service.is_steamdeck
|
||||
self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
|
||||
|
||||
# Initialize services early
|
||||
@@ -120,7 +121,7 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
self.shortcut_combo.addItem("Please Select...")
|
||||
self.shortcut_map = []
|
||||
for shortcut in self.mo2_shortcuts:
|
||||
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})"
|
||||
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
|
||||
self.shortcut_combo.addItem(display)
|
||||
self.shortcut_map.append(shortcut)
|
||||
|
||||
@@ -297,6 +298,41 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
|
||||
# Time tracking for workflow completion
|
||||
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):
|
||||
"""Handle window resize to prioritize form over console"""
|
||||
@@ -382,17 +418,22 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
log_handler = LoggingHandler()
|
||||
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
|
||||
idx = self.shortcut_combo.currentIndex() - 1 # Account for 'Please Select...'
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
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")
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
shortcut = self.shortcut_map[idx]
|
||||
modlist_name = shortcut.get('AppName', '')
|
||||
install_dir = shortcut.get('StartDir', '')
|
||||
modlist_name = shortcut.get('AppName', shortcut.get('appname', ''))
|
||||
install_dir = shortcut.get('StartDir', shortcut.get('startdir', ''))
|
||||
if not modlist_name or not install_dir:
|
||||
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
resolution = self.resolution_combo.currentText()
|
||||
# Handle resolution saving
|
||||
@@ -460,6 +501,7 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
# For existing modlists, add resolution if specified
|
||||
if self.resolution != "Leave unchanged":
|
||||
modlist_context.resolution = self.resolution.split()[0]
|
||||
# Note: If "Leave unchanged" is selected, resolution stays None (no fallback needed)
|
||||
|
||||
# Define callbacks
|
||||
def progress_callback(message):
|
||||
@@ -505,6 +547,9 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
|
||||
def on_configuration_complete(self, success, message, modlist_name):
|
||||
"""Handle configuration completion"""
|
||||
# Re-enable all controls when workflow completes
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
# Calculate time taken
|
||||
time_taken = self._calculate_time_taken()
|
||||
@@ -525,6 +570,9 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
|
||||
def on_configuration_error(self, error_message):
|
||||
"""Handle configuration error"""
|
||||
# Re-enable all controls on error
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
self._safe_append_text(f"Configuration error: {error_message}")
|
||||
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:
|
||||
self.config_process.terminate()
|
||||
self.config_process.waitForFinished(2000)
|
||||
# Reset button states
|
||||
self.start_btn.setEnabled(True)
|
||||
# Re-enable all controls
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
|
||||
def show_next_steps_dialog(self, message):
|
||||
@@ -665,7 +713,7 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
self.shortcut_map.clear()
|
||||
|
||||
for shortcut in self.mo2_shortcuts:
|
||||
display = f"{shortcut.get('AppName', 'Unknown')} ({shortcut.get('StartDir', '')})"
|
||||
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
|
||||
self.shortcut_combo.addItem(display)
|
||||
self.shortcut_map.append(shortcut)
|
||||
|
||||
@@ -693,6 +741,30 @@ class ConfigureExistingModlistScreen(QWidget):
|
||||
else:
|
||||
return f"{elapsed_seconds_remainder} seconds"
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
"""Reset the screen to default state when navigating back from main menu"""
|
||||
# Clear the shortcut selection
|
||||
self.shortcut_combo.clear()
|
||||
self.shortcut_map.clear()
|
||||
# Auto-refresh modlist list when screen is entered
|
||||
self.refresh_modlist_list()
|
||||
|
||||
# Clear console and process monitor
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
|
||||
# Reset resolution combo to saved config preference
|
||||
saved_resolution = self.resolution_service.get_saved_resolution()
|
||||
if saved_resolution:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
elif self.resolution_combo.count() > 0:
|
||||
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
|
||||
|
||||
# Re-enable controls (in case they were disabled from previous errors)
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running threads when the screen is closed"""
|
||||
debug_print("DEBUG: cleanup called - cleaning up ConfigurationThread")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
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.QtGui import QPixmap, QTextCursor
|
||||
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 PySide6.QtWidgets import QApplication
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
from jackify.shared.resolution_utils import get_resolution_fallback
|
||||
|
||||
def debug_print(message):
|
||||
"""Print debug message only if debug mode is enabled"""
|
||||
@@ -106,8 +107,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
self.protontricks_service = ProtontricksDetectionService()
|
||||
|
||||
# Path for workflow log
|
||||
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Configure_New_Modlist_workflow.log')
|
||||
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
|
||||
self.refresh_paths()
|
||||
|
||||
# Scroll tracking for professional auto-scroll behavior
|
||||
self._user_manually_scrolled = False
|
||||
@@ -211,7 +211,6 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
"7680x4320"
|
||||
])
|
||||
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
|
||||
saved_resolution = self.resolution_service.get_saved_resolution()
|
||||
@@ -236,6 +235,27 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
else:
|
||||
self.resolution_combo.setCurrentIndex(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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
form_section_widget.setLayout(form_grid)
|
||||
@@ -338,6 +358,44 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
self.start_btn.clicked.connect(self.validate_and_start_configure)
|
||||
# --- Connect steam_restart_finished signal ---
|
||||
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):
|
||||
"""Handle window resize to prioritize form over console"""
|
||||
@@ -423,7 +481,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
|
||||
def go_back(self):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(3) # Return to Modlist Tasks menu
|
||||
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||
|
||||
def update_top_panel(self):
|
||||
try:
|
||||
@@ -522,23 +580,40 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
# Start time tracking
|
||||
self._workflow_start_time = time.time()
|
||||
|
||||
# Disable controls during configuration (after validation passes)
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
# Validate modlist name
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
if not modlist_name:
|
||||
MessageService.warning(self, "Missing Name", "Please specify a name for your modlist", safety_level="low")
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
# --- Shortcut creation will be handled by automated workflow ---
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
steamdeck = os.path.exists('/etc/os-release') and 'steamdeck' in open('/etc/os-release').read().lower()
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
steamdeck = platform_service.is_steamdeck
|
||||
shortcut_handler = ShortcutHandler(steamdeck=steamdeck) # Still needed for Steam restart
|
||||
# --- 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"
|
||||
)
|
||||
print(f"DEBUG: Steam restart dialog returned: {reply!r}")
|
||||
|
||||
# Check if auto-restart is enabled
|
||||
auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
|
||||
|
||||
if auto_restart_enabled:
|
||||
# Auto-accept Steam restart - proceed without dialog
|
||||
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):
|
||||
self._enable_controls_after_operation()
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(0)
|
||||
return
|
||||
@@ -562,7 +637,6 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setValue(0)
|
||||
progress.show()
|
||||
self.setEnabled(False)
|
||||
def do_restart():
|
||||
try:
|
||||
ok = shortcut_handler.secure_steam_restart()
|
||||
@@ -579,7 +653,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
if hasattr(self, '_steam_restart_progress'):
|
||||
self._steam_restart_progress.close()
|
||||
del self._steam_restart_progress
|
||||
self.setEnabled(True)
|
||||
self._enable_controls_after_operation()
|
||||
if success:
|
||||
self._safe_append_text("Steam restarted successfully.")
|
||||
|
||||
@@ -651,16 +725,10 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
# Detect Steam Deck once
|
||||
try:
|
||||
import os
|
||||
_is_steamdeck = False
|
||||
if os.path.exists('/etc/os-release'):
|
||||
with open('/etc/os-release') as f:
|
||||
if 'steamdeck' in f.read().lower():
|
||||
_is_steamdeck = True
|
||||
except Exception:
|
||||
_is_steamdeck = False
|
||||
# Detect Steam Deck once using centralized service
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
_is_steamdeck = platform_service.is_steamdeck
|
||||
|
||||
# Create and start the thread
|
||||
self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path, _is_steamdeck)
|
||||
@@ -722,7 +790,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
"""Handle error from the automated prefix workflow"""
|
||||
self._safe_append_text(f"Error during automated Steam setup: {error_message}")
|
||||
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):
|
||||
"""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
|
||||
self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
shortcut_handler = ShortcutHandler(steamdeck=False)
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck)
|
||||
current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path)
|
||||
|
||||
if not current_appid or not current_appid.isdigit():
|
||||
@@ -880,7 +951,12 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
|
||||
# Initialize ModlistHandler with correct parameters
|
||||
path_handler = PathHandler()
|
||||
modlist_handler = ModlistHandler(steamdeck=False, verbose=False)
|
||||
|
||||
# Use centralized Steam Deck detection
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
|
||||
modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
|
||||
|
||||
# Set required properties manually after initialization
|
||||
modlist_handler.modlist_dir = install_dir
|
||||
@@ -962,7 +1038,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
try:
|
||||
# Get resolution from UI
|
||||
resolution = self.resolution_combo.currentText()
|
||||
resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else '2560x1600'
|
||||
resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else None
|
||||
|
||||
# Update the context with the new AppID (same format as manual steps)
|
||||
mo2_exe_path = self.install_dir_edit.text().strip()
|
||||
@@ -1011,7 +1087,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
nexus_api_key='', # Not needed for configuration
|
||||
modlist_value=self.context.get('modlist_value'),
|
||||
modlist_source=self.context.get('modlist_source', 'identifier'),
|
||||
resolution=self.context.get('resolution', '2560x1600'),
|
||||
resolution=self.context.get('resolution') or get_resolution_fallback(None),
|
||||
skip_confirmation=True
|
||||
)
|
||||
|
||||
@@ -1112,7 +1188,7 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
nexus_api_key='', # Not needed for configuration
|
||||
modlist_value='', # Not needed for existing modlist
|
||||
modlist_source='existing',
|
||||
resolution=self.context.get('resolution'),
|
||||
resolution=self.context.get('resolution') or get_resolution_fallback(None),
|
||||
skip_confirmation=True
|
||||
)
|
||||
|
||||
@@ -1162,8 +1238,8 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
|
||||
def on_configuration_complete(self, success, message, modlist_name):
|
||||
"""Handle configuration completion (same as Tuxborn)"""
|
||||
# Always re-enable the start button when workflow completes
|
||||
self.start_btn.setEnabled(True)
|
||||
# Re-enable all controls when workflow completes
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
# Calculate time taken
|
||||
@@ -1185,8 +1261,8 @@ class ConfigureNewModlistScreen(QWidget):
|
||||
|
||||
def on_configuration_error(self, error_message):
|
||||
"""Handle configuration error"""
|
||||
# Re-enable the start button on error
|
||||
self.start_btn.setEnabled(True)
|
||||
# Re-enable all controls on error
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
self._safe_append_text(f"Configuration error: {error_message}")
|
||||
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)
|
||||
dlg.exec()
|
||||
|
||||
def reset_screen_to_defaults(self):
|
||||
"""Reset the screen to default state when navigating back from main menu"""
|
||||
# Reset form fields
|
||||
self.install_dir_edit.setText("/path/to/Modlist/ModOrganizer.exe")
|
||||
|
||||
# Clear console and process monitor
|
||||
self.console.clear()
|
||||
self.process_monitor.clear()
|
||||
|
||||
# Reset resolution combo to saved config preference
|
||||
saved_resolution = self.resolution_service.get_saved_resolution()
|
||||
if saved_resolution:
|
||||
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
|
||||
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
|
||||
self.resolution_combo.setCurrentIndex(resolution_index)
|
||||
elif self.resolution_combo.count() > 0:
|
||||
self.resolution_combo.setCurrentIndex(0) # Fallback to "Leave unchanged"
|
||||
|
||||
# Re-enable controls (in case they were disabled from previous errors)
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up any running threads when the screen is closed"""
|
||||
debug_print("DEBUG: cleanup called - cleaning up threads")
|
||||
|
||||
@@ -355,9 +355,8 @@ class InstallModlistScreen(QWidget):
|
||||
self.online_modlists = {} # {game_type: [modlist_dict, ...]}
|
||||
self.modlist_details = {} # {modlist_name: modlist_dict}
|
||||
|
||||
# Path for workflow log
|
||||
self.modlist_log_path = os.path.expanduser('~/Jackify/logs/Modlist_Install_workflow.log')
|
||||
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
|
||||
# Initialize log path (can be refreshed via refresh_paths method)
|
||||
self.refresh_paths()
|
||||
|
||||
# Initialize services early
|
||||
from jackify.backend.services.api_key_service import APIKeyService
|
||||
@@ -368,6 +367,10 @@ class InstallModlistScreen(QWidget):
|
||||
self.resolution_service = ResolutionService()
|
||||
self.config_handler = ConfigHandler()
|
||||
self.protontricks_service = ProtontricksDetectionService()
|
||||
|
||||
# Somnium guidance tracking
|
||||
self._show_somnium_guidance = False
|
||||
self._somnium_install_dir = None
|
||||
|
||||
# Scroll tracking for professional auto-scroll behavior
|
||||
self._user_manually_scrolled = False
|
||||
@@ -459,11 +462,11 @@ class InstallModlistScreen(QWidget):
|
||||
file_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.file_edit = QLineEdit()
|
||||
self.file_edit.setMinimumWidth(400)
|
||||
file_btn = QPushButton("Browse")
|
||||
file_btn.clicked.connect(self.browse_wabbajack_file)
|
||||
self.file_btn = QPushButton("Browse")
|
||||
self.file_btn.clicked.connect(self.browse_wabbajack_file)
|
||||
file_layout.addWidget(QLabel(".wabbajack File:"))
|
||||
file_layout.addWidget(self.file_edit)
|
||||
file_layout.addWidget(file_btn)
|
||||
file_layout.addWidget(self.file_btn)
|
||||
self.file_group.setLayout(file_layout)
|
||||
file_tab_vbox.addWidget(self.file_group)
|
||||
file_tab.setLayout(file_tab_vbox)
|
||||
@@ -484,22 +487,22 @@ class InstallModlistScreen(QWidget):
|
||||
install_dir_label = QLabel("Install Directory:")
|
||||
self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir())
|
||||
self.install_dir_edit.setMaximumHeight(25) # Force compact height
|
||||
browse_install_btn = QPushButton("Browse")
|
||||
browse_install_btn.clicked.connect(self.browse_install_dir)
|
||||
self.browse_install_btn = QPushButton("Browse")
|
||||
self.browse_install_btn.clicked.connect(self.browse_install_dir)
|
||||
install_dir_hbox = QHBoxLayout()
|
||||
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.addLayout(install_dir_hbox, 1, 1)
|
||||
# Downloads Dir
|
||||
downloads_dir_label = QLabel("Downloads Directory:")
|
||||
self.downloads_dir_edit = QLineEdit(self.config_handler.get_modlist_downloads_base_dir())
|
||||
self.downloads_dir_edit.setMaximumHeight(25) # Force compact height
|
||||
browse_downloads_btn = QPushButton("Browse")
|
||||
browse_downloads_btn.clicked.connect(self.browse_downloads_dir)
|
||||
self.browse_downloads_btn = QPushButton("Browse")
|
||||
self.browse_downloads_btn.clicked.connect(self.browse_downloads_dir)
|
||||
downloads_dir_hbox = QHBoxLayout()
|
||||
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.addLayout(downloads_dir_hbox, 2, 1)
|
||||
# Nexus API Key
|
||||
@@ -603,7 +606,25 @@ class InstallModlistScreen(QWidget):
|
||||
self.resolution_combo.setCurrentIndex(0)
|
||||
# Otherwise, default is 'Leave unchanged' (index 0)
|
||||
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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
form_section_widget.setLayout(form_grid)
|
||||
@@ -723,6 +744,57 @@ class InstallModlistScreen(QWidget):
|
||||
|
||||
# Initialize process tracking
|
||||
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):
|
||||
"""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
|
||||
|
||||
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)
|
||||
# Reload saved API key if available and field is empty
|
||||
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()
|
||||
if saved_key:
|
||||
self.api_key_original_text = saved_key
|
||||
self.api_key_edit.setText(saved_key)
|
||||
self.api_key_is_obfuscated = False # Start unobfuscated
|
||||
# Set checkbox state
|
||||
self.save_api_key_checkbox.setChecked(True)
|
||||
# Start obfuscation timer
|
||||
self.api_key_obfuscation_timer.start(3000)
|
||||
# Always reload saved API key to pick up changes from Settings dialog
|
||||
saved_key = self.api_key_service.get_saved_api_key()
|
||||
if saved_key:
|
||||
self.api_key_original_text = saved_key
|
||||
self.api_key_edit.setText(saved_key)
|
||||
self.api_key_is_obfuscated = False # Start unobfuscated
|
||||
# Set checkbox state
|
||||
self.save_api_key_checkbox.setChecked(True)
|
||||
# Immediately obfuscate saved keys (don't wait 3 seconds)
|
||||
self._obfuscate_api_key()
|
||||
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
|
||||
|
||||
def _load_saved_parent_directories(self):
|
||||
@@ -982,7 +1057,7 @@ class InstallModlistScreen(QWidget):
|
||||
|
||||
def go_back(self):
|
||||
if self.stacked_widget:
|
||||
self.stacked_widget.setCurrentIndex(3) # Return to Modlist Tasks menu
|
||||
self.stacked_widget.setCurrentIndex(self.main_menu_index)
|
||||
|
||||
def update_top_panel(self):
|
||||
try:
|
||||
@@ -1071,7 +1146,7 @@ class InstallModlistScreen(QWidget):
|
||||
self.save_api_key_checkbox.setChecked(False)
|
||||
debug_print("DEBUG: Failed to save API key immediately")
|
||||
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
|
||||
self.save_api_key_checkbox.setChecked(False)
|
||||
else:
|
||||
@@ -1118,6 +1193,9 @@ class InstallModlistScreen(QWidget):
|
||||
if not self._check_protontricks():
|
||||
return
|
||||
|
||||
# Disable all controls during installation (except Cancel)
|
||||
self._disable_controls_during_operation()
|
||||
|
||||
try:
|
||||
tab_index = self.source_tabs.currentIndex()
|
||||
install_mode = 'online'
|
||||
@@ -1125,12 +1203,14 @@ class InstallModlistScreen(QWidget):
|
||||
modlist = self.file_edit.text().strip()
|
||||
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.")
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
install_mode = 'file'
|
||||
else:
|
||||
modlist = self.modlist_btn.text().strip()
|
||||
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.")
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
|
||||
# For online modlists, use machine_url instead of display name
|
||||
@@ -1156,6 +1236,7 @@ class InstallModlistScreen(QWidget):
|
||||
missing_fields.append("Nexus API Key")
|
||||
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))
|
||||
self._enable_controls_after_operation()
|
||||
return
|
||||
validation_handler = ValidationHandler()
|
||||
from pathlib import Path
|
||||
@@ -1279,7 +1360,8 @@ class InstallModlistScreen(QWidget):
|
||||
'oblivion': 'oblivion',
|
||||
'starfield': 'starfield',
|
||||
'oblivion_remastered': 'oblivion_remastered',
|
||||
'enderal': 'enderal'
|
||||
'enderal': 'enderal',
|
||||
'enderal special edition': 'enderal'
|
||||
}
|
||||
game_type = game_mapping.get(game_name.lower())
|
||||
debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
|
||||
@@ -1296,6 +1378,7 @@ class InstallModlistScreen(QWidget):
|
||||
|
||||
# Check if game is supported
|
||||
debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported")
|
||||
debug_print(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
|
||||
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
|
||||
debug_print(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
|
||||
|
||||
@@ -1321,14 +1404,11 @@ class InstallModlistScreen(QWidget):
|
||||
debug_print(f"DEBUG: Exception in validate_and_start_install: {e}")
|
||||
import traceback
|
||||
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
# Re-enable the button in case of exception
|
||||
self.start_btn.setEnabled(True)
|
||||
# Re-enable all controls after exception
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
# Also re-enable the entire widget
|
||||
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
|
||||
debug_print(f"DEBUG: Controls re-enabled in exception handler")
|
||||
|
||||
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')
|
||||
@@ -1498,12 +1578,21 @@ class InstallModlistScreen(QWidget):
|
||||
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}")
|
||||
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
|
||||
)
|
||||
# Check if auto-restart is enabled
|
||||
auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
|
||||
|
||||
if auto_restart_enabled:
|
||||
# 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:
|
||||
# --- Create Steam shortcut BEFORE restarting Steam ---
|
||||
# Proceed directly to automated prefix creation
|
||||
@@ -1519,6 +1608,8 @@ class InstallModlistScreen(QWidget):
|
||||
"You can manually add the modlist to Steam later if desired.",
|
||||
safety_level="medium"
|
||||
)
|
||||
# Re-enable controls since operation is complete
|
||||
self._enable_controls_after_operation()
|
||||
else:
|
||||
# Check for user cancellation first
|
||||
last_output = self.console.toPlainText()
|
||||
@@ -1608,9 +1699,6 @@ class InstallModlistScreen(QWidget):
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setValue(0)
|
||||
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():
|
||||
debug_print("DEBUG: do_restart thread started - using direct backend service")
|
||||
@@ -1648,9 +1736,7 @@ class InstallModlistScreen(QWidget):
|
||||
finally:
|
||||
self._steam_restart_progress = None
|
||||
|
||||
self.setEnabled(True)
|
||||
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
|
||||
# Controls are managed by the proper control management system
|
||||
if success:
|
||||
self._safe_append_text("Steam restarted successfully.")
|
||||
|
||||
@@ -1660,7 +1746,14 @@ class InstallModlistScreen(QWidget):
|
||||
|
||||
# Save resolution for later use in configuration
|
||||
resolution = self.resolution_combo.currentText()
|
||||
self._current_resolution = resolution.split()[0] if resolution != "Leave unchanged" else "2560x1600"
|
||||
# Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)")
|
||||
if resolution != "Leave unchanged":
|
||||
if " (" in resolution:
|
||||
self._current_resolution = resolution.split(" (")[0]
|
||||
else:
|
||||
self._current_resolution = resolution
|
||||
else:
|
||||
self._current_resolution = None
|
||||
|
||||
# Use automated prefix creation instead of manual steps
|
||||
debug_print("DEBUG: Starting automated prefix creation workflow")
|
||||
@@ -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.")
|
||||
|
||||
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"""
|
||||
try:
|
||||
# Disable controls during installation
|
||||
self._disable_controls_during_operation()
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
final_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
||||
|
||||
if not os.path.exists(final_exe_path):
|
||||
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
|
||||
MessageService.critical(self, "ModOrganizer.exe Not Found",
|
||||
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
|
||||
return
|
||||
# Check if this is Somnium specifically (uses files/ subdirectory)
|
||||
modlist_name_lower = modlist_name.lower()
|
||||
if "somnium" in modlist_name_lower:
|
||||
somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
|
||||
if os.path.exists(somnium_exe_path):
|
||||
final_exe_path = somnium_exe_path
|
||||
self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup")
|
||||
# Show Somnium guidance popup after automated workflow completes
|
||||
self._show_somnium_guidance = True
|
||||
self._somnium_install_dir = install_dir
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}")
|
||||
MessageService.critical(self, "Somnium ModOrganizer.exe Not Found",
|
||||
f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.")
|
||||
return
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}")
|
||||
MessageService.critical(self, "ModOrganizer.exe Not Found",
|
||||
f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.")
|
||||
return
|
||||
|
||||
# Run automated prefix creation in separate thread
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
@@ -1781,33 +1903,43 @@ class InstallModlistScreen(QWidget):
|
||||
import traceback
|
||||
debug_print(f"DEBUG: Traceback: {traceback.format_exc()}")
|
||||
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):
|
||||
"""Handle completion of automated prefix creation"""
|
||||
if success:
|
||||
debug_print(f"SUCCESS: Automated prefix creation completed!")
|
||||
debug_print(f"Prefix created at: {prefix_path}")
|
||||
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
|
||||
|
||||
# Continue with configuration using the new AppID and timestamp
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: Automated prefix creation failed")
|
||||
self._safe_append_text("Please check the logs for details")
|
||||
MessageService.critical(self, "Automated Setup Failed",
|
||||
"Automated prefix creation failed. Please check the console output for details.")
|
||||
try:
|
||||
if success:
|
||||
debug_print(f"SUCCESS: Automated prefix creation completed!")
|
||||
debug_print(f"Prefix created at: {prefix_path}")
|
||||
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
|
||||
|
||||
# Continue with configuration using the new AppID and timestamp
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp)
|
||||
else:
|
||||
self._safe_append_text(f"ERROR: Automated prefix creation failed")
|
||||
self._safe_append_text("Please check the logs 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):
|
||||
"""Handle error in automated prefix creation"""
|
||||
self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}")
|
||||
MessageService.critical(self, "Automated Setup Error",
|
||||
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):
|
||||
"""Handle progress updates from automated prefix creation"""
|
||||
@@ -1828,7 +1960,6 @@ class InstallModlistScreen(QWidget):
|
||||
self.steam_restart_progress.setMinimumDuration(0)
|
||||
self.steam_restart_progress.setValue(0)
|
||||
self.steam_restart_progress.show()
|
||||
self.setEnabled(False)
|
||||
|
||||
def hide_steam_restart_progress(self):
|
||||
"""Hide Steam restart progress dialog"""
|
||||
@@ -1840,45 +1971,57 @@ class InstallModlistScreen(QWidget):
|
||||
pass
|
||||
finally:
|
||||
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):
|
||||
"""Handle configuration completion on main thread"""
|
||||
if success:
|
||||
# Show celebration SuccessDialog after the entire workflow
|
||||
from ..dialogs import SuccessDialog
|
||||
import time
|
||||
if not hasattr(self, '_install_workflow_start_time'):
|
||||
self._install_workflow_start_time = time.time()
|
||||
time_taken = int(time.time() - self._install_workflow_start_time)
|
||||
mins, secs = divmod(time_taken, 60)
|
||||
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
game_name = display_names.get(self._current_game_type, self._current_game_name)
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="install",
|
||||
time_taken=time_str,
|
||||
game_name=game_name,
|
||||
parent=self
|
||||
)
|
||||
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.")
|
||||
try:
|
||||
# Re-enable controls now that installation/configuration is complete
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
if success:
|
||||
# Check if we need to show Somnium guidance
|
||||
if self._show_somnium_guidance:
|
||||
self._show_somnium_post_install_guidance()
|
||||
|
||||
# Show celebration SuccessDialog after the entire workflow
|
||||
from ..dialogs import SuccessDialog
|
||||
import time
|
||||
if not hasattr(self, '_install_workflow_start_time'):
|
||||
self._install_workflow_start_time = time.time()
|
||||
time_taken = int(time.time() - self._install_workflow_start_time)
|
||||
mins, secs = divmod(time_taken, 60)
|
||||
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
|
||||
display_names = {
|
||||
'skyrim': 'Skyrim',
|
||||
'fallout4': 'Fallout 4',
|
||||
'falloutnv': 'Fallout New Vegas',
|
||||
'oblivion': 'Oblivion',
|
||||
'starfield': 'Starfield',
|
||||
'oblivion_remastered': 'Oblivion Remastered',
|
||||
'enderal': 'Enderal'
|
||||
}
|
||||
game_name = display_names.get(self._current_game_type, self._current_game_name)
|
||||
success_dialog = SuccessDialog(
|
||||
modlist_name=modlist_name,
|
||||
workflow_type="install",
|
||||
time_taken=time_str,
|
||||
game_name=game_name,
|
||||
parent=self
|
||||
)
|
||||
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
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
@@ -1898,7 +2041,10 @@ class InstallModlistScreen(QWidget):
|
||||
"""Handle configuration error on main thread"""
|
||||
self._safe_append_text(f"Configuration failed with error: {error_message}")
|
||||
MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}")
|
||||
|
||||
|
||||
# Re-enable all controls on error
|
||||
self._enable_controls_after_operation()
|
||||
|
||||
# Clean up thread
|
||||
if hasattr(self, 'config_thread') and self.config_thread is not None:
|
||||
# Disconnect all signals to prevent "Internal C++ object already deleted" errors
|
||||
@@ -1937,16 +2083,25 @@ class InstallModlistScreen(QWidget):
|
||||
else:
|
||||
# User clicked Cancel or closed the dialog - cancel the workflow
|
||||
self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.")
|
||||
# Reset button states
|
||||
self.start_btn.setEnabled(True)
|
||||
# Re-enable all controls when workflow is cancelled
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
|
||||
def _get_mo2_path(self, install_dir, modlist_name):
|
||||
"""Get ModOrganizer.exe path, handling Somnium's non-standard structure"""
|
||||
mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
||||
if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower():
|
||||
somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe")
|
||||
if os.path.exists(somnium_path):
|
||||
mo2_exe_path = somnium_path
|
||||
return mo2_exe_path
|
||||
|
||||
def validate_manual_steps_completion(self):
|
||||
"""Validate that manual steps were actually completed and handle retry logic"""
|
||||
modlist_name = self.modlist_name_edit.text().strip()
|
||||
install_dir = self.install_dir_edit.text().strip()
|
||||
mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe")
|
||||
mo2_exe_path = self._get_mo2_path(install_dir, modlist_name)
|
||||
|
||||
# Add delay to allow Steam filesystem updates to complete
|
||||
self._safe_append_text("Waiting for Steam filesystem updates to complete...")
|
||||
@@ -1957,7 +2112,10 @@ class InstallModlistScreen(QWidget):
|
||||
# Steam assigns a NEW AppID during restart, different from the one we initially created
|
||||
self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...")
|
||||
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
|
||||
shortcut_handler = ShortcutHandler(steamdeck=False)
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck)
|
||||
current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path)
|
||||
|
||||
if not current_appid or not current_appid.isdigit():
|
||||
@@ -1978,7 +2136,12 @@ class InstallModlistScreen(QWidget):
|
||||
|
||||
# Initialize ModlistHandler with correct parameters
|
||||
path_handler = PathHandler()
|
||||
modlist_handler = ModlistHandler(steamdeck=False, verbose=False)
|
||||
|
||||
# Use centralized Steam Deck detection
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
|
||||
modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False)
|
||||
|
||||
# Set required properties manually after initialization
|
||||
modlist_handler.modlist_dir = install_dir
|
||||
@@ -2184,10 +2347,10 @@ class InstallModlistScreen(QWidget):
|
||||
updated_context = {
|
||||
'name': modlist_name,
|
||||
'path': install_dir,
|
||||
'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"),
|
||||
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
||||
'modlist_value': None,
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', '2560x1600'),
|
||||
'resolution': getattr(self, '_current_resolution', None),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed since automated prefix is done
|
||||
'appid': new_appid, # Use the NEW AppID from automated prefix creation
|
||||
@@ -2196,15 +2359,21 @@ class InstallModlistScreen(QWidget):
|
||||
self.context = updated_context # Ensure context is always set
|
||||
debug_print(f"Updated context with new AppID: {new_appid}")
|
||||
|
||||
# Get Steam Deck detection once and pass to ConfigThread
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
is_steamdeck = platform_service.is_steamdeck
|
||||
|
||||
# Create new config thread with updated context
|
||||
class ConfigThread(QThread):
|
||||
progress_update = Signal(str)
|
||||
configuration_complete = Signal(bool, str, str)
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, context):
|
||||
|
||||
def __init__(self, context, is_steamdeck):
|
||||
super().__init__()
|
||||
self.context = context
|
||||
self.is_steamdeck = is_steamdeck
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
@@ -2213,8 +2382,8 @@ class InstallModlistScreen(QWidget):
|
||||
from jackify.backend.models.modlist import ModlistContext
|
||||
from pathlib import Path
|
||||
|
||||
# Initialize backend service
|
||||
system_info = SystemInfo(is_steamdeck=False)
|
||||
# Initialize backend service with passed Steam Deck detection
|
||||
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
|
||||
modlist_service = ModlistService(system_info)
|
||||
|
||||
# Convert context to ModlistContext for service
|
||||
@@ -2226,7 +2395,7 @@ class InstallModlistScreen(QWidget):
|
||||
nexus_api_key='', # Not needed for configuration
|
||||
modlist_value=self.context.get('modlist_value'),
|
||||
modlist_source=self.context.get('modlist_source', 'identifier'),
|
||||
resolution=self.context.get('resolution', '2560x1600'),
|
||||
resolution=self.context.get('resolution'),
|
||||
skip_confirmation=True,
|
||||
engine_installed=True # Skip path manipulation for engine workflows
|
||||
)
|
||||
@@ -2245,7 +2414,7 @@ class InstallModlistScreen(QWidget):
|
||||
# This shouldn't happen since automated prefix creation is complete
|
||||
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
|
||||
|
||||
# Call the service method for post-Steam configuration
|
||||
# Call the service method for post-Steam configuration
|
||||
result = modlist_service.configure_modlist_post_steam(
|
||||
context=modlist_context,
|
||||
progress_callback=progress_callback,
|
||||
@@ -2261,7 +2430,7 @@ class InstallModlistScreen(QWidget):
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
# Start configuration thread
|
||||
self.config_thread = ConfigThread(updated_context)
|
||||
self.config_thread = ConfigThread(updated_context, is_steamdeck)
|
||||
self.config_thread.progress_update.connect(self.on_configuration_progress)
|
||||
self.config_thread.configuration_complete.connect(self.on_configuration_complete)
|
||||
self.config_thread.error_occurred.connect(self.on_configuration_error)
|
||||
@@ -2282,10 +2451,10 @@ class InstallModlistScreen(QWidget):
|
||||
updated_context = {
|
||||
'name': modlist_name,
|
||||
'path': install_dir,
|
||||
'mo2_exe_path': os.path.join(install_dir, "ModOrganizer.exe"),
|
||||
'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name),
|
||||
'modlist_value': None,
|
||||
'modlist_source': None,
|
||||
'resolution': getattr(self, '_current_resolution', '2560x1600'),
|
||||
'resolution': getattr(self, '_current_resolution', None),
|
||||
'skip_confirmation': True,
|
||||
'manual_steps_completed': True, # Mark as completed
|
||||
'appid': new_appid # Use the NEW AppID from Steam
|
||||
@@ -2322,15 +2491,21 @@ class InstallModlistScreen(QWidget):
|
||||
def _create_config_thread(self, context):
|
||||
"""Create a new ConfigThread with proper lifecycle management"""
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
|
||||
# Get Steam Deck detection once
|
||||
from jackify.backend.services.platform_detection_service import PlatformDetectionService
|
||||
platform_service = PlatformDetectionService.get_instance()
|
||||
is_steamdeck = platform_service.is_steamdeck
|
||||
|
||||
class ConfigThread(QThread):
|
||||
progress_update = Signal(str)
|
||||
configuration_complete = Signal(bool, str, str)
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, context, parent=None):
|
||||
|
||||
def __init__(self, context, is_steamdeck, parent=None):
|
||||
super().__init__(parent)
|
||||
self.context = context
|
||||
self.is_steamdeck = is_steamdeck
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
@@ -2339,8 +2514,8 @@ class InstallModlistScreen(QWidget):
|
||||
from jackify.backend.models.modlist import ModlistContext
|
||||
from pathlib import Path
|
||||
|
||||
# Initialize backend service
|
||||
system_info = SystemInfo(is_steamdeck=False)
|
||||
# Initialize backend service with passed Steam Deck detection
|
||||
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
|
||||
modlist_service = ModlistService(system_info)
|
||||
|
||||
# Convert context to ModlistContext for service
|
||||
@@ -2389,7 +2564,7 @@ class InstallModlistScreen(QWidget):
|
||||
self.progress_update.emit(f"DEBUG: {error_details}")
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
return ConfigThread(context, parent=self)
|
||||
return ConfigThread(context, is_steamdeck, parent=self)
|
||||
|
||||
def handle_validation_failure(self, missing_text):
|
||||
"""Handle failed validation with retry logic"""
|
||||
@@ -2510,18 +2685,68 @@ class InstallModlistScreen(QWidget):
|
||||
# Cleanup any remaining processes
|
||||
self.cleanup_processes()
|
||||
|
||||
# Reset button states
|
||||
self.start_btn.setEnabled(True)
|
||||
# Reset button states and re-enable all controls
|
||||
self._enable_controls_after_operation()
|
||||
self.cancel_btn.setVisible(True)
|
||||
self.cancel_install_btn.setVisible(False)
|
||||
|
||||
self._safe_append_text("Installation cancelled by user.")
|
||||
|
||||
def _show_somnium_post_install_guidance(self):
|
||||
"""Show guidance popup for Somnium post-installation steps"""
|
||||
from ..widgets.message_service import MessageService
|
||||
|
||||
guidance_text = f"""<b>Somnium Post-Installation Required</b><br><br>
|
||||
Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:<br><br>
|
||||
<b>1.</b> Launch the Steam shortcut created for Somnium<br>
|
||||
<b>2.</b> In ModOrganizer, go to Settings → Executables<br>
|
||||
<b>3.</b> For each executable entry (SKSE64, etc.), update the binary path to point to:<br>
|
||||
<code>{self._somnium_install_dir}/files/root/Enderal Special Edition/skse64_loader.exe</code><br><br>
|
||||
<b>Note:</b> Full Somnium support will be added in a future Jackify update.<br><br>
|
||||
<i>You can also refer to the Somnium installation guide at:<br>
|
||||
https://wiki.scenicroute.games/Somnium/1_Installation.html</i>"""
|
||||
|
||||
MessageService.information(self, "Somnium Setup Required", guidance_text)
|
||||
|
||||
# Reset the guidance flag
|
||||
self._show_somnium_guidance = False
|
||||
self._somnium_install_dir = None
|
||||
|
||||
def cancel_and_cleanup(self):
|
||||
"""Handle Cancel button - clean up processes and go back"""
|
||||
self.cleanup_processes()
|
||||
self.go_back()
|
||||
|
||||
def 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):
|
||||
"""Handle window close event - clean up processes"""
|
||||
self.cleanup_processes()
|
||||
|
||||
@@ -120,7 +120,7 @@ class MainMenu(QWidget):
|
||||
msg.setIcon(QMessageBox.Information)
|
||||
msg.exec()
|
||||
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":
|
||||
# This is the main menu, so do nothing
|
||||
pass
|
||||
|
||||
@@ -198,11 +198,11 @@ class ModlistTasksScreen(QWidget):
|
||||
if action_id == "return_main_menu":
|
||||
self.stacked_widget.setCurrentIndex(0)
|
||||
elif action_id == "install_modlist":
|
||||
self.stacked_widget.setCurrentIndex(4)
|
||||
self.stacked_widget.setCurrentIndex(3)
|
||||
elif action_id == "configure_new_modlist":
|
||||
self.stacked_widget.setCurrentIndex(5)
|
||||
self.stacked_widget.setCurrentIndex(4)
|
||||
elif action_id == "configure_existing_modlist":
|
||||
self.stacked_widget.setCurrentIndex(6)
|
||||
self.stacked_widget.setCurrentIndex(5)
|
||||
|
||||
def go_back(self):
|
||||
"""Return to main menu"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,7 @@ class UnsupportedGameDialog(QDialog):
|
||||
<li><strong>Oblivion</strong></li>
|
||||
<li><strong>Starfield</strong></li>
|
||||
<li><strong>Oblivion Remastered</strong></li>
|
||||
<li><strong>Enderal</strong></li>
|
||||
</ul>
|
||||
|
||||
<p>For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.</p>
|
||||
@@ -113,6 +114,7 @@ class UnsupportedGameDialog(QDialog):
|
||||
<li><strong>Oblivion</strong></li>
|
||||
<li><strong>Starfield</strong></li>
|
||||
<li><strong>Oblivion Remastered</strong></li>
|
||||
<li><strong>Enderal</strong></li>
|
||||
</ul>
|
||||
|
||||
<p>For unsupported games, you will need to manually configure Steam shortcuts and other post-install steps.</p>
|
||||
|
||||
97
jackify/shared/appimage_utils.py
Normal file
97
jackify/shared/appimage_utils.py
Normal 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
|
||||
@@ -14,15 +14,21 @@ import shutil
|
||||
class LoggingHandler:
|
||||
"""
|
||||
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).
|
||||
- Handles log rotation and log directory creation.
|
||||
Usage:
|
||||
logger = LoggingHandler().setup_logger('install_wabbajack', 'jackify-install-wabbajack.log')
|
||||
"""
|
||||
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()
|
||||
|
||||
@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:
|
||||
"""Ensure the log directory exists."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
113
jackify/shared/resolution_utils.py
Normal file
113
jackify/shared/resolution_utils.py
Normal 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
|
||||
@@ -51,9 +51,15 @@ def _clear_screen_fallback():
|
||||
|
||||
def print_jackify_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 ║
|
||||
║ & associated utilities on Linux ║
|
||||
|
||||
@@ -222,15 +222,21 @@ class ValidationHandler:
|
||||
def validate_steam_shortcut(self, app_id: str) -> Tuple[bool, str]:
|
||||
"""Validate a Steam shortcut."""
|
||||
try:
|
||||
# Check if shortcuts.vdf exists
|
||||
shortcuts_path = Path.home() / '.steam' / 'steam' / 'userdata' / '75424832' / 'config' / 'shortcuts.vdf'
|
||||
# Use native Steam service to get proper shortcuts.vdf path with multi-user support
|
||||
from jackify.backend.services.native_steam_service import NativeSteamService
|
||||
steam_service = NativeSteamService()
|
||||
shortcuts_path = steam_service.get_shortcuts_vdf_path()
|
||||
|
||||
if not shortcuts_path:
|
||||
return False, "Could not determine shortcuts.vdf path (no active Steam user found)"
|
||||
|
||||
if not shortcuts_path.exists():
|
||||
return False, "shortcuts.vdf not found"
|
||||
|
||||
|
||||
# Check if shortcuts.vdf is accessible
|
||||
if not os.access(shortcuts_path, os.R_OK | os.W_OK):
|
||||
return False, "shortcuts.vdf is not accessible"
|
||||
|
||||
|
||||
# Parse shortcuts.vdf using VDFHandler
|
||||
shortcuts_data = VDFHandler.load(str(shortcuts_path), binary=True)
|
||||
|
||||
|
||||
BIN
jackify/tools/cabextract
Executable file
BIN
jackify/tools/cabextract
Executable file
Binary file not shown.
19627
jackify/tools/winetricks
Executable file
19627
jackify/tools/winetricks
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user